@rafinery/cli 0.5.0 → 0.7.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rafa.mjs +37 -5
  3. package/blueprint/.claude/agents/atlas.md +2 -1
  4. package/blueprint/.claude/agents/sage.md +66 -0
  5. package/blueprint/.claude/commands/rafa.md +214 -269
  6. package/blueprint/.claude/rafa/contract.md +204 -115
  7. package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
  8. package/blueprint/.claude/rafa/hooks/pre-push +24 -0
  9. package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
  10. package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
  11. package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
  12. package/blueprint/.claude/skills/rafa-build/SKILL.md +20 -5
  13. package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
  15. package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
  16. package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
  17. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  18. package/lib/benchmark.mjs +573 -0
  19. package/lib/blueprint.mjs +11 -1
  20. package/lib/brain-repo.mjs +10 -4
  21. package/lib/ci-setup.mjs +2 -0
  22. package/lib/claude-config.mjs +77 -0
  23. package/lib/dirty.mjs +114 -0
  24. package/lib/distill.mjs +4 -0
  25. package/lib/gate/compile.mjs +293 -44
  26. package/lib/gate/verify-citations.mjs +214 -23
  27. package/lib/githook.mjs +54 -0
  28. package/lib/init.mjs +18 -0
  29. package/lib/pull.mjs +7 -0
  30. package/lib/push.mjs +21 -0
  31. package/lib/reflex.mjs +76 -0
  32. package/lib/releases.mjs +35 -0
  33. package/lib/status.mjs +152 -0
  34. package/lib/update.mjs +13 -0
  35. package/package.json +1 -1
@@ -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
  }
@@ -0,0 +1,54 @@
1
+ // The git pre-push hook installer — M5 sensor #3 (the deterministic checkpoint
2
+ // boundary). `git push` of the code branch is a ratified natural checkpoint
3
+ // moment; this hook makes it MECHANICAL instead of SOP-remembered.
4
+ //
5
+ // .git/hooks is per-clone (git never versions it), so this runs at init AND
6
+ // pull (the teammate path) AND update — every working copy gets the sensor.
7
+ // Never clobbers a foreign hook: ours is recognized by its marker line; anything
8
+ // else is respected and the chain-line printed for the dev to add themselves.
9
+
10
+ import { chmodSync, copyFileSync, existsSync, readFileSync } from "node:fs";
11
+ import { execSync } from "node:child_process";
12
+ import { join } from "node:path";
13
+
14
+ const MARKER = "rafa pre-push hook";
15
+ const CHAIN_LINE = "npx -y @rafinery/cli checkpoint || true";
16
+
17
+ export function installPrePush(targetDir) {
18
+ const src = join(targetDir, ".claude", "rafa", "hooks", "pre-push");
19
+ if (!existsSync(src)) return { skipped: "no vendored hook template (run `rafa update`)" };
20
+
21
+ let hooksDir;
22
+ try {
23
+ // --git-path resolves worktrees/submodules correctly; never assume .git is a dir.
24
+ hooksDir = execSync("git rev-parse --git-path hooks", { cwd: targetDir, encoding: "utf8" }).trim();
25
+ hooksDir = join(targetDir, hooksDir);
26
+ } catch {
27
+ return { skipped: "not a git repo" };
28
+ }
29
+
30
+ const dst = join(hooksDir, "pre-push");
31
+ if (existsSync(dst)) {
32
+ const body = readFileSync(dst, "utf8");
33
+ if (!body.includes(MARKER)) {
34
+ return {
35
+ skipped:
36
+ "a pre-push hook already exists (not rafa's) — to chain the checkpoint boundary, " +
37
+ `add this line to it yourself: ${CHAIN_LINE}`,
38
+ };
39
+ }
40
+ // ours → refresh in place (idempotent update)
41
+ copyFileSync(src, dst);
42
+ chmodSync(dst, 0o755);
43
+ return { updated: true };
44
+ }
45
+ copyFileSync(src, dst);
46
+ chmodSync(dst, 0o755);
47
+ return { installed: true };
48
+ }
49
+
50
+ export function reportGitHook(r) {
51
+ if (r.installed) console.log(" ✓ git pre-push hook → checkpoint runs at every code push (non-blocking)");
52
+ else if (r.updated) console.log(" ✓ git pre-push hook refreshed");
53
+ else if (r.skipped) console.log(` ! git pre-push hook not installed — ${r.skipped}`);
54
+ }
package/lib/init.mjs CHANGED
@@ -105,6 +105,24 @@ export default async function init(args) {
105
105
  );
