@rafinery/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,83 @@ export const RAFA_PERMISSIONS = [
13
13
  "Bash(rafa:*)",
14
14
  ];
15
15
 
16
+ // The M5 sensor hooks (capture engine): SessionStart injects the state digest
17
+ // (staleness · conflicts · active plan), PostToolUse dirty-marks edited code
18
+ // files into .rafa/dirty.jsonl. Both scripts are vendored LOCKSTEP at
19
+ // .claude/rafa/hooks/ and are no-ops outside a rafa-provisioned repo.
20
+ export const RAFA_HOOKS = {
21
+ SessionStart: {
22
+ matcher: "startup|resume|clear",
23
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/session-start.mjs"',
24
+ },
25
+ PostToolUse: {
26
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
27
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/post-tool.mjs"',
28
+ },
29
+ // The correction reflex (M5 sensor #4): detect correction-shaped prompts,
30
+ // queue them, and inject the bank-it-now steering at exactly that moment.
31
+ UserPromptSubmit: {
32
+ matcher: "*",
33
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/user-prompt-submit.mjs"',
34
+ },
35
+ };
36
+
37
+ // The ambient loop-state statusline (frictionless loop, 2026-07-13). Set ONLY
38
+ // when the dev has no statusline of their own, or theirs is already ours —
39
+ // a configured statusline is the dev's turf, never replaced (merge, don't own).
40
+ export const RAFA_STATUSLINE = {
41
+ type: "command",
42
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/statusline.mjs"',
43
+ };
44
+
45
+ export function mergeStatusLine(targetDir) {
46
+ const file = settingsPath(targetDir);
47
+ const existing = readClaudeSettings(targetDir);
48
+ if (existing && existing.error) {
49
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
50
+ }
51
+ const settings = existing ?? {};
52
+ const cur = settings.statusLine;
53
+ const ours = typeof cur?.command === "string" && cur.command.includes(".claude/rafa/hooks/statusline.mjs");
54
+ if (cur && !ours) return { skipped: "you already have a statusline — keeping yours (rafa's shows via `rafa status --line`)" };
55
+ if (ours && cur.command === RAFA_STATUSLINE.command) return { added: [] };
56
+ const next = { ...settings, statusLine: { ...RAFA_STATUSLINE } };
57
+ mkdirSync(dirname(file), { recursive: true });
58
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
59
+ return { added: ["statusLine"] };
60
+ }
61
+
62
+ // Merge rafa's sensor hooks into .claude/settings.json WITHOUT clobbering the
63
+ // dev's own hook config: a rafa hook is recognized by its script path — if any
64
+ // handler for that event already points at .claude/rafa/hooks/<script>, the
65
+ // event is left untouched (matcher/command tuning survives updates). Returns
66
+ // { added: [eventNames] } or { skipped: "reason" }.
67
+ export function mergeClaudeHooks(targetDir, defs = RAFA_HOOKS) {
68
+ const file = settingsPath(targetDir);
69
+ const existing = readClaudeSettings(targetDir);
70
+ if (existing && existing.error) {
71
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
72
+ }
73
+ const settings = existing ?? {};
74
+ const hooks = { ...(settings.hooks ?? {}) };
75
+ const added = [];
76
+ for (const [event, def] of Object.entries(defs)) {
77
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
78
+ const marker = def.command.match(/\.claude\/rafa\/hooks\/[\w.-]+/)?.[0] ?? def.command;
79
+ const present = groups.some((g) =>
80
+ (g?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(marker)),
81
+ );
82
+ if (present) continue;
83
+ hooks[event] = [...groups, { matcher: def.matcher, hooks: [{ type: "command", command: def.command }] }];
84
+ added.push(event);
85
+ }
86
+ if (added.length === 0 && existing) return { added: [] };
87
+ const next = { ...settings, hooks };
88
+ mkdirSync(dirname(file), { recursive: true });
89
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
90
+ return { added };
91
+ }
92
+
16
93
  // Merge the repo's .mcp.json server names into the OWNED agent cards' tools
17
94
  // lines (ratified 2026-07-12: subagents must be able to reach the repo's MCPs
18
95
  // — a Convex repo's mcp__convex tools were structurally invisible to atlas).
