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:
mvschwarzandClaude Fable 5 committed 2026-07-10 23:58:53 -07:00
1 parent 79b141f8a1
commit 302036aa6c
6 files changed
+481 -55

No files matched your search

+71 -22
View File
@@ -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}`);
});
});
+13 -7
View File
@@ -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", () => {