mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
daemon: canonical scope-membership matcher (VM-003 + VM-004)
One shared, typed-tag-authoritative membership predicate replaces the four divergent scope matchers in the review pipeline. New pure helper parseScopeTags (domain/slices/qitem-membership.ts): JSON array parsed per element, comma-legacy-aware, element-level-trim-only, exact prefix match. Consumers rewired to it: agentsForSlices (+ mission-tag-direct via missionName option, pm ruling i), matchQitems (unquoted LIKE as prefilter + row-level confirm; substring fallback tier now gated behind zero CONFIRMED typed rows — the VM-004 fix), hasActiveQitem leg 1, attentionForTag (+ d2 excludeTagPrefix aligned to the canonical slice set). composeAgents mission branch + gatherMission pass missionName so a dir-exists/zero-indexed mission no longer composes a confident-empty band. LIMIT 500 / DESC / legacy zero-typed behavior preserved. Daemon-only; UI untouched; compose.ts wording unchanged (C4 no-change). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
79b141f8a1
commit
302036aa6c
6 files changed
+481
-55
No files matched your search
@@ -14,6 +14,7 @@ import YAML from "yaml";
|
||||
import type Database from "better-sqlite3";
|
||||
import { sessionMemberLabel } from "../session-name.js";
|
||||
import type { SliceIndexer, SliceRecord } from "../slices/slice-indexer.js";
|
||||
import { parseScopeTags } from "../slices/qitem-membership.js";
|
||||
import {
|
||||
composeMissionReview,
|
||||
composeRecordedGreenForSlice,
|
||||
@@ -143,7 +144,7 @@ export class ReviewGatherer {
|
||||
mission: { name: mission, id: missionMeta.id, title: missionMeta.title, intent: missionMeta.intent },
|
||||
slices: composed,
|
||||
missionAttention: this.attentionForTag(`mission:${mission}`, `slice:`),
|
||||
agents: this.agentsForSlices(slices.map((s) => s.name)),
|
||||
agents: this.agentsForSlices(slices.map((s) => s.name), { missionName: mission }),
|
||||
nowIso,
|
||||
});
|
||||
}
|
||||
@@ -189,7 +190,16 @@ export class ReviewGatherer {
|
||||
const mission = scope.slice("mission:".length);
|
||||
const slices = this.indexer.list().filter((s) => s.missionId === mission);
|
||||
if (slices.length === 0 && !this.missionDirExists(mission)) return null;
|
||||
return composeAgentsBand(this.agentsForSlices(slices.map((s) => s.name)), scope, [], nowIso);
|
||||
// C3 (pm ruling i): mission-tag-DIRECT membership — `mission:X`-tagged
|
||||
// active work counts even when its slice tag isn't indexed, so a
|
||||
// dir-exists/zero-indexed mission no longer composes a confident-empty
|
||||
// band from an always-empty name list.
|
||||
return composeAgentsBand(
|
||||
this.agentsForSlices(slices.map((s) => s.name), { missionName: mission }),
|
||||
scope,
|
||||
[],
|
||||
nowIso,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -464,6 +474,19 @@ export class ReviewGatherer {
|
||||
|
||||
private attentionForTag(tag: string, excludeTagPrefix?: string): AttentionInput[] {
|
||||
if (!this.tableExists("queue_items")) return [];
|
||||
// Canonical membership: parse the queried tag once; the SQL LIKE is an
|
||||
// unquoted PREFILTER (now catches comma-legacy rows), and parseScopeTags
|
||||
// is the authoritative row-level confirm (rejects the prefilter's
|
||||
// substring/suffix over-matches). attentionForTag's real defect was the
|
||||
// comma-legacy UNDER-match — the old JSON-quoted `%"slice:X"%` never
|
||||
// appears inside a comma-joined element.
|
||||
const tagIsSlice = tag.startsWith("slice:");
|
||||
const tagIsMission = tag.startsWith("mission:");
|
||||
const tagName = tagIsSlice
|
||||
? tag.slice("slice:".length)
|
||||
: tagIsMission
|
||||
? tag.slice("mission:".length)
|
||||
: null;
|
||||
const hasEvidenceRef = this.columnExists("queue_items", "evidence_ref");
|
||||
const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary";
|
||||
const rows = this.db
|
||||
@@ -477,16 +500,27 @@ export class ReviewGatherer {
|
||||
OR destination_session LIKE 'human%'
|
||||
OR (state = 'blocked' AND blocked_on LIKE 'human%'))`,
|
||||
)
|
||||
.all(...ACTIVE_STATES, `%"${tag}"%`) as Array<QitemRow & { evidence_ref?: string | null }>;
|
||||
.all(...ACTIVE_STATES, `%${tag}%`) as Array<QitemRow & { evidence_ref?: string | null }>;
|
||||
return rows
|
||||
.filter((r) => {
|
||||
if (!excludeTagPrefix) return true;
|
||||
try {
|
||||
const tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
|
||||
return !tags.some((t) => t.startsWith(excludeTagPrefix));
|
||||
} catch {
|
||||
return true;
|
||||
// Row-level canonical confirm (authoritative over the prefilter).
|
||||
const scopes = parseScopeTags(r.tags);
|
||||
if (tagIsSlice && !scopes.slices.has(tagName!)) return false;
|
||||
if (tagIsMission && !scopes.missions.has(tagName!)) return false;
|
||||
// d2: excludeTagPrefix aligned to the canonical set (its documented
|
||||
// intent — "rows that carry a slice tag") so comma-legacy rows are
|
||||
// excluded like clean ones; unknown prefixes keep raw semantics.
|
||||
if (excludeTagPrefix === "slice:") return scopes.slices.size === 0;
|
||||
if (excludeTagPrefix === "mission:") return scopes.missions.size === 0;
|
||||
if (excludeTagPrefix) {
|
||||
try {
|
||||
const tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
|
||||
return !tags.some((t) => t.startsWith(excludeTagPrefix));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((r) => {
|
||||
// OPR.0.4.6.WF4 Q6 — stamp the ● workflow pointer from the item's own
|
||||
@@ -512,7 +546,11 @@ export class ReviewGatherer {
|
||||
/** Sessions holding active work on the named slices (null = rig-wide).
|
||||
* Region membership derives from work-on-scope, never rig co-residency.
|
||||
* Runtime/idle telemetry is honest-unknown at v1 (queue-derived only). */
|
||||
private agentsForSlices(sliceNames: string[] | null): AgentInput[] {
|
||||
private agentsForSlices(
|
||||
sliceNames: string[] | null,
|
||||
opts: { missionName?: string | null } = {},
|
||||
): AgentInput[] {
|
||||
const missionName = opts.missionName ?? null;
|
||||
if (!this.tableExists("queue_items")) return [];
|
||||
const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary";
|
||||
const rows = this.db
|
||||
@@ -526,14 +564,19 @@ export class ReviewGatherer {
|
||||
const bySession = new Map<string, { rows: QitemRow[]; slices: Set<string> }>();
|
||||
for (const r of rows) {
|
||||
if (isHumanSeatSession(r.destination_session)) continue;
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
const rowSlices = tags.filter((t) => t.startsWith("slice:")).map((t) => t.slice("slice:".length));
|
||||
if (sliceNames !== null && !rowSlices.some((s) => sliceNames.includes(s))) continue;
|
||||
const scopes = parseScopeTags(r.tags);
|
||||
const rowSlices = [...scopes.slices];
|
||||
// Membership (canonical): a row belongs when it carries one of the
|
||||
// named slices OR — for the mission band (C3, pm ruling i) — the
|
||||
// mission tag directly, so `mission:X`-tagged work counts even when
|
||||
// its slice tag isn't indexed. missionName is null for rig/slice
|
||||
// flows (byte-identical to pre-fix).
|
||||
if (
|
||||
sliceNames !== null &&
|
||||
!rowSlices.some((s) => sliceNames.includes(s)) &&
|
||||
!(missionName !== null && scopes.missions.has(missionName))
|
||||
)
|
||||
continue;
|
||||
const entry = bySession.get(r.destination_session) ?? { rows: [], slices: new Set<string>() };
|
||||
entry.rows.push(r);
|
||||
for (const s of rowSlices) entry.slices.add(s);
|
||||
@@ -780,12 +823,18 @@ export class ReviewGatherer {
|
||||
|
||||
private hasActiveQitem(name: string, slice: SliceRecord): boolean {
|
||||
if (!this.tableExists("queue_items")) return false;
|
||||
const row = this.db
|
||||
// Leg 1 (canonical): unquoted prefilter now catches comma-legacy rows;
|
||||
// each candidate is confirmed against the shared membership predicate,
|
||||
// short-circuiting on the first true. Post-fix, leg 2 (qitemIds, which
|
||||
// inherits matchQitems) converges on the same answer.
|
||||
const prefiltered = this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ? LIMIT 1`,
|
||||
`SELECT tags FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ?`,
|
||||
)
|
||||
.get(...ACTIVE_STATES, `%"slice:${name}"%`);
|
||||
if (row) return true;
|
||||
.all(...ACTIVE_STATES, `%slice:${name}%`) as Array<{ tags: string | null }>;
|
||||
for (const r of prefiltered) {
|
||||
if (parseScopeTags(r.tags).slices.has(name)) return true;
|
||||
}
|
||||
if (slice.qitemIds.length === 0) return false;
|
||||
const placeholders = slice.qitemIds.map(() => "?").join(",");
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Canonical scope-membership matcher (VM-003 + VM-004).
|
||||
//
|
||||
// "Which qitems belong to scope X" was answered FOUR inconsistent ways in
|
||||
// the review pipeline (matchQitems substring > hasActiveQitem JSON-LIKE >
|
||||
// attentionForTag JSON-LIKE > agentsForSlices exact-equality). This module
|
||||
// is the ONE canonical membership predicate every consumer shares: the
|
||||
// documented matchQitems contract (typed tags authoritative, per JSON
|
||||
// element, comma-legacy-aware) made canonical.
|
||||
|
||||
export interface QitemScopeTags {
|
||||
slices: Set<string>;
|
||||
missions: Set<string>;
|
||||
}
|
||||
|
||||
/** Canonical scope membership from a qitem's raw tags column.
|
||||
* Typed tags AUTHORITATIVE: JSON array parsed per element; each element
|
||||
* comma-split (the legacy CLI form, per the matchQitems contract comment)
|
||||
* and trimmed; exact `slice:`/`mission:` prefix match per token. No
|
||||
* substring semantics live here — the legacy substring tier remains a
|
||||
* matchQitems-local concern behind its zero-typed-rows condition. */
|
||||
export function parseScopeTags(rawTags: string | null | undefined): QitemScopeTags {
|
||||
const slices = new Set<string>();
|
||||
const missions = new Set<string>();
|
||||
if (rawTags == null) return { slices, missions };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawTags);
|
||||
} catch {
|
||||
// Malformed JSON -> empty sets (parity with today's catch-blocks).
|
||||
return { slices, missions };
|
||||
}
|
||||
if (!Array.isArray(parsed)) return { slices, missions };
|
||||
|
||||
for (const element of parsed) {
|
||||
if (typeof element !== "string") continue;
|
||||
// Comma-split the element (the legacy CLI form matchQitems' comment
|
||||
// names) and trim at the ELEMENT/token level ONLY — never after the
|
||||
// prefix. `slice: X` (space after the colon) therefore yields the name
|
||||
// ` X`, not `X`: it is NOT a membership. WHY: the unquoted SQL prefilter
|
||||
// `LIKE '%slice:<name>%'` does not match the raw string `slice: X`, so
|
||||
// trimming post-prefix would accept a row the prefilter cannot see — an
|
||||
// under-selecting prefilter, the exact invariant breach. With token-only
|
||||
// trim, any accepted element literally CONTAINS the prefilter needle, so
|
||||
// never-under-select holds by construction. (Case/name-drift is NOT
|
||||
// normalized in v1 — exact, case-sensitive match.)
|
||||
for (const rawToken of element.split(",")) {
|
||||
const token = rawToken.trim();
|
||||
if (token.startsWith("slice:")) {
|
||||
slices.add(token.slice("slice:".length));
|
||||
} else if (token.startsWith("mission:")) {
|
||||
missions.add(token.slice("mission:".length));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { slices, missions };
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { parseScopeTags } from "./qitem-membership.js";
|
||||
|
||||
export type SliceStatus = "active" | "done" | "blocked" | "draft";
|
||||
|
||||
@@ -451,37 +452,49 @@ export class SliceIndexer {
|
||||
let typedTagMatchCount = 0;
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE tags LIKE ? LIMIT 500`,
|
||||
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
typedTagMatchCount = rows.length;
|
||||
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE ? LIMIT 500`,
|
||||
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string; tags: string | null }>;
|
||||
// The unquoted LIKE stays a PREFILTER (catches comma-legacy AND the
|
||||
// suffix/sibling over-matches); parseScopeTags is the authoritative
|
||||
// row-level confirm. typedTagMatchCount counts CONFIRMED rows only,
|
||||
// so the substring gate below reflects real typed membership — this
|
||||
// kills the VM-004 `slice:X-suffix` over-match at the typed tier.
|
||||
for (const r of rows) {
|
||||
if (parseScopeTags(r.tags).slices.has(sliceName)) {
|
||||
ids.add(r.qitem_id);
|
||||
typedTagMatchCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// queue_items present but `tags` column missing (older test
|
||||
// harness): fall through to substring path with full term set.
|
||||
}
|
||||
|
||||
// 2. Substring matches. Terms are chosen so the missionId term
|
||||
// is INCLUDED only when no typed-tag rows exist (legacy
|
||||
// compatibility); otherwise the slice-specific terms drive
|
||||
// matching. In mission-folder workspaces, railItem defaults to
|
||||
// missionId when no explicit rail item is authored, so the typed-tag
|
||||
// branch must not blindly keep that fallback or mission-only qitems
|
||||
// leak back into every slice in the mission.
|
||||
const substringTerms = (typedTagMatchCount > 0
|
||||
? [sliceName, railItem === missionId ? null : railItem]
|
||||
: [sliceName, railItem, missionId]
|
||||
).filter((v): v is string => !!v);
|
||||
for (const term of Array.from(new Set(substringTerms))) {
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`, `%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
} catch {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
// 2. Substring fallback. Now that typed membership is canonical and
|
||||
// authoritative, the substring tier executes ONLY when zero CONFIRMED
|
||||
// typed-tag rows exist — the deliberate VM-004 behavior change for
|
||||
// typed corpora (it used to ALWAYS run, leaking sibling-name and
|
||||
// body-mention matches into typed slices). Legacy zero-typed corpora
|
||||
// keep today's full term set, including the missionId term (mission-
|
||||
// folder railItem defaults to missionId when no rail item is authored,
|
||||
// so it must be included for legacy mission-aware workspaces). LIMIT
|
||||
// 500 preserved.
|
||||
if (typedTagMatchCount === 0) {
|
||||
const substringTerms = [sliceName, railItem, missionId].filter(
|
||||
(v): v is string => !!v,
|
||||
);
|
||||
for (const term of Array.from(new Set(substringTerms))) {
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`, `%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
} catch {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`,
|
||||
).all(`%${term}%`) as Array<{ qitem_id: string }>;
|
||||
for (const r of rows) ids.add(r.qitem_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Canonical scope-membership matcher (VM-003 + VM-004) — C1 pure unit matrix.
|
||||
//
|
||||
// Pins parseScopeTags: typed-tag authority, per-JSON-element comma-legacy
|
||||
// awareness, element-level-trim-only (the P1 precision pin), and the
|
||||
// exact-name (no substring, no suffix, no post-prefix trim) semantics that
|
||||
// keep the parser aligned with the unquoted SQL prefilter.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseScopeTags } from "../src/domain/slices/qitem-membership.js";
|
||||
|
||||
describe("parseScopeTags — canonical scope membership", () => {
|
||||
it("clean array: separate slice + mission elements", () => {
|
||||
const { slices, missions } = parseScopeTags(JSON.stringify(["mission:M", "slice:X"]));
|
||||
expect([...slices]).toEqual(["X"]);
|
||||
expect([...missions]).toEqual(["M"]);
|
||||
});
|
||||
|
||||
it("comma-embedded legacy: one element carrying mission + slice", () => {
|
||||
const { slices, missions } = parseScopeTags(JSON.stringify(["mission:M,slice:X"]));
|
||||
expect(slices.has("X")).toBe(true);
|
||||
expect(missions.has("M")).toBe(true);
|
||||
});
|
||||
|
||||
it("element-boundary whitespace is trimmed (` slice:X ` -> X)", () => {
|
||||
const { slices } = parseScopeTags(JSON.stringify([" slice:X "]));
|
||||
expect([...slices]).toEqual(["X"]);
|
||||
});
|
||||
|
||||
it("comma-with-spaces legacy element (`mission:M , slice:X`)", () => {
|
||||
const { slices, missions } = parseScopeTags(JSON.stringify(["mission:M , slice:X"]));
|
||||
expect(slices.has("X")).toBe(true);
|
||||
expect(missions.has("M")).toBe(true);
|
||||
});
|
||||
|
||||
it("P1 NEGATIVE: `slice: X` (space AFTER the colon) is NOT membership of X", () => {
|
||||
const { slices } = parseScopeTags(JSON.stringify(["slice: X"]));
|
||||
expect(slices.has("X")).toBe(false); // name is ` X`, never post-prefix trimmed
|
||||
expect(slices.has(" X")).toBe(true); // the honest, prefilter-aligned parse
|
||||
});
|
||||
|
||||
it("NEGATIVE: `slice:X-suffix` yields `X-suffix`, never `X`", () => {
|
||||
const { slices } = parseScopeTags(JSON.stringify(["slice:X-suffix"]));
|
||||
expect(slices.has("X")).toBe(false);
|
||||
expect(slices.has("X-suffix")).toBe(true);
|
||||
});
|
||||
|
||||
it("multi-slice single element (`slice:X,slice:Y`) -> both", () => {
|
||||
const { slices } = parseScopeTags(JSON.stringify(["slice:X,slice:Y"]));
|
||||
expect([...slices].sort()).toEqual(["X", "Y"]);
|
||||
});
|
||||
|
||||
it("malformed JSON -> empty sets", () => {
|
||||
const { slices, missions } = parseScopeTags("{not json");
|
||||
expect(slices.size).toBe(0);
|
||||
expect(missions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("null / undefined -> empty sets", () => {
|
||||
for (const raw of [null, undefined]) {
|
||||
const { slices, missions } = parseScopeTags(raw);
|
||||
expect(slices.size).toBe(0);
|
||||
expect(missions.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-array JSON (object) -> empty sets", () => {
|
||||
const { slices, missions } = parseScopeTags(JSON.stringify({ slice: "X" }));
|
||||
expect(slices.size).toBe(0);
|
||||
expect(missions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("non-string array elements are skipped", () => {
|
||||
const { slices } = parseScopeTags(JSON.stringify([42, { a: 1 }, "slice:X"]));
|
||||
expect([...slices]).toEqual(["X"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
// Canonical scope-membership matcher (VM-003 + VM-004) — the AGREEMENT test
|
||||
// (the class-killer). One seeded queue_items fixture; the four historically
|
||||
// divergent matchers (matchQitems -> qitemIds, agentsForSlices -> the band,
|
||||
// hasActiveQitem -> phase signal, attentionForTag -> NeedsYou) must all AGREE
|
||||
// on membership for: clean tags · comma-embedded legacy tags · a mission-tagged
|
||||
// qitem on an unindexed slice · and the NEGATIVES (slice:X-suffix over-match,
|
||||
// sibling-name, body-mention-only). Over-match negatives AND under-match
|
||||
// positives both hold.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { ReviewGatherer } from "../src/domain/review/gather.js";
|
||||
|
||||
const NOW = "2026-07-11T12:00:00.000Z";
|
||||
|
||||
function writeSlice(root: string, rel: string, name: string): void {
|
||||
const dir = path.join(root, rel, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "README.md"), `---\nstatus: active\n---\n# ${name}\n`);
|
||||
}
|
||||
|
||||
function insertQitem(
|
||||
db: Database.Database,
|
||||
opts: { id: string; dest: string; tags: string[]; body?: string; tier?: string | null; state?: string },
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body, summary)
|
||||
VALUES (?, ?, ?, 'src@rig', ?, ?, 'high', ?, ?, ?, ?)`,
|
||||
).run(
|
||||
opts.id,
|
||||
NOW,
|
||||
NOW,
|
||||
opts.dest,
|
||||
opts.state ?? "in-progress",
|
||||
opts.tier ?? null,
|
||||
JSON.stringify(opts.tags),
|
||||
opts.body ?? "body",
|
||||
opts.id,
|
||||
);
|
||||
}
|
||||
|
||||
describe("scope-membership agreement (VM-003 + VM-004 class-killer)", () => {
|
||||
let missionsRoot: string;
|
||||
let cleanup: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup = fs.mkdtempSync(path.join(os.tmpdir(), "scope-agreement-"));
|
||||
missionsRoot = path.join(cleanup, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
// Mission "relx" with two indexed slices: target (under test) + sibling.
|
||||
writeSlice(missionsRoot, path.join("relx", "slices"), "target");
|
||||
writeSlice(missionsRoot, path.join("relx", "slices"), "sibling");
|
||||
writeSlice(missionsRoot, path.join("relx", "slices"), "legacyonly");
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueItemSummarySchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedFixture(): void {
|
||||
// --- members of slice:target ---
|
||||
insertQitem(db, { id: "qc", dest: "alice@rig", tags: ["mission:relx", "slice:target"] }); // clean
|
||||
insertQitem(db, { id: "ql", dest: "bob@rig", tags: ["mission:relx,slice:target"] }); // comma-legacy (VM-003)
|
||||
// --- NEGATIVES (must be excluded everywhere) ---
|
||||
insertQitem(db, { id: "qsuf", dest: "carol@rig", tags: ["slice:target-extra"] }); // suffix over-match
|
||||
insertQitem(db, { id: "qsib", dest: "dave@rig", tags: ["slice:sibling"] }); // sibling
|
||||
insertQitem(db, { id: "qbody", dest: "erin@rig", tags: [], body: "work on target here" }); // body-mention only
|
||||
// --- mission-direct (C3) ---
|
||||
insertQitem(db, { id: "qmis", dest: "frank@rig", tags: ["mission:relx"] });
|
||||
// --- human-gate rows for attentionForTag agreement ---
|
||||
insertQitem(db, { id: "qhg_member", dest: "grace@rig", tags: ["mission:relx,slice:target"], tier: "human-gate" });
|
||||
insertQitem(db, { id: "qhg_non", dest: "heidi@rig", tags: ["slice:target-extra"], tier: "human-gate" });
|
||||
// --- legacy zero-typed corpus (substring tier must still run) ---
|
||||
insertQitem(db, { id: "qleg", dest: "ivan@rig", tags: [], body: "legacyonly rollout notes" });
|
||||
}
|
||||
|
||||
function makeIndexerAndGatherer() {
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, additionalSliceRoots: [], dogfoodEvidenceRoot: null, db });
|
||||
const gatherer = new ReviewGatherer({ db, indexer, gitRepoPath: null, now: () => NOW });
|
||||
return { indexer, gatherer };
|
||||
}
|
||||
|
||||
it("matchQitems (qitemIds): typed-authoritative, comma-legacy IN, suffix/sibling/body OUT", () => {
|
||||
seedFixture();
|
||||
const { indexer } = makeIndexerAndGatherer();
|
||||
const ids = new Set(indexer.get("target")?.qitemIds ?? []);
|
||||
expect(ids.has("qc")).toBe(true); // clean
|
||||
expect(ids.has("ql")).toBe(true); // comma-legacy (VM-003)
|
||||
expect(ids.has("qhg_member")).toBe(true); // comma-legacy member (human-gate)
|
||||
expect(ids.has("qsuf")).toBe(false); // suffix over-match (VM-004)
|
||||
expect(ids.has("qsib")).toBe(false); // sibling
|
||||
expect(ids.has("qbody")).toBe(false); // body-mention (typed rows exist -> substring tier skipped)
|
||||
expect(ids.has("qmis")).toBe(false); // mission-only, not a slice member
|
||||
});
|
||||
|
||||
it("agentsForSlices band (slice scope): comma-legacy seat IN, negatives + mission-only OUT", () => {
|
||||
seedFixture();
|
||||
const { gatherer } = makeIndexerAndGatherer();
|
||||
const band = gatherer.composeAgents("slice:target");
|
||||
const sessions = new Set((band?.rows ?? []).map((r) => r.sessionName));
|
||||
expect(sessions.has("alice@rig")).toBe(true); // clean
|
||||
expect(sessions.has("bob@rig")).toBe(true); // comma-legacy (VM-003 fix)
|
||||
expect(sessions.has("carol@rig")).toBe(false); // suffix
|
||||
expect(sessions.has("dave@rig")).toBe(false); // sibling
|
||||
expect(sessions.has("erin@rig")).toBe(false); // body-mention
|
||||
expect(sessions.has("frank@rig")).toBe(false); // mission-only -> not in slice band
|
||||
});
|
||||
|
||||
it("hasActiveQitem AGREES with the band (phase/band coherence pin)", () => {
|
||||
seedFixture();
|
||||
const { indexer, gatherer } = makeIndexerAndGatherer();
|
||||
const rec = indexer.get("target")!;
|
||||
const active = (gatherer as unknown as { hasActiveQitem(n: string, s: unknown): boolean }).hasActiveQitem("target", rec);
|
||||
const band = gatherer.composeAgents("slice:target");
|
||||
expect(active).toBe(true);
|
||||
// Coherence: active work present => band has >= 1 row (one-compose-two-answers, killed).
|
||||
expect((band?.rows.length ?? 0)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("attentionForTag AGREES: comma-legacy member IN, suffix over-match OUT", () => {
|
||||
seedFixture();
|
||||
const { gatherer } = makeIndexerAndGatherer();
|
||||
const attn = (gatherer as unknown as { attentionForTag(t: string): Array<{ qitemId: string }> }).attentionForTag("slice:target");
|
||||
const ids = new Set(attn.map((a) => a.qitemId));
|
||||
expect(ids.has("qhg_member")).toBe(true); // comma-legacy member (under-match fixed)
|
||||
expect(ids.has("qhg_non")).toBe(false); // suffix over-match rejected by canonical confirm
|
||||
});
|
||||
|
||||
it("mission band (C3): mission-tag-direct seat appears even without an indexed slice tag", () => {
|
||||
seedFixture();
|
||||
const { gatherer } = makeIndexerAndGatherer();
|
||||
const band = gatherer.composeAgents("mission:relx");
|
||||
const sessions = new Set((band?.rows ?? []).map((r) => r.sessionName));
|
||||
expect(sessions.has("frank@rig")).toBe(true); // mission:relx-tagged, no slice tag (C3 fix)
|
||||
expect(sessions.has("alice@rig")).toBe(true); // slice member still present via union
|
||||
});
|
||||
|
||||
it("mission attention excludes rows carrying a slice tag (d2 excludeTagPrefix, comma-legacy aware)", () => {
|
||||
seedFixture();
|
||||
const { gatherer } = makeIndexerAndGatherer();
|
||||
const attn = (gatherer as unknown as { attentionForTag(t: string, ex?: string): Array<{ qitemId: string }> }).attentionForTag("mission:relx", "slice:");
|
||||
const ids = new Set(attn.map((a) => a.qitemId));
|
||||
// qhg_member is a comma-legacy row that carries slice:target -> excluded from
|
||||
// mission attention (d2: raw startsWith blind to comma-legacy; canonical set is not).
|
||||
expect(ids.has("qhg_member")).toBe(false);
|
||||
});
|
||||
|
||||
it("tier gating: zero-typed corpus still matches via the preserved substring fallback", () => {
|
||||
seedFixture();
|
||||
const { indexer } = makeIndexerAndGatherer();
|
||||
const ids = new Set(indexer.get("legacyonly")?.qitemIds ?? []);
|
||||
// No typed slice:legacyonly rows -> typedTagMatchCount 0 -> substring tier runs
|
||||
// over [sliceName, railItem, missionId], matching the body mention.
|
||||
expect(ids.has("qleg")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope-membership byte-identity carve (clean corpus, zero regression)", () => {
|
||||
let missionsRoot: string;
|
||||
let cleanup: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup = fs.mkdtempSync(path.join(os.tmpdir(), "scope-carve-"));
|
||||
missionsRoot = path.join(cleanup, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeSlice(missionsRoot, path.join("relx", "slices"), "target");
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueItemSummarySchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("a clean single-member slice band matches the pinned pre-change literal", () => {
|
||||
insertQitem(db, { id: "qc", dest: "alice@rig", tags: ["mission:relx", "slice:target"] });
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, additionalSliceRoots: [], dogfoodEvidenceRoot: null, db });
|
||||
const gatherer = new ReviewGatherer({ db, indexer, gitRepoPath: null, now: () => NOW });
|
||||
const band = gatherer.composeAgents("slice:target");
|
||||
// Pinned expectation (pre-change behavior for a well-formed corpus).
|
||||
expect(band).toEqual({
|
||||
scope: "slice:target",
|
||||
rows: [
|
||||
{
|
||||
agentName: "alice",
|
||||
runtime: "unknown",
|
||||
stateGlyph: "unknown",
|
||||
doing: "qc",
|
||||
holdsCount: 1,
|
||||
lastTransitionIso: NOW,
|
||||
exception: null,
|
||||
sessionName: "alice@rig",
|
||||
slices: ["target"],
|
||||
},
|
||||
],
|
||||
provenance: `computed from queue at ${NOW}`,
|
||||
coordinationHealth: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("an empty clean band keeps the confident provenance string byte-identical (C4 no-change)", () => {
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, additionalSliceRoots: [], dogfoodEvidenceRoot: null, db });
|
||||
const gatherer = new ReviewGatherer({ db, indexer, gitRepoPath: null, now: () => NOW });
|
||||
const band = gatherer.composeAgents("slice:target");
|
||||
expect(band?.rows).toEqual([]);
|
||||
expect(band?.provenance).toBe(`no agents holding or recently holding work — computed from queue at ${NOW}`);
|
||||
});
|
||||
});
|
||||
@@ -326,17 +326,22 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
// mission-tagged-only qitems no longer pollute the slice's queue.
|
||||
// Substring fallback is preserved for slices without typed-tag
|
||||
// qitems (legacy corpus compatibility — HG-2).
|
||||
it("when typed slice:<name> tag matches exist, mission-tagged-only qitems are NOT included", () => {
|
||||
it("when typed slice:<name> tag matches exist, typed tags are authoritative: mission-only AND body-substring-only qitems are NOT included (VM-004)", () => {
|
||||
// Production shape: slices/missions root with a mission folder
|
||||
// containing the slice. missionId resolves to the mission folder
|
||||
// name; railItem defaults to missionId when frontmatter doesn't
|
||||
// specify one. The over-match bug was about the missionId
|
||||
// substring term polluting per-slice queue results.
|
||||
// specify one.
|
||||
//
|
||||
// VM-004 (canonical scope-membership matcher): typed tags are
|
||||
// AUTHORITATIVE. When ANY confirmed `slice:<name>` typed row exists,
|
||||
// the substring fallback tier is gated OFF entirely — so neither the
|
||||
// mission-only qitem NOR the body-substring-only qitem leaks into the
|
||||
// slice queue. (Pre-VM-004 the substring tier always ran and kept the
|
||||
// sliceName body-substring match; that leak is exactly what VM-004
|
||||
// closes. Legacy zero-typed corpora keep the full substring fallback —
|
||||
// see the sibling "preserves legacy substring fallback" test.)
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "release-fake", "slices"), "fake-slice-17", {
|
||||
// Explicit rail-item disambiguates from missionId so the fix's
|
||||
// "drop missionId, keep railItem" branch can be exercised
|
||||
// without the railItem-equals-missionId collision.
|
||||
"README.md": "---\nstatus: active\nrail-item: WALK-17\n---\n# Fake 17\n",
|
||||
});
|
||||
insertQitem(db, {
|
||||
@@ -356,8 +361,9 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
});
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
|
||||
const slice = indexer.get("fake-slice-17")!;
|
||||
expect(slice.qitemIds.sort()).toEqual(["q-by-slice-name-body", "q-typed-slice-tag"]);
|
||||
expect(slice.qitemIds).toEqual(["q-typed-slice-tag"]);
|
||||
expect(slice.qitemIds).not.toContain("q-mission-tag-only");
|
||||
expect(slice.qitemIds).not.toContain("q-by-slice-name-body");
|
||||
});
|
||||
|
||||
it("does not re-include mission-only qitems when railItem defaults to missionId", () => {
|
||||
|
||||
Reference in new issue
Block a user