package/lib/dirty.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // rafa dirty — the cite-graph invalidator's query surface (M5, agent-internal:
2
+ // the conductor runs this at boundaries; devs never need to remember it).
3
+ //
4
+ // rafa dirty human view: dirty code files + the brain notes citing them
5
+ // rafa dirty --json the same, machine-shaped (conductor bootstrap / spawn prompts)
6
+ // rafa dirty --consume clear the queue AFTER a completed scoped refresh — the
7
+ // refresh must have shipped (gates + checkpoint/push) first
8
+ //
9
+ // The queue (.rafa/dirty.jsonl) is written by the PostToolUse hook the moment an
10
+ // edit happens (monotonic, no session-end dependency). Citing notes resolve from
11
+ // the LOCAL manifest when one exists (full pull / prior compile); otherwise the
12
+ // mapping is honestly "unresolved here" — counts still serve, and the platform's
13
+ // get_code_context covers work-time recall. Never guesses.
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ export const DIRTY_FILES_ALARM = 15; // ≥ → recommend /rafa scan --brain-only
19
+ export const CITED_NOTES_ALARM = 10;
20
+
21
+ export function readDirty(root = process.cwd()) {
22
+ const file = join(root, ".rafa", "dirty.jsonl");
23
+ const files = new Map(); // path → last touch
24
+ if (!existsSync(file)) return { file, files: [] };
25
+ for (const line of readFileSync(file, "utf8").split("\n")) {
26
+ if (!line.trim()) continue;
27
+ try {
28
+ const e = JSON.parse(line);
29
+ if (e && typeof e.f === "string") files.set(e.f, e.t ?? "");
30
+ } catch {
31
+ /* torn line — skip, never fail the query */
32
+ }
33
+ }
34
+ return { file, files: [...files.entries()].map(([f, t]) => ({ f, t })) };
35
+ }
36
+
37
+ // file → citing notes from the local manifest; null = no manifest here (lazy .rafa).
38
+ export function resolveCiters(root, dirtyFiles) {
39
+ const path = join(root, ".rafa", "manifest.json");
40
+ if (!existsSync(path)) return null;
41
+ let m;
42
+ try {
43
+ m = JSON.parse(readFileSync(path, "utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ const want = new Set(dirtyFiles);
48
+ const notes = [];
49
+ for (const group of [m.notes ?? [], m.improvements ?? []]) {
50
+ for (const n of group) {
51
+ const files = [...new Set((n.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file))];
52
+ if (files.length) notes.push({ id: n.id, path: n.path, files });
53
+ }
54
+ }
55
+ return notes;
56
+ }
57
+
58
+ export default async function dirty(args = []) {
59
+ const ROOT = process.cwd();
60
+ const { file, files } = readDirty(ROOT);
61
+
62
+ if (args.includes("--consume")) {
63
+ if (files.length === 0) {
64
+ console.log("✓ dirty queue already clean");
65
+ return;
66
+ }
67
+ writeFileSync(file, "");
68
+ console.log(
69
+ `✓ consumed ${files.length} dirty file(s) — do this ONLY after the scoped refresh shipped (gates + checkpoint/push)`,
70
+ );
71
+ return;
72
+ }
73
+
74
+ const notes = resolveCiters(ROOT, files.map((e) => e.f));
75
+ const alarm =
76
+ files.length >= DIRTY_FILES_ALARM || (notes !== null && notes.length >= CITED_NOTES_ALARM);
77
+
78
+ if (args.includes("--json")) {
79
+ console.log(
80
+ JSON.stringify(
81
+ {
82
+ files: files.map((e) => e.f),
83
+ notes, // null = unresolved here (no local manifest) — an honest unknown, not zero
84
+ thresholds: { files: DIRTY_FILES_ALARM, notes: CITED_NOTES_ALARM },
85
+ alarm,
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ );
91
+ return;
92
+ }
93
+
94
+ if (files.length === 0) {
95
+ console.log("✓ dirty queue clean — no code edits recorded since the last brain reconcile");
96
+ return;
97
+ }
98
+ console.log(`${files.length} code file(s) edited since the last brain reconcile:`);
99
+ for (const e of files) console.log(` · ${e.f}`);
100
+ if (notes === null) {
101
+ console.log("citing notes: unresolved here (lazy .rafa, no local manifest) — recall still");
102
+ console.log("serves fresh knowledge via the platform; run a scoped refresh from a session.");
103
+ } else if (notes.length === 0) {
104
+ console.log("citing notes: none — nothing in the brain cites these files (nothing to refresh).");
105
+ } else {
106
+ console.log(`${notes.length} brain note(s) cite them:`);
107
+ for (const n of notes) console.log(` · ${n.id} ← ${n.files.join(", ")}`);
108
+ }
109
+ console.log(
110
+ alarm
111
+ ? `⚠ drift past threshold — recommend a brain refresh from main (/rafa scan --brain-only), then \`rafa dirty --consume\`.`
112
+ : `→ at the next boundary: scoped refresh of the citing notes (atlas re-derives ONLY those → gates → checkpoint), then \`rafa dirty --consume\`.`,
113
+ );
114
+ }
package/lib/distill.mjs CHANGED
@@ -61,6 +61,10 @@ async function loadAgentSdk(cwd) {
61
61
  const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
62
62
 
63
63
  export default async function distill(args = []) {
64
+ // U7 recursion guard, belt-and-braces: the Agent SDK worker must never fire
65
+ // the M5 session sensors (a worker's own tool calls re-triggering hooks would
66
+ // loop). The ci-setup workflow sets this too; this covers ad-hoc invocations.
67
+ process.env.RAFA_HOOKS_DISABLED = "1";
64
68
  const branch = args.filter((a) => !a.startsWith("-"))[0];
65
69
  if (!args.includes("--headless"))
66
70
  die(
@@ -191,6 +191,11 @@ export function runCompile(argv = []) {
191
191
  }
192
192
  checkVersion(data, path);
193
193
  const id = checkId(data, path, name);
194
+ // `anchor:` / `absent:` are checker-side declarations (verify-citations gates
195
+ // B2/B3 on them) — optional here, but if present they must be non-empty.
196
+ for (const key of ["anchor", "absent"])
197
+ if (key in data && (typeof data[key] !== "string" || data[key].trim() === ""))
198
+ fail(path, key, "optional · non-empty token (or `none`)");
194
199
  notes.push({
195
200
  id,
196
201
  kind,
@@ -332,9 +337,42 @@ export function runCompile(argv = []) {
332
337
  fail(path, `domains.${domain}`, `status ∈ ${STATUS.join("|")}`);
333
338
  domains.push({ domain, status: String(status) });
334
339
  }
340
+ // Optional `inventory:` block list — `<name> :: <glob> :: <count>` per entry.
341
+ // Checker-side (verify-citations recomputes each via git ls-files); validated
342
+ // here so a malformed declaration fails loudly at the gate, never silently.
343
+ if ("inventory" in data) {
344
+ if (!Array.isArray(data.inventory) || data.inventory.length === 0) {
345
+ fail(path, "inventory", "optional · block list of `<name> :: <glob> :: <count>`");
346
+ } else {
347
+ for (const entry of data.inventory)
348
+ if (!/^.+?\s*::\s*.+?\s*::\s*\d+$/.test(String(entry)))
349
+ fail(path, "inventory", `malformed entry: ${JSON.stringify(entry)} · must be \`<name> :: <glob> :: <count>\``);
350
+ }
351
+ }
335
352
  return { domains };
336
353
  }
337
354
 
355
+ // citation-check.json — the checker's machine record of its last run (generated
356
+ // by `rafa verify-citations`). Folded into manifest.citations so the platform
357
+ // knows which gate level this brain passed. Absent file → null (an honest
358
+ // "no recorded run"); a malformed one is a tool bug and fails loudly.
359
+ function compileCitations() {
360
+ const path = join(ROOT, "brain", "citation-check.json");
361
+ if (!existsSync(path)) return null;
362
+ let rec;
363
+ try {
364
+ rec = JSON.parse(read(path));
365
+ } catch (e) {
366
+ fail(path, "json", `unparseable: ${e instanceof Error ? e.message : e}`);
367
+ return null;
368
+ }
369
+ if (typeof rec.checkerVersion !== "number" || typeof rec.pass !== "boolean" || typeof rec.at !== "string") {
370
+ fail(path, "shape", "required · { checkerVersion: int, pass: bool, at: ISO string }");
371
+ return null;
372
+ }
373
+ return { checkerVersion: rec.checkerVersion, pass: rec.pass, at: rec.at };
374
+ }
375
+
338
376
  // Plans (contract §7 v2 — the work-item tree, plans data version 2): one epic
339
377
  // (root) → tasks → subtasks. Vendor-blended vocabulary; `blocked` is DERIVED
340
378
  // (unresolved blocked_by / blocked_reason), never stored. VALIDATED here
@@ -573,6 +611,7 @@ export function runCompile(argv = []) {
573
611
  const health = compileHealth();
574
612
  const ledger = compileLedger();
575
613
  const coverage = compileCoverage();
614
+ const citations = compileCitations();
576
615
  const plans = compilePlans();
577
616
  const activePlanId = compileActivePlanId(plans);
578
617
  const agentCards = compileAgentCards();
@@ -607,6 +646,7 @@ export function runCompile(argv = []) {
607
646
  health,
608
647
  ledger,
609
648
  coverage,
649
+ citations,
610
650
  notes,
611
651
  improvements,
612
652
  };
@@ -1,6 +1,8 @@
1
1
  // Deterministic citation checker for the atlas scan output. Lives INSIDE
2
2
  // @rafinery/cli (blueprint split, 0.4.0) — run as `rafa verify-citations`.
3
- // Three gates:
3
+ // Five gates (checker v2 — the 2026-06-08 ratchet: prism's two recommended
4
+ // checks mechanized, per the disposition protocol "every prism catch gets
5
+ // mechanized | eval-cased | judgment"):
4
6
  //
5
7
  // RESOLUTION (B1) — every cite `- <file>:<line>[-<end>] :: <token>` points at a line
6
8
  // that actually contains <token>. Catches off-by-N / wrong-file.
@@ -11,18 +13,48 @@
11
13
  // or `anchor: none` for composition/ordering contracts that don't grep
12
14
  // as one token. So completeness can't be silently skipped (closes F1:
13
15
  // the guarantee is mandatory, not opt-in; exemptions are explicit).
16
+ // ABSENCE (B3) — a note may declare `absent: <token>` (repeatable, anchor-style):
17
+ // "this token appears NOWHERE in code." The checker re-greps every
18
+ // declared absence and FAILS if it now matches (docs/.md excluded).
19
+ // Mechanizes the 2026-06-08 blocker class: a false absence-claim
20
+ // (the note said stubbed; the code had grown the real thing).
21
+ // INVENTORY — coverage.md may declare `inventory:` entries
22
+ // `- <name> :: <glob> :: <count>`; the checker recomputes each via
23
+ // `git ls-files ':(glob)<glob>'` and FAILS on drift. Mechanizes the
24
+ // route-inventory diff (coverage claiming N surface files vs reality).
14
25
  //
15
- // Writes a generated report to <root>/citation-check.md (don't hand-paste it).
26
+ // Plus a non-failing heuristic: notes whose title/summary reads existence-shaped
27
+ // ("does not exist", "stubbed", "not implemented"…) without declaring `absent:` are
28
+ // listed as WARNs — prism's worklist, never a gate failure (a heuristic that fails
29
+ // the gate would be an assumed value).
30
+ //
31
+ // Writes a generated report to <root>/citation-check.md (don't hand-paste it) and a
32
+ // machine record to <root>/citation-check.json ({ checkerVersion, pass, gates … }) —
33
+ // `rafa compile` folds that record into manifest.citations so the platform knows
34
+ // which gate level a brain actually passed (recorded run, never assumed).
16
35
  // Returns exit code 1 on any failure. Run from repo root: rafa verify-citations
17
36
 
18
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync } from "node:fs";
37
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
19
38
  import { execSync } from "node:child_process";
20
39
  import { join } from "node:path";
21
40
  import { tmpdir } from "node:os";
22
41
 
42
+ export const CHECKER_VERSION = 2; // v1 = the 3-gate era (resolution/completeness/policy)
43
+
23
44
  const CITE = /^\s*-\s+(.+?):(\d+)(?:-(\d+))?\s*::\s*(.+?)\s*$/; // - file:start[-end] :: token
24
45
  const ANCHOR = /^anchor:\s*(.+?)\s*$/;
46
+ const ABSENT = /^absent:\s*(.+?)\s*$/;
25
47
  const TYPE = /^type:\s*(.+?)\s*$/;
48
+ const TITLE = /^title:\s*(.+?)\s*$/;
49
+ const SUMMARY = /^summary:\s*(.+?)\s*$/;
50
+ const INV_ENTRY = /^(.+?)\s*::\s*(.+?)\s*::\s*(\d+)$/; // name :: glob :: count
51
+
52
+ // Existence-shaped phrases (title/summary only — the machine surface; the body is
53
+ // never parsed). Deliberately narrow: policy claims ("never hardcode") must not hit.
54
+ const ABSENCE_HEURISTIC =
55
+ /(does\s?n[o']?t exist|do not exist|no longer exists?|nowhere in|not (yet )?(implemented|built|wired|created|present)|only a stub|stubbed|unbuilt|unimplemented|nothing (reads|writes|calls|imports|consumes)|absent from)/i;
56
+
57
+ const strip = (s) => s.replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
26
58
 
27
59
  function walk(dir) {
28
60
  if (!existsSync(dir)) return [];
@@ -32,19 +64,33 @@ function walk(dir) {
32
64
  });
33
65
  }
34
66
 
35
- function gitGrep(token) {
67
+ function gitGrep(token, cwd = process.cwd()) {
36
68
  try {
37
- const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8" });
69
+ const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8", cwd });
38
70
  return out.split("\n").filter(Boolean).map((l) => {
39
71
  const m = l.match(/^(.+?):(\d+):/);
40
72
  return m ? { file: m[1], line: +m[2] } : null;
41
- }).filter(Boolean);
73
+ }).filter(Boolean)
74
+ // The brain is never "code": exclude .rafa/** categorically so the checker
75
+ // can't self-collide with manifests/notes if .rafa is (accidentally) tracked.
76
+ .filter((h) => !h.file.startsWith(".rafa/"));
42
77
  } catch (e) {
43
78
  if (e.status === 1) return []; // git grep: no matches
44
79
  throw e;
45
80
  }
46
81
  }
47
82
 
83
+ // Tracked files matching a glob — deterministic inventory ground truth.
84
+ // `:(glob)` pathspec magic makes `**` match across directories.
85
+ function gitLsGlob(glob, cwd = process.cwd()) {
86
+ try {
87
+ const out = execSync(`git ls-files -- ${JSON.stringify(`:(glob)${glob}`)}`, { encoding: "utf8", cwd });
88
+ return out.split("\n").filter(Boolean);
89
+ } catch {
90
+ return null; // bad pathspec → caller reports it as a failure, never guesses 0
91
+ }
92
+ }
93
+
48
94
  function citeResolves(file, start, end, token) {
49
95
  if (!existsSync(file)) return { ok: false, reason: "file missing" };
50
96
  const src = readFileSync(file, "utf8").split("\n");
@@ -53,28 +99,81 @@ function citeResolves(file, start, end, token) {
53
99
  return { ok: false, reason: `token not on ${start}${end !== start ? "-" + end : ""}` };
54
100
  }
55
101
 
102
+ // coverage.md frontmatter `inventory:` block list → [{ name, glob, count }] + malformed lines.
103
+ function parseCoverageInventory(path) {
104
+ const entries = [];
105
+ const malformed = [];
106
+ if (!existsSync(path)) return { entries, malformed };
107
+ const lines = readFileSync(path, "utf8").split("\n");
108
+ let fence = 0;
109
+ let inBlock = false;
110
+ for (const line of lines) {
111
+ if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
112
+ if (fence !== 1) continue;
113
+ if (/^inventory:\s*$/.test(line)) { inBlock = true; continue; }
114
+ if (inBlock) {
115
+ const item = line.match(/^\s+-\s+(.+?)\s*$/);
116
+ if (item) {
117
+ const m = strip(item[1]).match(INV_ENTRY);
118
+ if (m) entries.push({ name: m[1].trim(), glob: m[2].trim(), count: +m[3] });
119
+ else malformed.push(item[1]);
120
+ continue;
121
+ }
122
+ if (/^\S/.test(line)) inBlock = false; // next top-level key ends the block
123
+ }
124
+ }
125
+ return { entries, malformed };
126
+ }
127
+
56
128
  export function runVerifyCitations(argv = []) {
57
129
  const arg = (k, d) => { const m = argv.find((a) => a.startsWith(`--${k}=`)); return m ? m.slice(k.length + 3) : d; };
58
130
  const ROOT = arg("root", ".rafa/brain"); // --root=.rafa/improve for the improve ledger
59
131
  const NOTE_DIRS = arg("dirs", "rules,playbooks").split(",").filter(Boolean); // --dirs=improvements
60
132
 
61
- // --selftest: prove the resolution logic fails on a bad cite and passes a good one, on a
62
- // throwaway temp file (no brain/repo pollution). prism runs this as its mutation probe —
63
- // re-running the checker is not proof it still works; this is.
133
+ // --selftest: prove each gate's logic on throwaway fixtures (no brain/repo pollution).
134
+ // prism runs this as its mutation probe — re-running the checker is not proof it still
135
+ // works; this is. Covers: resolution (good+bad cite) · absence (present token must
136
+ // flag, missing token must pass) · inventory (count drift must flag, exact must pass).
64
137
  if (argv.includes("--selftest")) {
65
- const dir = mkdtempSync(join(tmpdir(), "vc-selftest-"));
66
- const f = join(dir, "sample.txt");
67
- writeFileSync(f, "alpha\nTOKEN beta\ngamma\n");
68
- const good = citeResolves(f, 2, 2, "TOKEN").ok; // expect true
69
- const bad = citeResolves(f, 1, 1, "TOKEN").ok; // expect false (off-by-one)
70
- rmSync(dir, { recursive: true, force: true });
71
- const sane = good === true && bad === false;
72
- console.log(`checker self-test: ${sane ? "PASS" : "FAIL"} — good cite=${good} (expect true), bad cite=${bad} (expect false)`);
138
+ const results = [];
139
+ // resolution pure file logic
140
+ {
141
+ const dir = mkdtempSync(join(tmpdir(), "vc-selftest-"));
142
+ const f = join(dir, "sample.txt");
143
+ writeFileSync(f, "alpha\nTOKEN beta\ngamma\n");
144
+ results.push(["resolution good cite", citeResolves(f, 2, 2, "TOKEN").ok === true]);
145
+ results.push(["resolution bad cite", citeResolves(f, 1, 1, "TOKEN").ok === false]);
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ // absence + inventory — need a real (temp) git index
149
+ {
150
+ const dir = mkdtempSync(join(tmpdir(), "vc-selftest-git-"));
151
+ try {
152
+ execSync("git init -q", { cwd: dir });
153
+ mkdirSync(join(dir, "app", "x"), { recursive: true });
154
+ writeFileSync(join(dir, "app", "x", "page.tsx"), "export default function X() { return SELFTEST_PRESENT; }\n");
155
+ writeFileSync(join(dir, "app", "readme.md"), "SELFTEST_PRESENT in docs must not count\n");
156
+ execSync("git add -A", { cwd: dir });
157
+ const present = gitGrep("SELFTEST_PRESENT", dir).filter((h) => !h.file.endsWith(".md"));
158
+ const gone = gitGrep("SELFTEST_TRULY_ABSENT", dir).filter((h) => !h.file.endsWith(".md"));
159
+ results.push(["absence flags a present token", present.length === 1]); // code hit only, .md excluded
160
+ results.push(["absence passes a truly absent token", gone.length === 0]);
161
+ const found = gitLsGlob("app/**/page.tsx", dir);
162
+ results.push(["inventory exact count passes", (found ?? []).length === 1]);
163
+ results.push(["inventory drift flags", (found ?? []).length !== 2]);
164
+ } finally {
165
+ rmSync(dir, { recursive: true, force: true });
166
+ }
167
+ }
168
+ const sane = results.every(([, ok]) => ok);
169
+ for (const [name, ok] of results) console.log(` ${ok ? "✓" : "✗"} ${name}`);
170
+ console.log(`checker self-test (v${CHECKER_VERSION}): ${sane ? "PASS" : "FAIL"}`);
73
171
  return sane ? 0 : 1;
74
172
  }
75
173
 
76
174
  const notes = NOTE_DIRS.flatMap((d) => walk(join(ROOT, d)));
77
- if (notes.length === 0) {
175
+ const coveragePath = join(ROOT, "coverage.md");
176
+ if (notes.length === 0 && !existsSync(coveragePath)) {
78
177
  console.log(`No notes under ${ROOT}/{${NOTE_DIRS.join(",")}}/ — run a scan first.`);
79
178
  return 0;
80
179
  }
@@ -82,13 +181,19 @@ export function runVerifyCitations(argv = []) {
82
181
  const resolution = []; // { note, loc, token, ok, reason }
83
182
  const completeness = []; // { note, anchor, site, ok, reason }
84
183
  const policy = []; // { note, ok, reason }
184
+ const absence = []; // { note, token, site, ok, reason }
185
+ const inventory = []; // { name, glob, declared, found, ok, reason, paths }
186
+ const warns = []; // { note, phrase } — heuristic, never fails the gate
85
187
 
86
188
  for (const note of notes) {
87
189
  const rel = note.replace(ROOT + "/", "");
88
190
  const lines = readFileSync(note, "utf8").split("\n");
89
191
  const cites = [];
90
192
  const anchors = [];
193
+ const absents = [];
91
194
  let noteType = "";
195
+ let title = "";
196
+ let summary = "";
92
197
  let fence = 0;
93
198
  for (const line of lines) {
94
199
  if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
@@ -96,9 +201,15 @@ export function runVerifyCitations(argv = []) {
96
201
  const c = line.match(CITE);
97
202
  if (c) { cites.push({ file: c[1], start: +c[2], end: c[3] ? +c[3] : +c[2], token: c[4] }); continue; }
98
203
  const a = line.match(ANCHOR);
99
- if (a) { anchors.push(a[1].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim()); continue; }
204
+ if (a) { anchors.push(strip(a[1])); continue; }
205
+ const ab = line.match(ABSENT);
206
+ if (ab) { absents.push(strip(ab[1])); continue; }
100
207
  const t = line.match(TYPE);
101
- if (t) noteType = t[1].replace(/\s+#.*$/, "").trim();
208
+ if (t) { noteType = strip(t[1]); continue; }
209
+ const ti = line.match(TITLE);
210
+ if (ti) { title = strip(ti[1]); continue; }
211
+ const s = line.match(SUMMARY);
212
+ if (s) summary = strip(s[1]);
102
213
  }
103
214
 
104
215
  // POLICY — every contract must declare an anchor (token or explicit `none`)
@@ -126,15 +237,56 @@ export function runVerifyCitations(argv = []) {
126
237
  completeness.push({ note: rel, anchor, site: `${h.file}:${h.line}`, ok: covered, reason: covered ? "" : "grep hit not cited — site omitted" });
127
238
  }
128
239
  }
240
+
241
+ // ABSENCE (B3) — a declared absence must still be absent
242
+ for (const token of absents) {
243
+ if (token.toLowerCase() === "none") continue;
244
+ const hits = gitGrep(token).filter((h) => !h.file.endsWith(".md")); // docs excluded, same rule as completeness
245
+ if (hits.length === 0) {
246
+ absence.push({ note: rel, token, site: "(nowhere — as claimed)", ok: true, reason: "" });
247
+ } else {
248
+ for (const h of hits) {
249
+ absence.push({ note: rel, token, site: `${h.file}:${h.line}`, ok: false, reason: "claimed absent but exists — the claim went stale" });
250
+ }
251
+ }
252
+ }
253
+
254
+ // WARN heuristic — existence-shaped claim with no declared `absent:` token
255
+ if (absents.length === 0) {
256
+ const m = (title + " · " + summary).match(ABSENCE_HEURISTIC);
257
+ if (m) warns.push({ note: rel, phrase: m[0] });
258
+ }
259
+ }
260
+
261
+ // INVENTORY — coverage.md declared counts vs tracked reality
262
+ {
263
+ const { entries, malformed } = parseCoverageInventory(coveragePath);
264
+ for (const bad of malformed)
265
+ inventory.push({ name: "(malformed)", glob: bad, declared: NaN, found: NaN, ok: false, reason: "entry must be `<name> :: <glob> :: <count>`", paths: [] });
266
+ for (const e of entries) {
267
+ const paths = gitLsGlob(e.glob);
268
+ if (paths === null) {
269
+ inventory.push({ name: e.name, glob: e.glob, declared: e.count, found: NaN, ok: false, reason: "glob is not a valid git pathspec", paths: [] });
270
+ continue;
271
+ }
272
+ const ok = paths.length === e.count;
273
+ inventory.push({
274
+ name: e.name, glob: e.glob, declared: e.count, found: paths.length, ok,
275
+ reason: ok ? "" : `declared ${e.count}, found ${paths.length} — coverage is stale (refresh the scan)`,
276
+ paths: ok ? [] : paths.slice(0, 20),
277
+ });
278
+ }
129
279
  }
130
280
 
131
281
  const rFail = resolution.filter((r) => !r.ok);
132
282
  const cFail = completeness.filter((r) => !r.ok);
133
283
  const pFail = policy.filter((r) => !r.ok);
134
- const fail = rFail.length + cFail.length + pFail.length;
284
+ const aFail = absence.filter((r) => !r.ok);
285
+ const iFail = inventory.filter((r) => !r.ok);
286
+ const fail = rFail.length + cFail.length + pFail.length + aFail.length + iFail.length;
135
287
 
136
288
  const md = [
137
- "# Citation check (generated — do not hand-edit)",
289
+ `# Citation check (generated — do not hand-edit) · checker v${CHECKER_VERSION}`,
138
290
  "",
139
291
  `## Resolution (B1): ${resolution.length - rFail.length}/${resolution.length} ✓`,
140
292
  ...resolution.map((r) => `${r.ok ? "✓" : "✗"} ${r.loc} :: ${r.token}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
@@ -145,12 +297,51 @@ export function runVerifyCitations(argv = []) {
145
297
  `## Policy (contract → anchor declared): ${policy.length - pFail.length}/${policy.length} ✓`,
146
298
  ...policy.map((r) => `${r.ok ? "✓" : "✗"} ${r.note}${r.ok ? "" : " — " + r.reason}`),
147
299
  "",
300
+ `## Absence (B3, declared \`absent:\` re-grepped): ${absence.length - aFail.length}/${absence.length} ✓`,
301
+ ...absence.map((r) => `${r.ok ? "✓" : "✗"} absent '${r.token}' → ${r.site}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
302
+ "",
303
+ `## Inventory (coverage declared vs \`git ls-files\`): ${inventory.length - iFail.length}/${inventory.length} ✓`,
304
+ ...inventory.flatMap((r) => [
305
+ `${r.ok ? "✓" : "✗"} ${r.name} '${r.glob}' declared ${r.declared} · found ${r.found}${r.ok ? "" : " — " + r.reason}`,
306
+ ...r.paths.map((p) => ` · ${p}`),
307
+ ]),
308
+ "",
309
+ `## Warns (heuristic, non-failing — existence-shaped title/summary with no \`absent:\` declared): ${warns.length}`,
310
+ ...warns.map((w) => `⚠ ${w.note} — reads as an absence claim ("${w.phrase}") — declare \`absent: <token>\` so the gate can re-grep it, or reword`),
311
+ "",
148
312
  fail ? `**${fail} FAILED.**` : "**All pass.**",
149
313
  "",
150
314
  ].join("\n");
151
315
  writeFileSync(join(ROOT, "citation-check.md"), md);
152
316
 
317
+ // Machine record of THIS run — compile folds it into manifest.citations so the
318
+ // platform knows the gate level a brain passed. Recorded, never assumed.
319
+ writeFileSync(
320
+ join(ROOT, "citation-check.json"),
321
+ JSON.stringify(
322
+ {
323
+ checkerVersion: CHECKER_VERSION,
324
+ at: new Date().toISOString(),
325
+ pass: fail === 0,
326
+ gates: {
327
+ resolution: { pass: resolution.length - rFail.length, total: resolution.length },
328
+ completeness: { pass: completeness.length - cFail.length, total: completeness.length },
329
+ policy: { pass: policy.length - pFail.length, total: policy.length },
330
+ absence: { pass: absence.length - aFail.length, total: absence.length },
331
+ inventory: { pass: inventory.length - iFail.length, total: inventory.length },
332
+ },
333
+ warns: warns.length,
334
+ },
335
+ null,
336
+ 2,
337
+ ) + "\n",
338
+ );
339
+
153
340
  console.log(md);
154
- console.log(`resolution ${resolution.length - rFail.length}/${resolution.length} · completeness ${completeness.length - cFail.length}/${completeness.length} · policy ${policy.length - pFail.length}/${policy.length}` + (fail ? ` · ${fail} FAILED` : " · all pass"));
341
+ console.log(
342
+ `resolution ${resolution.length - rFail.length}/${resolution.length} · completeness ${completeness.length - cFail.length}/${completeness.length} · policy ${policy.length - pFail.length}/${policy.length} · absence ${absence.length - aFail.length}/${absence.length} · inventory ${inventory.length - iFail.length}/${inventory.length}` +
343
+ (warns.length ? ` · ${warns.length} warn(s)` : "") +
344
+ (fail ? ` · ${fail} FAILED` : " · all pass"),
345
+ );
155
346
  return fail ? 1 : 0;
156
347
  }