106
106
  // Grant rafa's tools their permissions (merge, never overwrite).
107
107
  reportSettings(TARGET);
108
+ // Wire the M5 sensor hooks (SessionStart digest + PostToolUse dirty-marker)
109
+ // into .claude/settings.json — merge, never clobber the dev's own hooks.
110
+ {
111
+ const { mergeClaudeHooks, mergeStatusLine } = await import("./claude-config.mjs");
112
+ const h = mergeClaudeHooks(TARGET);
113
+ if (h.skipped) console.log(` ! hooks untouched — ${h.skipped}`);
114
+ else if (h.added?.length) console.log(` ✓ sensor hooks → .claude/settings.json (${h.added.join(", ")})`);
115
+ else console.log(" ✓ sensor hooks already wired");
116
+ const sl = mergeStatusLine(TARGET);
117
+ if (sl.skipped) console.log(` ! statusline untouched — ${sl.skipped}`);
118
+ else if (sl.added?.length) console.log(" ✓ loop-state statusline → .claude/settings.json");
119
+ else console.log(" ✓ loop-state statusline already wired");
120
+ }
121
+ // The pre-push checkpoint boundary is per-clone (.git/hooks is never committed).
122
+ {
123
+ const { installPrePush, reportGitHook } = await import("./githook.mjs");
124
+ reportGitHook(installPrePush(TARGET));
125
+ }
108
126
  // Wire the repo's own MCP servers into the (owned) agent cards so subagents
109
127
  // can actually reach them (zero-command orchestration, 2026-07-12).
