@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.
- package/CHANGELOG.md +97 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +2 -1
- package/blueprint/.claude/commands/rafa.md +71 -6
- package/blueprint/.claude/rafa/contract.md +32 -0
- package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
- package/blueprint/.claude/rafa/hooks/pre-push +24 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
- package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
- package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +11 -4
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +14 -3
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/blueprint.mjs +9 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +77 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +4 -0
- package/lib/gate/compile.mjs +40 -0
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +18 -0
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +35 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +13 -0
- package/package.json +1 -1
package/lib/claude-config.mjs
CHANGED
|
@@ -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(
|
package/lib/gate/compile.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|