@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
@@ -0,0 +1,573 @@
1
+ // rafa benchmark — the brain-vs-cold token-efficiency proof, productized.
2
+ //
3
+ // Reproduces the 2026-06-03 audit method
4
+ // (examples/sample-brain/audits/2026-06-03-brain-vs-cold-execution.md) as a
5
+ // repeatable harness anyone can point at THEIR scanned repo:
6
+ //
7
+ // 1. Cut TWO isolated git worktrees from the current sha —
8
+ // A = cold (agent forbidden from reading .rafa/)
9
+ // B = brain (reads .rafa/brain/ first as its index)
10
+ // 2. Run the SAME pre-clarified task fixture in each (ship a default modeled on
11
+ // the audit's "Saved Investigations"; override with --task <file>).
12
+ // 3. Count tokens from the HARNESS counters — never self-reports (audit rule,
13
+ // :29) — per phase (scope · build · correction) and total, for A and B.
14
+ // 4. Audit the two diffs (which conventions each obeyed/breached).
15
+ // 5. Emit a machine-readable benchmark.json (schema below; consumed by the
16
+ // benchmark-ingest task / dashboard widget).
17
+ //
18
+ // Honesty — TWO axes, both stamped in the emitted JSON so a consumer can never
19
+ // mistake one class of number for another:
20
+ // • measured-vs-estimated: a real run's ratio is a MEASURED result of THIS run
21
+ // on THIS repo — never the dashboard's ESTIMATED baseline.
22
+ // • real-vs-fixture: `--dry-run` writes the AUDIT's fixture numbers to exercise
23
+ // the scaffold — those are demo data, NOT a measurement of anything.
24
+ // So the writer stamps `measured` (true only for a real `--counts` run, false for
25
+ // `--dry-run`), `kind` ("measured" | "fixture"), and a matching caveat. And
26
+ // `--dry-run` NEVER writes the consumer path (.rafa/benchmark.json) — it defaults
27
+ // to .rafa/benchmark.demo.json — so fixture data can't be ingested as real.
28
+ // A small repo is close to the worst case for amortization (audit :90) — the gap
29
+ // widens on larger codebases.
30
+ //
31
+ // Automation boundary — SCOPE OF THIS SCAFFOLD:
32
+ // • worktree cut + seed + cleanup, the default task fixture, per-phase token
33
+ // INTAKE, the diff-audit step, and the benchmark.json writer are automated.
34
+ // • `--dry-run` exercises worktree isolation + the writer end-to-end from
35
+ // built-in fixture counts (the audit's numbers) with NO live agent.
36
+ // • Full unattended two-agent orchestration is OUT of scope — the two agents
37
+ // are driven by a human/agent loop (the audit was reviewer-driven). The run
38
+ // procedure is DOCUMENTED (RUN_PROCEDURE below + `--help`); a measurement is
39
+ // NEVER faked — real numbers only ever arrive through `--counts <file>`.
40
+ //
41
+ // benchmark.json schema (schemaVersion 1):
42
+ // {
43
+ // schemaVersion: 1,
44
+ // measured, // true = real --counts run; false = --dry-run fixture
45
+ // kind, // "measured" (real) | "fixture" (demo). Both ≠ the
46
+ // // dashboard's ESTIMATED baseline.
47
+ // repo, codeSha, task, at, // task = the fixture id
48
+ // cold: { phases: {scope,build,correction}, totalTokens },
49
+ // brain: { phases: {scope,build,correction}, totalTokens },
50
+ // ratio, // coldTotal / brainTotal ("N×")
51
+ // improvement: { kind, breach?, prevented? },
52
+ // caveat
53
+ // }
54
+
55
+ import { execSync } from "node:child_process";
56
+ import {
57
+ cpSync,
58
+ existsSync,
59
+ mkdtempSync,
60
+ readFileSync,
61
+ rmSync,
62
+ writeFileSync,
63
+ } from "node:fs";
64
+ import { tmpdir } from "node:os";
65
+ import { basename, join } from "node:path";
66
+ import { callTool } from "./mcp-client.mjs";
67
+
68
+ const die = (m) => {
69
+ console.error(`✗ ${m}`);
70
+ process.exit(1);
71
+ };
72
+
73
+ // `--name <value>` reader (the lib idiom — see fold.mjs).
74
+ const flag = (args, name) => {
75
+ const i = args.indexOf(name);
76
+ return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--")
77
+ ? args[i + 1]
78
+ : null;
79
+ };
80
+
81
+ const sh = (cmd, cwd) =>
82
+ execSync(cmd, {
83
+ cwd,
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ }).trim();
87
+
88
+ const SCHEMA_VERSION = 1;
89
+
90
+ // ── the default task fixture (audit's "Saved Investigations") ────────────────
91
+ // Pre-clarified so the load-bearing design fork is already answered — this
92
+ // isolates EXECUTION cost, exactly as the audit did (both agents got the same
93
+ // open-ended "list the contracts your change depends on"; neither was told
94
+ // which to watch).
95
+ const DEFAULT_TASK = {
96
+ id: "saved-investigations",
97
+ title: "Saved Investigations",
98
+ prompt:
99
+ "Authenticated /app/investigations: a user creates an investigation, chats with the " +
100
+ "LangGraph agent; a server tool `recordFinding` appends to CopilotKit shared CoAgent " +
101
+ "state; investigation + findings persist in Convex scoped to the Clerk user; listed on " +
102
+ "return visits.",
103
+ preClarified: [
104
+ "Persist in Convex, scoped to ctx.auth.getUserIdentity().subject (the Clerk user).",
105
+ "recordFinding is a SERVER tool that appends to shared CoAgent state.",
106
+ "Reuse the existing starterAgent graph — do not add a new graph.",
107
+ "Keep the frontend/backend AgentState shape aligned.",
108
+ ],
109
+ // Not handed to the agents (they get the open-ended prompt) — used by the
110
+ // diff-audit step as ground truth for which conventions to check.
111
+ conventionsToWatch: [
112
+ 'RSC boundary: "use client" only at the leaf, never route-level (silent breach).',
113
+ "Convex queries/mutations scoped to the Clerk subject.",
114
+ "AgentState shape aligned frontend↔backend.",
115
+ "No new LangGraph graph — reuse starterAgent.",
116
+ ],
117
+ };
118
+
119
+ // ── the fixture counts (--dry-run) — the audit's measured numbers ────────────
120
+ // Used ONLY to exercise the writer + isolation without a live agent. NOT a
121
+ // substitute for a real run; a genuine measurement arrives via --counts.
122
+ const FIXTURE_COUNTS = {
123
+ cold: { scope: 32300, build: 94500, correction: 104700 },
124
+ brain: { scope: 35200, build: 77300, correction: 0 },
125
+ };
126
+ const FIXTURE_IMPROVEMENT = {
127
+ kind: "silent-convention",
128
+ breach:
129
+ 'cold tagged the route "use client", silently disabling RSC down the subtree',
130
+ prevented: true,
131
+ };
132
+ const MEASURED_CAVEAT =
133
+ "MEASURED on this repo — not the estimated baseline. A small repo is near the worst " +
134
+ "case for amortization; the gap widens on larger codebases (audit 2026-06-03, :90).";
135
+ const FIXTURE_CAVEAT =
136
+ "FIXTURE/DEMO data (the audit's numbers) — NOT a measurement of this repo. Emitted by " +
137
+ "`rafa benchmark --dry-run` to exercise the scaffold. Run a real agent measurement and " +
138
+ "emit via `--counts` for a `measured: true` result.";
139
+
140
+ // ── the diff-audit step ──────────────────────────────────────────────────────
141
+ // Scaffold: given the two worktrees + the fixture's conventionsToWatch, this is
142
+ // where a reviewer (or agent) records which conventions each diff obeyed or
143
+ // breached — the audit's method of judging diffs against ground truth, NOT the
144
+ // agents' self-reports. Without a live run there are no diffs to judge, so the
145
+ // improvement summary is carried in the counts intake (`improvement` field) or
146
+ // the fixture. Kept as a named seam so the real run has one place to fill in.
147
+ function auditDiff({ coldPath, brainPath, task, improvement }) {
148
+ return {
149
+ conventionsWatched: task.conventionsToWatch,
150
+ // A real run populates per-worktree obeyed/breached from the audited diffs.
151
+ coldWorktree: coldPath,
152
+ brainWorktree: brainPath,
153
+ improvement: improvement ?? null,
154
+ };
155
+ }
156
+
157
+ // ── the writer ───────────────────────────────────────────────────────────────
158
+ // Assembles benchmark.json from per-phase token counts. Total = sum of the
159
+ // phases (matches the audit arithmetic exactly). ratio = coldTotal / brainTotal.
160
+ //
161
+ // `measured` is LOAD-BEARING and REQUIRED — it is the honesty stamp the a4 ingest
162
+ // keys on. true = a real `--counts` run (kind "measured"); false = `--dry-run`
163
+ // fixture/demo data (kind "fixture"). The caveat matches. A caller that omits it
164
+ // is a bug, so we don't default it — fixture data must never silently inherit the
165
+ // "measured" stamp.
166
+ export function buildResult({
167
+ repo,
168
+ codeSha,
169
+ task,
170
+ coldPhases,
171
+ brainPhases,
172
+ improvement,
173
+ measured,
174
+ caveat,
175
+ at,
176
+ }) {
177
+ if (typeof measured !== "boolean")
178
+ die(
179
+ "buildResult: `measured` (boolean) is required — refusing to emit an unlabeled result.",
180
+ );
181
+ const sum = (phases) =>
182
+ Object.values(phases).reduce(
183
+ (a, b) => a + (Number.isFinite(Number(b)) ? Number(b) : 0),
184
+ 0,
185
+ );
186
+ const coldTotal = sum(coldPhases);
187
+ const brainTotal = sum(brainPhases);
188
+ const ratio =
189
+ brainTotal > 0 ? Number((coldTotal / brainTotal).toFixed(2)) : null;
190
+ return {
191
+ schemaVersion: SCHEMA_VERSION,
192
+ measured, // true = real measurement; false = --dry-run fixture — the a4 ingest keys on this
193
+ kind: measured ? "measured" : "fixture",
194
+ repo: repo ?? "",
195
+ codeSha: codeSha ?? "unknown",
196
+ task: typeof task === "string" ? task : task.id,
197
+ at: at ?? new Date().toISOString(),
198
+ cold: { phases: coldPhases, totalTokens: coldTotal },
199
+ brain: { phases: brainPhases, totalTokens: brainTotal },
200
+ ratio,
201
+ improvement: improvement ?? null,
202
+ caveat: caveat ?? (measured ? MEASURED_CAVEAT : FIXTURE_CAVEAT),
203
+ };
204
+ }
205
+
206
+ // ── worktree plumbing (git — the CLI already owns this surface) ──────────────
207
+ function repoIdentity(root) {
208
+ let codeSha = "unknown";
209
+ try {
210
+ codeSha = sh("git rev-parse --short HEAD", root);
211
+ } catch {
212
+ /* no commits yet — fine */
213
+ }
214
+ let repo = "";
215
+ try {
216
+ const origin = sh("git remote get-url origin", root);
217
+ const m = origin.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
218
+ if (m) repo = `${m[1]}/${m[2]}`;
219
+ } catch {
220
+ /* no origin */
221
+ }
222
+ return { codeSha, repo };
223
+ }
224
+
225
+ // Cut the two isolated worktrees from the current sha. A=cold has NO .rafa/
226
+ // (worktrees only check out TRACKED files, and .rafa/ is gitignored, so cold is
227
+ // naturally brain-blind). B=brain gets a copy of the live .rafa/brain/ seeded in.
228
+ function cutWorktrees(root) {
229
+ let sha;
230
+ try {
231
+ sha = sh("git rev-parse HEAD", root);
232
+ } catch {
233
+ die(
234
+ "cannot resolve HEAD — run `rafa benchmark` from a repo with at least one commit.",
235
+ );
236
+ }
237
+ const dir = mkdtempSync(join(tmpdir(), "rafa-benchmark-"));
238
+ const coldPath = join(dir, "cold");
239
+ const brainPath = join(dir, "brain");
240
+
241
+ sh(`git worktree add --detach ${JSON.stringify(coldPath)} ${sha}`, root);
242
+ sh(`git worktree add --detach ${JSON.stringify(brainPath)} ${sha}`, root);
243
+
244
+ // Defensive: cold must never carry a brain (it won't, but enforce the contract).
245
+ const coldRafa = join(coldPath, ".rafa");
246
+ if (existsSync(coldRafa)) rmSync(coldRafa, { recursive: true, force: true });
247
+
248
+ // Seed the brain worktree from the live brain, if a scan has produced one.
249
+ const srcBrain = join(root, ".rafa", "brain");
250
+ let brainSeeded = false;
251
+ if (existsSync(srcBrain)) {
252
+ cpSync(srcBrain, join(brainPath, ".rafa", "brain"), { recursive: true });
253
+ brainSeeded = true;
254
+ }
255
+ return { dir, coldPath, brainPath, sha, brainSeeded };
256
+ }
257
+
258
+ function cleanupWorktrees(root, wt) {
259
+ for (const p of [wt.coldPath, wt.brainPath]) {
260
+ try {
261
+ sh(`git worktree remove --force ${JSON.stringify(p)}`, root);
262
+ } catch {
263
+ /* already gone */
264
+ }
265
+ }
266
+ try {
267
+ rmSync(wt.dir, { recursive: true, force: true });
268
+ } catch {
269
+ /* best effort */
270
+ }
271
+ try {
272
+ sh("git worktree prune", root);
273
+ } catch {
274
+ /* best effort */
275
+ }
276
+ }
277
+
278
+ const countWorktrees = (root) => {
279
+ try {
280
+ return sh("git worktree list --porcelain", root)
281
+ .split("\n")
282
+ .filter((l) => l.startsWith("worktree ")).length;
283
+ } catch {
284
+ return 0;
285
+ }
286
+ };
287
+
288
+ // ── the documented run procedure (human/agent-driven measurement) ────────────
289
+ const RUN_PROCEDURE = `Run procedure (the real, agent-driven measurement — orchestration is intentionally
290
+ NOT automated: the two agents are driven by a human/agent loop, as the audit was):
291
+
292
+ 1. rafa benchmark # cuts + seeds the two worktrees, prints their paths
293
+ # and the identical task prompt; leaves them in place
294
+ 2. In worktree A (cold): run your agent on the printed task. It is FORBIDDEN from
295
+ reading .rafa/. Record the HARNESS token counter (not the agent's self-report)
296
+ for each phase: scope, build, correction.
297
+ 3. In worktree B (brain): run the SAME agent on the SAME task, told to read
298
+ .rafa/brain/ FIRST as its index. Record the same per-phase harness counts.
299
+ 4. Audit BOTH diffs against ground truth yourself (the conventionsToWatch) — judge
300
+ the diffs, not the agents' self-reports. Note which conventions each obeyed or
301
+ silently breached, and the improvement class it produced.
302
+ 5. Write a counts file, e.g. counts.json:
303
+ { "cold": { "scope": N, "build": N, "correction": N },
304
+ "brain": { "scope": N, "build": N, "correction": N },
305
+ "improvement": { "kind": "...", "breach": "...", "prevented": true } }
306
+ 6. rafa benchmark --counts counts.json --out benchmark.json
307
+ # emits the MEASURED benchmark.json + cleans up
308
+ 7. rafa benchmark --counts counts.json --push
309
+ # same, and reports it to the platform (agent-key MCP)
310
+ # → the repo's proof page shows "proven on this repo: N×".
311
+ # Or push a file you already emitted: rafa benchmark --push
312
+
313
+ Never invent a number. If you did not run an agent, use --dry-run (fixture counts,
314
+ clearly a demo) — real numbers arrive only through --counts.`;
315
+
316
+ const USAGE = `rafa benchmark — reproduce the brain-vs-cold token proof on THIS repo (measured).
317
+
318
+ Usage:
319
+ rafa benchmark Cut + seed the two isolated worktrees and print the
320
+ task + run procedure (leaves worktrees for the
321
+ agent-driven run; finish with --counts).
322
+ rafa benchmark --counts <file> Emit benchmark.json from a REAL per-phase counts file
323
+ (harness counters), then clean up the worktrees.
324
+ rafa benchmark --dry-run Exercise worktree isolation + the writer end-to-end
325
+ from built-in FIXTURE counts (no live agent), then
326
+ clean up. For verifying the scaffold — clearly a demo.
327
+ Emits measured:false / kind:"fixture".
328
+
329
+ Every result is stamped: "measured" (true only for a real --counts run, false for --dry-run
330
+ fixture data) and "kind" ("measured" | "fixture"). The ingest keys on "measured" — fixture
331
+ data can never be rendered as a real measurement.
332
+
333
+ Options:
334
+ --task <file> Override the task fixture (JSON: {id,title,prompt,conventionsToWatch}
335
+ or a plain .md/.txt used as the prompt). Default: "saved-investigations".
336
+ --counts <file> JSON of measured per-phase token counts (see run procedure).
337
+ --push Report the MEASURED result to the platform over the agent-key MCP
338
+ surface (report_benchmark — same key custody as usage metering), so
339
+ the repo's proof page shows "proven on this repo: N×". Refused for
340
+ fixture/--dry-run data. Standalone ("rafa benchmark --push") pushes an
341
+ already-emitted .rafa/benchmark.json.
342
+ --out <file> Where to write the result. Default: .rafa/benchmark.json for a real
343
+ --counts run; .rafa/benchmark.demo.json for --dry-run (never the
344
+ consumer path, so fixture data can't be ingested as real).
345
+ --keep Do not remove the worktrees after emitting (for inspection).
346
+ --dry-run Fixture mode (above).
347
+ -h, --help This help.
348
+
349
+ ${RUN_PROCEDURE}`;
350
+
351
+ function loadTask(path) {
352
+ if (!path) return DEFAULT_TASK;
353
+ if (!existsSync(path)) die(`--task file not found: ${path}`);
354
+ const raw = readFileSync(path, "utf8");
355
+ try {
356
+ const t = JSON.parse(raw);
357
+ if (!t.id) t.id = basename(path).replace(/\.[^.]+$/, "");
358
+ if (!t.conventionsToWatch) t.conventionsToWatch = [];
359
+ return t;
360
+ } catch {
361
+ // Not JSON — treat the whole file as the prompt.
362
+ return {
363
+ id: basename(path).replace(/\.[^.]+$/, ""),
364
+ title: "",
365
+ prompt: raw,
366
+ conventionsToWatch: [],
367
+ };
368
+ }
369
+ }
370
+
371
+ function loadCounts(path) {
372
+ if (!existsSync(path)) die(`--counts file not found: ${path}`);
373
+ let c;
374
+ try {
375
+ c = JSON.parse(readFileSync(path, "utf8"));
376
+ } catch (e) {
377
+ die(
378
+ `--counts file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
379
+ );
380
+ }
381
+ if (!c.cold || !c.brain)
382
+ die(
383
+ '--counts must be { "cold": {scope,build,correction}, "brain": {…} } (see `rafa benchmark --help`).',
384
+ );
385
+ return c;
386
+ }
387
+
388
+ function writeResult(outPath, result) {
389
+ writeFileSync(outPath, JSON.stringify(result, null, 2) + "\n");
390
+ }
391
+
392
+ // ── push the MEASURED result to the platform (a4 transport) ──────────────────
393
+ // Reuses the existing agent-key MCP surface (report_benchmark on the platform,
394
+ // same key custody as log_usage) — no new surface. MEASURED-ONLY on this side
395
+ // too: refuse a fixture/dry-run result before it ever leaves the machine, so the
396
+ // server's honesty gate is never the first line of defence. Best-effort by
397
+ // intent, but a rejected/non-measured push is LOUD (the whole point is proof).
398
+ async function pushResult(root, result) {
399
+ if (result?.measured !== true)
400
+ die(
401
+ "refusing to push a non-measured result (measured:false / kind:fixture) — " +
402
+ "proof is measured only. Emit a real result via `rafa benchmark --counts <file>`.",
403
+ );
404
+ if (typeof result.ratio !== "number" || !(result.ratio > 0))
405
+ die(
406
+ "refusing to push a degenerate result (ratio is null/≤0 — brain total was 0). Nothing to prove.",
407
+ );
408
+ await callTool(root, "report_benchmark", {
409
+ measured: true,
410
+ task: result.task,
411
+ codeSha: result.codeSha,
412
+ coldTokens: result.cold.totalTokens,
413
+ brainTokens: result.brain.totalTokens,
414
+ ratio: result.ratio,
415
+ at: result.at,
416
+ });
417
+ console.log(
418
+ `✓ pushed MEASURED result to the platform: ${result.ratio}× on task "${result.task}" ` +
419
+ `(${result.codeSha}) — the proof page will show "proven on this repo".`,
420
+ );
421
+ }
422
+
423
+ export default async function benchmark(args = []) {
424
+ if (args.includes("-h") || args.includes("--help")) {
425
+ console.log(USAGE);
426
+ return;
427
+ }
428
+
429
+ const root = process.cwd();
430
+ const task = loadTask(flag(args, "--task"));
431
+ const outFlag = flag(args, "--out");
432
+ const keep = args.includes("--keep");
433
+ const dryRun = args.includes("--dry-run");
434
+ const push = args.includes("--push");
435
+ const countsFile = flag(args, "--counts");
436
+ const { codeSha, repo } = repoIdentity(root);
437
+
438
+ // Fixture data is NEVER pushed as proof — refuse the combination up front so
439
+ // the intent is unmistakable (the server would refuse too; fail earlier).
440
+ if (push && dryRun)
441
+ die("--push cannot be combined with --dry-run — fixture data is never ingested as proof.");
442
+
443
+ // --dry-run NEVER defaults to the consumer path (.rafa/benchmark.json) — fixture
444
+ // data must not be ingestible as a real measurement. It writes .demo.json unless
445
+ // the operator explicitly redirects it with --out.
446
+ const out =
447
+ outFlag ??
448
+ join(root, ".rafa", dryRun ? "benchmark.demo.json" : "benchmark.json");
449
+
450
+ // ── --dry-run: isolation + writer, end-to-end, from fixture counts ──────────
451
+ if (dryRun) {
452
+ const before = countWorktrees(root);
453
+ console.log("• cutting two isolated worktrees (A=cold · B=brain) …");
454
+ const wt = cutWorktrees(root);
455
+ const coldBlind = !existsSync(join(wt.coldPath, ".rafa"));
456
+ const brainHas =
457
+ wt.brainSeeded && existsSync(join(wt.brainPath, ".rafa", "brain"));
458
+ console.log(
459
+ ` cold : ${wt.coldPath} [.rafa present: ${!coldBlind} → ${coldBlind ? "brain-blind ✓" : "LEAK ✗"}]`,
460
+ );
461
+ console.log(
462
+ ` brain : ${wt.brainPath} [.rafa/brain seeded: ${brainHas ? "yes ✓" : wt.brainSeeded ? "no ✗" : "no source brain — run a scan first"}]`,
463
+ );
464
+ console.log(` worktrees now: ${countWorktrees(root)} (was ${before})`);
465
+
466
+ const audit = auditDiff({
467
+ coldPath: wt.coldPath,
468
+ brainPath: wt.brainPath,
469
+ task,
470
+ improvement: FIXTURE_IMPROVEMENT,
471
+ });
472
+ const result = buildResult({
473
+ repo,
474
+ codeSha,
475
+ task,
476
+ coldPhases: FIXTURE_COUNTS.cold,
477
+ brainPhases: FIXTURE_COUNTS.brain,
478
+ improvement: audit.improvement,
479
+ measured: false, // fixture/demo — stamped kind:"fixture", never ingested as real
480
+ });
481
+ writeResult(out, result);
482
+ console.log(
483
+ `• [DRY-RUN — fixture counts, NOT a real measurement · measured:false kind:fixture] wrote ${out}: ` +
484
+ `${result.ratio}× (cold ${result.cold.totalTokens} / brain ${result.brain.totalTokens})`,
485
+ );
486
+
487
+ cleanupWorktrees(root, wt);
488
+ console.log(
489
+ `✓ worktrees cleaned up: ${countWorktrees(root)} remaining (was ${before})`,
490
+ );
491
+ console.log(
492
+ " Real numbers only via a live agent run — see `rafa benchmark --help`.",
493
+ );
494
+ return;
495
+ }
496
+
497
+ // ── --counts <file>: emit the MEASURED result from real intake ─────────────
498
+ if (countsFile) {
499
+ const counts = loadCounts(countsFile);
500
+ const result = buildResult({
501
+ repo,
502
+ codeSha,
503
+ task,
504
+ coldPhases: counts.cold,
505
+ brainPhases: counts.brain,
506
+ improvement: counts.improvement ?? null,
507
+ measured: true, // real intake — stamped kind:"measured"
508
+ });
509
+ writeResult(out, result);
510
+ console.log(
511
+ `✓ wrote MEASURED ${out}: ${result.ratio}× on task "${result.task}" ` +
512
+ `(cold ${result.cold.totalTokens} / brain ${result.brain.totalTokens} · ${codeSha})`,
513
+ );
514
+ // Push it to the platform if asked (reuses the agent-key MCP surface).
515
+ if (push) await pushResult(root, result);
516
+ // If worktrees from a prior `rafa benchmark` setup are lingering, prune them
517
+ // unless the operator asked to keep them.
518
+ if (!keep) {
519
+ try {
520
+ sh("git worktree prune", root);
521
+ } catch {
522
+ /* best effort */
523
+ }
524
+ }
525
+ return;
526
+ }
527
+
528
+ // ── --push (standalone): push an already-emitted MEASURED benchmark.json ────
529
+ // For the two-step flow — `--counts` writes the file, a later `--push` sends
530
+ // it. Reads the resolved --out (default .rafa/benchmark.json); the measured
531
+ // gate in pushResult refuses fixture/degenerate data.
532
+ if (push) {
533
+ if (!existsSync(out))
534
+ die(
535
+ `no ${out} to push — run \`rafa benchmark --counts <file>\` first (or pass --out <file>).`,
536
+ );
537
+ let result;
538
+ try {
539
+ result = JSON.parse(readFileSync(out, "utf8"));
540
+ } catch (e) {
541
+ die(`${out} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
542
+ }
543
+ await pushResult(root, result);
544
+ return;
545
+ }
546
+
547
+ // ── default: set up the run for the human/agent loop ───────────────────────
548
+ console.log(
549
+ "• cutting two isolated worktrees for the agent-driven measurement …",
550
+ );
551
+ const wt = cutWorktrees(root);
552
+ console.log(` cold (A, brain-blind): ${wt.coldPath}`);
553
+ console.log(
554
+ ` brain (B, reads .rafa/brain): ${wt.brainPath}` +
555
+ (wt.brainSeeded
556
+ ? ""
557
+ : " ! no .rafa/brain to seed — run `/rafa scan` first, else B runs cold too"),
558
+ );
559
+ console.log(
560
+ `\n Task "${task.id}"${task.title ? ` — ${task.title}` : ""}:\n ${task.prompt}`,
561
+ );
562
+ if (task.preClarified?.length)
563
+ console.log(
564
+ "\n Pre-clarified (both agents get these):\n" +
565
+ task.preClarified.map((p) => ` • ${p}`).join("\n"),
566
+ );
567
+ console.log("\n" + RUN_PROCEDURE);
568
+ console.log(
569
+ `\n When done: rafa benchmark --counts <file> --out ${out}` +
570
+ " (add --push to report it to the platform's proof page)" +
571
+ "\n Or discard: git worktree remove --force <path> (both), then git worktree prune.",
572
+ );
573
+ }
package/lib/blueprint.mjs CHANGED
@@ -30,6 +30,7 @@ export const BLUEPRINT = {
30
30
  ".claude/agents/prism.md",
31
31
  ".claude/agents/bloom.md",
32
32
  ".claude/agents/compass.md",
33
+ ".claude/agents/sage.md",
33
34
  ".claude/commands/rafa.md",
34
35
  ],
35
36
  ownedDirs: [
@@ -41,8 +42,13 @@ export const BLUEPRINT = {
41
42
  ".claude/skills/rafa-distill",
42
43
  ".claude/skills/rafa-insights",
43
44
  ".claude/skills/rafa-leverage",
45
+ ".claude/skills/rafa-sage",
44
46
  ],
45
47
  lockstep: [".claude/rafa/contract.md"],
48
+ // The M5 sensor hooks — deterministic protocol machinery (like the contract):
49
+ // they move with the CLI version, clients don't tune them. NOT knowledge —
50
+ // the brain (.rafa/) stays executable-free; these live in the code repo.
51
+ lockstepDirs: [".claude/rafa/hooks"],
46
52
  };
47
53
 
48
54
  const HERE = dirname(fileURLToPath(import.meta.url)); // …/packages/cli/lib
@@ -96,7 +102,11 @@ export function blueprintFiles(from) {
96
102
  ...BLUEPRINT.owned,
97
103
  ...BLUEPRINT.ownedDirs.flatMap((d) => filesUnder(from, d)),
98
104
  ];
99
- return { owned, lockstep: [...BLUEPRINT.lockstep] };
105
+ const lockstep = [
106
+ ...BLUEPRINT.lockstep,
107
+ ...(BLUEPRINT.lockstepDirs ?? []).flatMap((d) => filesUnder(from, d)),
108
+ ];
109
+ return { owned, lockstep };
100
110
  }
101
111
 
102
112
  function sameBytes(a, b) {
@@ -6,7 +6,7 @@
6
6
  // which materializes the skeleton + git wiring from the committed rafa.json so
7
7
  // `clone → npx rafa <cmd>` just works with no init step on the machine.
8
8
 
9
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { execSync } from "node:child_process";
11
11
  import { join } from "node:path";
12
12
  import { readStamp } from "./stamp.mjs";
@@ -73,10 +73,16 @@ export function ensureBrainRepo(cwd, { requireRemote = true } = {}) {
73
73
  }
74
74
 
75
75
  // Session/state debris never rides a brain push: the sidecar, conflict
76
- // copies, and distill staging are transport-excluded here (not knowledge).
76
+ // copies, distill staging, and the M5 dirty queue are transport-excluded here
77
+ // (not knowledge). MERGED line-by-line so clones ignored under an older CLI
78
+ // still gain new exclusions.
77
79
  const ignore = join(rafaDir, ".gitignore");
78
- const IGNORES = "hydration.json\n*.theirs.md\ndistill-incoming/\ndistill-verdicts.json\n";
79
- if (!existsSync(ignore)) writeFileSync(ignore, IGNORES);
80
+ const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl"];
81
+ const cur = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
82
+ const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
83
+ const missing = IGNORES.filter((l) => !have.has(l));
84
+ if (missing.length)
85
+ writeFileSync(ignore, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + missing.join("\n") + "\n");
80
86
 
81
87
  return { rafaDir, remote, bootstrapped };
82
88
  }
package/lib/ci-setup.mjs CHANGED
@@ -46,6 +46,7 @@ jobs:
46
46
  - name: Mechanical fold (no LLM — rigor lives at main)
47
47
  env:
48
48
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
49
+ RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
49
50
  run: npx -y @rafinery/cli fold --from "\${{ github.event.pull_request.head.ref }}" --to "\${{ github.event.pull_request.base.ref }}"
50
51
 
51
52
  distill:
@@ -66,6 +67,7 @@ jobs:
66
67
  ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
67
68
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
68
69
  RAFA_BRAIN_TOKEN: \${{ secrets.RAFA_BRAIN_TOKEN }}
70
+ RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
69
71
  run: npx -y @rafinery/cli distill --headless "\${{ github.event.pull_request.head.ref }}"
70
72
  `;
71
73