110
128
  {
package/lib/pull.mjs CHANGED
@@ -32,6 +32,13 @@ export default async function pull(args = []) {
32
32
  const inRafa = inDir(rafaDir);
33
33
  if (bootstrapped) console.log(`• bootstrapped lazy .rafa/ from rafa.json (remote: ${remote})`);
34
34
 
35
+ // Every working copy gets the M5 checkpoint boundary (.git/hooks is per-clone —
36
+ // pull IS the teammate path, so this is where a fresh clone gains the sensor).
37
+ {
38
+ const { installPrePush, reportGitHook } = await import("./githook.mjs");
39
+ reportGitHook(installPrePush(ROOT));
40
+ }
41
+
35
42
  // Reachability + default branch — loud if the remote can't be reached.
36
43
  let branch = "main";
37
44
  try {
package/lib/push.mjs CHANGED
@@ -13,6 +13,7 @@ import { execSync } from "node:child_process";
13
13
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { runCompile } from "./gate/compile.mjs";
16
+ import { runVerifyCitations } from "./gate/verify-citations.mjs";
16
17
  import { ensureBrainRepo, remoteDefaultBranch, inDir, die } from "./brain-repo.mjs";
17
18
  import { CLI_VERSION } from "./releases.mjs";
18
19
 
@@ -39,6 +40,26 @@ export default async function push(args = []) {
39
40
  /* no origin — manifest.repo stays "" */
40
41
  }
41
42
 
43
+ // ── citation gate (in-process) ──
44
+ // Re-run the checker HERE so the record that rides the push is the record of
45
+ // THIS push — a stale/hand-stamped citation-check.json can never ship
46
+ // (mechanized 2026-07-13 from prism's live-run recommendation: the stale-record
47
+ // class dies at the transport layer, not in review).
48
+ console.log("• rafa verify-citations — re-grounding every cite/absence/inventory …");
49
+ if (runVerifyCitations([]) !== 0)
50
+ die(
51
+ "citations failed the checker (see report above). Fix the notes against the " +
52
+ "code (or have atlas repair them), then re-push.",
53
+ );
54
+ if (
55
+ existsSync(join(ROOT, ".rafa", "improve", "improvements")) &&
56
+ runVerifyCitations(["--root=.rafa/improve", "--dirs=improvements"]) !== 0
57
+ )
58
+ die(
59
+ "improvement citations failed the checker (see report above). Fix the ledger " +
60
+ "files against the code (or have bloom correct them), then re-push.",
61
+ );
62
+
42
63
  // ── contract gate (in-process) ──
43
64
  // Compile + validate the brain. A schema-invalid brain NEVER leaves the machine —
44
65
  // the platform ingests JSON, so what we push must already conform to the contract.
package/lib/reflex.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // rafa reflex — the correction queue's query/consume surface (M5, agent-internal;
2
+ // the UserPromptSubmit sensor writes the queue, the conductor works it).
3
+ //
4
+ // rafa reflex list unprocessed corrections
5
+ // rafa reflex --json machine shape (bootstrap digest / spawn prompts)
6
+ // rafa reflex --consume <id> [reason] mark one processed — AFTER it was banked
7
+ // through the gates, or judged session-only
8
+ //
9
+ // Consume APPENDS a done-marker line ({id, done:true, verdict, at}) — the queue
10
+ // is append-only like every M5 sensor stream (monotonic, torn-line tolerant);
11
+ // readers treat the latest line per id as truth. Nothing here ever ships: the
12
+ // queue is transport-excluded; only distilled cited notes pass the gates.
13
+
14
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ export function readReflex(root = process.cwd()) {
18
+ const file = join(root, ".rafa", "reflex.jsonl");
19
+ const byId = new Map(); // id → latest entry (done-markers override)
20
+ if (!existsSync(file)) return { file, pending: [], done: 0 };
21
+ for (const line of readFileSync(file, "utf8").split("\n")) {
22
+ if (!line.trim()) continue;
23
+ try {
24
+ const e = JSON.parse(line);
25
+ if (e?.id) byId.set(e.id, { ...(byId.get(e.id) ?? {}), ...e });
26
+ } catch {
27
+ /* torn line — skip */
28
+ }
29
+ }
30
+ const all = [...byId.values()];
31
+ return {
32
+ file,
33
+ pending: all.filter((e) => !e.done),
34
+ done: all.filter((e) => e.done).length,
35
+ };
36
+ }
37
+
38
+ export default async function reflex(args = []) {
39
+ const ROOT = process.cwd();
40
+ const { file, pending, done } = readReflex(ROOT);
41
+
42
+ const ci = args.indexOf("--consume");
43
+ if (ci !== -1) {
44
+ const id = args[ci + 1];
45
+ if (!id || id.startsWith("-")) {
46
+ console.error("✗ usage: rafa reflex --consume <id> [banked|session-only|refuted]");
47
+ process.exit(1);
48
+ }
49
+ if (!pending.some((e) => e.id === id)) {
50
+ console.error(`✗ no unprocessed correction with id ${id} (see \`rafa reflex\`)`);
51
+ process.exit(1);
52
+ }
53
+ const verdict = args[ci + 2] && !args[ci + 2].startsWith("-") ? args[ci + 2] : "banked";
54
+ appendFileSync(
55
+ file,
56
+ JSON.stringify({ id, done: true, verdict, at: new Date().toISOString() }) + "\n",
57
+ );
58
+ console.log(`✓ correction ${id} consumed (${verdict})`);
59
+ return;
60
+ }
61
+
62
+ if (args.includes("--json")) {
63
+ console.log(JSON.stringify({ pending, done }, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (pending.length === 0) {
68
+ console.log(`✓ reflex queue clean${done ? ` (${done} processed)` : ""} — no unbanked corrections`);
69
+ return;
70
+ }
71
+ console.log(`${pending.length} unprocessed correction(s):`);
72
+ for (const e of pending) console.log(` · ${e.id} [${e.t}] "${e.p}"`);
73
+ console.log(
74
+ "→ for each: bank it through the gates if it's durable repo knowledge (branch: note + checkpoint · main: full gates), then `rafa reflex --consume <id>` — or consume with `session-only` if it isn't.",
75
+ );
76
+ }
package/lib/releases.mjs CHANGED
@@ -133,6 +133,41 @@ export const RELEASES = [
133
133
  "files mechanically (parent→epic, child→task, blocked→todo+blocked_reason). Brain data " +
134
134
  "schema unchanged; no re-scan.",
135
135
  },
136
+ {
137
+ version: "0.6.0",
138
+ contract: 1,
139
+ plans: 2,
140
+ requires: "update",
141
+ summary:
142
+ "The core-solidity release — checker v2 + the M5 capture engine. RATCHET: " +
143
+ "verify-citations gains the two 2026-06-08 catches as gates — ABSENCE (declared " +
144
+ "`absent:` tokens re-grepped every run; a stale absence claim fails at gate 1) and " +
145
+ "INVENTORY (coverage `inventory: <name> :: <glob> :: <count>` recomputed via git " +
146
+ "ls-files; surface drift fails) — plus a non-failing WARN heuristic for undeclared " +
147
+ "absence-shaped claims (prism's worklist). The checker records its run " +
148
+ "(citation-check.json) and compile folds it into manifest.citations {checkerVersion, " +
149
+ "pass, at} — the platform learns which gate level a brain passed. M5 SENSORS " +
150
+ "(deterministic capture moments — no more SOP-only checkpoints): SessionStart state " +
151
+ "digest (staleness · conflicts · active plan), PostToolUse dirty-marking " +
152
+ "(.rafa/dirty.jsonl, monotonic, no session-end dependency), git pre-push runs " +
153
+ "`rafa checkpoint` (non-blocking; installed per-clone at init/pull/update). New " +
154
+ "`rafa dirty [--json|--consume]` — the cite-graph invalidator's query surface: dirty " +
155
+ "files → citing notes → scoped refresh offer at boundaries; drift threshold → " +
156
+ "fire-alarm (/rafa scan --brain-only). Hooks vendor LOCKSTEP at .claude/rafa/hooks/ " +
157
+ "and are disabled in headless/CI runs (RAFA_HOOKS_DISABLED=1 — the recursion guard). " +
158
+ "THE CORRECTION REFLEX: correction-shaped prompts are queued (.rafa/reflex.jsonl) and " +
159
+ "steer the session to validate + bank them through the gates SAME-SESSION (rafa reflex " +
160
+ "--consume closes each with a verdict; ungroundable claims never bank). " +
161
+ "THE FRICTIONLESS LOOP: loop-state statusline (rafa ▸ plan 2/5 · 3 stale · 1 correction; " +
162
+ "never replaces a dev's own statusline; `rafa status --line|--json` is the harness-" +
163
+ "neutral core — Claude Code today, Cursor/Codex adapters later) · a deterministic " +
164
+ "'suggested next' line in the session digest · DIRECT-DO routing (small work = no plan " +
165
+ "files, no approval; escalates only when it grows) · brainstorm mode (grounded " +
166
+ "participant, one crystallization offer). " +
167
+ "`rafa push` now re-runs the checker itself (stale/hand-stamped check records can " +
168
+ "never ship). Brain data schema unchanged; no re-scan; adopt with `rafa update` " +
169
+ "(re-vendors hooks, merges settings + statusline, installs the pre-push boundary).",
170
+ },
136
171
  ];
137
172
 
138
173
  // The release this CLI build ships (last entry).