@rafinery/cli 0.8.1 → 0.8.3

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 (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/rafa-distiller.mjs +13 -0
  3. package/bin/rafa.mjs +9 -8
  4. package/blueprint/.claude/agents/atlas.md +5 -1
  5. package/blueprint/.claude/agents/bloom.md +5 -1
  6. package/blueprint/.claude/agents/compass.md +5 -1
  7. package/blueprint/.claude/agents/prism.md +5 -1
  8. package/blueprint/.claude/agents/sage.md +5 -1
  9. package/blueprint/.claude/commands/rafa.md +20 -2
  10. package/blueprint/.claude/rafa/contract.md +31 -3
  11. package/blueprint/.claude/rafa/hooks/brain-commit.mjs +81 -0
  12. package/blueprint/.claude/rafa/hooks/brain-map.mjs +64 -0
  13. package/blueprint/.claude/rafa/hooks/brain-switch.mjs +48 -0
  14. package/blueprint/.claude/rafa/hooks/post-checkout +10 -0
  15. package/blueprint/.claude/rafa/hooks/post-commit +11 -0
  16. package/blueprint/.claude/rafa/hooks/post-rewrite +10 -0
  17. package/blueprint/.claude/rafa/hooks/pre-push +22 -0
  18. package/blueprint/.claude/rafa/hooks/session-start.mjs +93 -0
  19. package/blueprint/.claude/skills/rafa-improve/SKILL.md +5 -0
  20. package/blueprint/.claude/skills/rafa-scan/SKILL.md +1 -1
  21. package/blueprint/.claude/skills/rafa-validate/SKILL.md +6 -0
  22. package/lib/checkpoint.mjs +42 -0
  23. package/lib/ci-setup.mjs +54 -3
  24. package/lib/dirty.mjs +37 -0
  25. package/lib/distill.mjs +313 -37
  26. package/lib/distiller/doctrine.mjs +379 -0
  27. package/lib/distiller/state-plane.mjs +159 -0
  28. package/lib/distiller.mjs +410 -0
  29. package/lib/doctor.mjs +110 -0
  30. package/lib/gate/compile.mjs +31 -0
  31. package/lib/gate/verify-citations.mjs +9 -2
  32. package/lib/githook.mjs +75 -33
  33. package/lib/init.mjs +22 -23
  34. package/lib/mcp-client.mjs +22 -5
  35. package/lib/pull.mjs +2 -2
  36. package/lib/push.mjs +67 -2
  37. package/lib/releases.mjs +13 -0
  38. package/lib/sensor-health.mjs +91 -0
  39. package/lib/status.mjs +47 -1
  40. package/lib/update.mjs +2 -2
  41. package/lib/working-set.mjs +19 -1
  42. package/package.json +12 -10
  43. package/LICENSE +0 -21
@@ -0,0 +1,410 @@
1
+ // The distiller loop — the metered core boot.sh hands off to (reconciliation
2
+ // orchestrator, spec §2.6; refinery-arc-p1-distiller). boot.sh has already won the
3
+ // claim (claim-on-start) and exported RAFA_ATTEMPT (the fence token); this is the
4
+ // run that owns the repo. Sequence:
5
+ //
6
+ // heartbeat on → collect the folded working sets → arbitrate against merged main
7
+ // (the mechanical DOCTRINE, doctrine.mjs) → author survivors + the per-run
8
+ // reconciliation-report concept → okf emit → verify-citations → compile → push
9
+ // (NEVER force) → reconcile_report (node delta + per-branch outcomes + meter).
10
+ //
11
+ // The ONE non-mechanical step is claim-level truth — judged by the org's own LLM
12
+ // behind the `judge` seam. Every other decision (fold, re-ground, prune, sweep,
13
+ // latest-merge-wins, provenance, failure class) is deterministic doctrine, so the
14
+ // whole loop is drivable in tests with a fake judge + fake platform + a manual clock.
15
+ //
16
+ // Refutations are a GREEN run (reconcile_report success with refuted outcomes), not
17
+ // a failure. A gate rejection is DETERMINISTIC (needs-attention now, no retry spend);
18
+ // a boot/clone/network fault is TRANSIENT (backoff retry while a later branch proceeds).
19
+
20
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { createRequire } from "node:module";
23
+ import { pathToFileURL } from "node:url";
24
+ import { runOkfEmit } from "./gate/okf-emit.mjs";
25
+ import { runVerifyCitations } from "./gate/verify-citations.mjs";
26
+ import { runCompile } from "./gate/compile.mjs";
27
+ import push from "./push.mjs";
28
+ import pull from "./pull.mjs";
29
+ import { callTool } from "./mcp-client.mjs";
30
+ import {
31
+ startHeartbeat,
32
+ makeLogBatcher,
33
+ reportSuccess,
34
+ reportFailure,
35
+ } from "./distiller/state-plane.mjs";
36
+ import {
37
+ foldCandidates,
38
+ arbitrateCandidate,
39
+ resolveCollision,
40
+ rewriteCites,
41
+ changedFilesSince,
42
+ mergeRangeSweep,
43
+ outcomeRow,
44
+ perBranchOutcomes,
45
+ buildDelta,
46
+ renderReconReport,
47
+ classifyError,
48
+ deterministicFailure,
49
+ transientFailure,
50
+ } from "./distiller/doctrine.mjs";
51
+
52
+ // Load the already-mirrored org brain notes for the merge-range sweep: every
53
+ // `.rafa/brain/{rules,playbooks}/**/*.md` (the `await pull(["--full"])` in the
54
+ // entry puts them on disk before the loop runs). Skips generated `index.md`,
55
+ // scratch `_*.md`, and conflict `*.theirs.md`; reconciliations/ is excluded by
56
+ // only walking rules + playbooks. Returns [{ path, content }] with bundle-relative
57
+ // paths ("brain/rules/x.md") mirroring foldCandidates' candidate shape.
58
+ export function loadBrainNotes(cwd = process.cwd()) {
59
+ const brain = join(cwd, ".rafa", "brain");
60
+ const walkMd = (dir) => {
61
+ if (!existsSync(dir)) return [];
62
+ return readdirSync(dir).flatMap((e) => {
63
+ const p = join(dir, e);
64
+ if (statSync(p).isDirectory()) return walkMd(p);
65
+ return e.endsWith(".md") && e !== "index.md" && !e.startsWith("_") && !e.endsWith(".theirs.md")
66
+ ? [p]
67
+ : [];
68
+ });
69
+ };
70
+ const notes = [];
71
+ for (const sub of ["rules", "playbooks"]) {
72
+ for (const p of walkMd(join(brain, sub)))
73
+ notes.push({ path: "brain/" + p.slice(brain.length + 1), content: readFileSync(p, "utf8") });
74
+ }
75
+ return notes;
76
+ }
77
+
78
+ // A brain-relative path that would escape .rafa/ is refused loudly (mirrors
79
+ // distill.mjs safeRel — never write, never guess).
80
+ const safeRel = (p) => {
81
+ if (p.startsWith("/") || p.split("/").includes(".."))
82
+ throw deterministicFailure(`unsafe working-set path: ${p}`);
83
+ return p;
84
+ };
85
+
86
+ // The Agent SDK is not a hard dependency of the CLI (heavy; only the metered judge
87
+ // needs it). Same isolated-install resolution order as distill.mjs.
88
+ async function loadAgentSdk(cwd) {
89
+ const roots = [process.env.RAFA_AGENT_SDK_DIR, null, cwd];
90
+ for (const root of roots) {
91
+ try {
92
+ if (root === null) return await import("@anthropic-ai/claude-agent-sdk");
93
+ if (!root) continue;
94
+ const req = createRequire(join(root, "package.json"));
95
+ return await import(pathToFileURL(req.resolve("@anthropic-ai/claude-agent-sdk")).href);
96
+ } catch {
97
+ /* try the next root */
98
+ }
99
+ }
100
+ throw deterministicFailure(
101
+ "@anthropic-ai/claude-agent-sdk is not installed — the distiller image bakes it in " +
102
+ "(RAFA_AGENT_SDK_DIR points at the isolated install).",
103
+ );
104
+ }
105
+
106
+ // ── the injectable orchestration ────────────────────────────────────────────────
107
+ // Every external effect is a seam with a real default: `call` (platform MCP),
108
+ // `judge` (org LLM), `collectSets` (working sets), `orgNotes` (brain notes for the
109
+ // sweep), gate/push runners, timers, clock. Tests pass deterministic fakes; the
110
+ // sandbox path builds the real ones in the default export below.
111
+ export async function runDistiller({
112
+ env = process.env,
113
+ cwd = process.cwd(),
114
+ call,
115
+ judge,
116
+ collectSets,
117
+ orgNotes = async () => [],
118
+ cursorSha = env.RAFA_CURSOR_SHA || "",
119
+ parents = (env.RAFA_PARENT_SHAS || "").split(",").filter(Boolean),
120
+ authorSurvivor,
121
+ removeNote,
122
+ authorReport,
123
+ runGates,
124
+ doPush,
125
+ now = () => Date.now(),
126
+ setTimer = setInterval,
127
+ clearTimer = clearInterval,
128
+ log = console.log,
129
+ } = {}) {
130
+ const reconciliationId = env.RAFA_LEAD_RECONCILIATION_ID;
131
+ const attempt = Number(env.RAFA_ATTEMPT);
132
+ const mergeSha = env.RAFA_MERGE_SHA;
133
+ const tier = env.RAFA_TIER === "provisional" ? "provisional" : "canonical";
134
+ const model = env.RAFA_MODEL || "claude-opus-4-8";
135
+ const actor = { model, agent: "distiller", runner: "sandbox" };
136
+ if (!reconciliationId) throw deterministicFailure("RAFA_LEAD_RECONCILIATION_ID is not set");
137
+ if (!Number.isFinite(attempt)) throw deterministicFailure("RAFA_ATTEMPT (the fence token) is not set");
138
+ if (!mergeSha) throw deterministicFailure("RAFA_MERGE_SHA is not set");
139
+
140
+ const t0 = now();
141
+ const meter = { machineSeconds: 0, tokens: 0 };
142
+ let fenced = false;
143
+
144
+ // Token accounting rides the judge seam's return (`{ hold, reason, tokens }`) —
145
+ // the ONLY metered step is claim-level truth. This wrapper accumulates it so
146
+ // reconcile_report's meter reflects real LLM spend, never a hardcoded 0.
147
+ let judgedTokens = 0;
148
+ const meteredJudge = async (cand, ctx) => {
149
+ const v = await judge(cand, ctx);
150
+ judgedTokens += v && Number.isFinite(v.tokens) ? v.tokens : 0;
151
+ return v;
152
+ };
153
+
154
+ const hb = startHeartbeat({
155
+ call, reconciliationId, attempt, actor, phase: "reconciling",
156
+ setTimer, clearTimer,
157
+ onFenced: () => { fenced = true; log("! heartbeat fenced (a newer attempt owns this run) — aborting"); },
158
+ });
159
+ const logs = makeLogBatcher({ call, reconciliationId, attempt, actor, setTimer, clearTimer, warn: log });
160
+
161
+ try {
162
+ logs.append("distiller: collecting folded working sets");
163
+ const branchSets = await collectSets();
164
+ hb.setPhase("arbitrating");
165
+
166
+ // Fold N branches into ONE run; a path carried by >1 branch is a collision.
167
+ const { candidates, collisions } = foldCandidates(branchSets);
168
+ const dispositions = [];
169
+
170
+ // Non-colliding candidates: arbitrate each against merged main.
171
+ for (const cand of candidates) {
172
+ const d = await arbitrateCandidate(cand, cwd, { judge: meteredJudge });
173
+ dispositions.push({ ...d, candidate: cand });
174
+ }
175
+ // Collisions: judge every version, latest-merge-wins tiebreak (case 5).
176
+ for (const { versions } of collisions) {
177
+ for (const d of await resolveCollision(versions, cwd, { judge: meteredJudge })) dispositions.push(d);
178
+ }
179
+
180
+ // Merge-range sweep (case 4, second half): existing org notes citing files the
181
+ // merge changed get their citations re-resolved — re-ground or prune, same rule.
182
+ // changedFilesSince returns null on an UNREACHABLE range (a cursor sha not yet
183
+ // in the clone) — honor that contract: a transient skip (backoff retry once the
184
+ // sha lands), a loud log line, NEVER a silent coerce-to-[] that would let stale
185
+ // org notes through undetected.
186
+ const changed = changedFilesSince(cursorSha, mergeSha, cwd);
187
+ if (changed === null) {
188
+ logs.append(`distiller: merge-range ${cursorSha}..${mergeSha} unreachable — transient skip, retrying`);
189
+ throw transientFailure(`merge-range sweep: ${cursorSha}..${mergeSha} unreachable (cursor sha not in the clone yet?)`);
190
+ }
191
+ const orgNoteList = await orgNotes();
192
+ const sweep = mergeRangeSweep({ changedFiles: changed, orgNotes: orgNoteList, cwd });
193
+ // Observability: the sweep's reach is REPORTED (a true 0 is fine; an
194
+ // indistinguishable stub is not). Counts only — never note content.
195
+ logs.append(
196
+ `distiller: merge-range sweep — ${orgNoteList.length} org note(s) loaded · ` +
197
+ `${changed.length} changed file(s) in range · ${sweep.length} citation(s) re-checked`,
198
+ );
199
+ for (const s of sweep) {
200
+ if (s.disposition.status === "deleted") {
201
+ dispositions.push({
202
+ outcome: "pruned", noteId: s.noteId, cites: [s.cite],
203
+ detail: `sweep: ${s.disposition.record}`,
204
+ candidate: { path: s.notePath, branch: "(org-brain)", capturedAtSha: mergeSha },
205
+ sweepPath: s.notePath,
206
+ });
207
+ } else if (s.disposition.status === "regrounded") {
208
+ dispositions.push({
209
+ outcome: "rewritten", noteId: s.noteId, cites: [s.disposition.to],
210
+ detail: `sweep: re-grounded ${s.cite.loc} → ${s.disposition.to.loc}`,
211
+ candidate: { path: s.notePath, branch: "(org-brain)", capturedAtSha: mergeSha },
212
+ });
213
+ }
214
+ }
215
+
216
+ // Author survivors into the brain; prune deletions; refuted/folded author nothing.
217
+ hb.setPhase("authoring");
218
+ for (const d of dispositions) {
219
+ if (d.outcome === "banked") {
220
+ authorSurvivor({ path: safeRel(d.candidate.path), content: d.candidate.content });
221
+ } else if (d.outcome === "rewritten" && d.candidate.content) {
222
+ authorSurvivor({
223
+ path: safeRel(d.candidate.path),
224
+ content: rewriteCites(d.candidate.content, d.regroundings),
225
+ });
226
+ } else if (d.outcome === "pruned" && d.sweepPath) {
227
+ removeNote(safeRel(d.sweepPath));
228
+ }
229
+ }
230
+
231
+ // The per-run reconciliation-report concept (provenance record).
232
+ const outcomeRows = dispositions.map((d) =>
233
+ outcomeRow({
234
+ candidate: d.candidate, outcome: d.outcome, detail: d.detail,
235
+ reconciliationId, supersededBy: d.supersededBy,
236
+ }),
237
+ );
238
+ meter.machineSeconds = Math.round((now() - t0) / 1000);
239
+ meter.tokens = judgedTokens; // real LLM spend from the judge seam, not a hardcoded 0
240
+ const report = renderReconReport({
241
+ mergeSha, outcome: "succeeded", tier, reconciliationId, outcomeRows, meter,
242
+ });
243
+ authorReport(report);
244
+ logs.append(`distiller: ${outcomeRows.length} outcome(s) — running gates`);
245
+
246
+ // Gates + push. A gate rejection is DETERMINISTIC (retrying only burns spend).
247
+ hb.setPhase("gating");
248
+ if (fenced) throw deterministicFailure("run fenced before gating — a newer attempt owns it");
249
+ const gate = runGates();
250
+ if (!gate.ok) throw deterministicFailure(gate.message || "authored brain failed the gates");
251
+ hb.setPhase("pushing");
252
+ doPush(); // never force — push.mjs rebase-replays, aborts loudly on real conflict
253
+
254
+ // Terminal success (refutations included in the delta — a refute-everything run
255
+ // is GREEN here, not a failure).
256
+ const delta = buildDelta(dispositions);
257
+ const outcomes = perBranchOutcomes(branchSets, outcomeRows);
258
+ const res = await reportSuccess({ call, reconciliationId, attempt, actor, parents, delta, outcomes, meter });
259
+ log(
260
+ `✓ reconciliation ${reconciliationId}: ${delta.banked.length} banked · ${delta.rewritten.length} rewritten · ` +
261
+ `${delta.pruned.length} pruned · ${delta.refuted.length} refuted · ${collisions.length} collision(s)`,
262
+ );
263
+ return { ok: true, delta, outcomes, meter, report, reportResult: res };
264
+ } catch (e) {
265
+ const errorClass = classifyError(e);
266
+ const message = e instanceof Error ? e.message : String(e);
267
+ // A fenced run must not stamp a terminal state (the newer attempt owns it).
268
+ if (fenced) {
269
+ log(`! run fenced — leaving the terminal write to the owning attempt (${message})`);
270
+ return { ok: false, fenced: true, errorClass, message };
271
+ }
272
+ log(`✗ reconciliation ${reconciliationId} failed (${errorClass}): ${message}`);
273
+ const res = await reportFailure({ call, reconciliationId, attempt, actor, errorClass, message });
274
+ return { ok: false, errorClass, message, reportResult: res };
275
+ } finally {
276
+ hb.stop();
277
+ await logs.stop();
278
+ }
279
+ }
280
+
281
+ // ── the CLI entry (bin/rafa-distiller.mjs · boot.sh's DISTILLER_ENTRY seam) ───────
282
+ // Builds the REAL seams from the sandbox environment and runs the loop. This is the
283
+ // path that only exercises end-to-end inside the pinned image (E2B/Docker) — the
284
+ // exit-gate sandbox-run clause. The doctrine + state-plane it composes are proven
285
+ // at module level without a sandbox.
286
+ export default async function distiller(_args = []) {
287
+ process.env.RAFA_HOOKS_DISABLED = "1"; // never re-fire session sensors from the worker
288
+ const cwd = process.cwd();
289
+ const env = process.env;
290
+ const repo = env.RAFA_REPO;
291
+
292
+ // Real platform call — mirrors boot.sh exactly (JSON-RPC 2.0 to RAFA_MCP_URL, key
293
+ // in the Authorization header never logged, `repo` = RAFA_REPO). The route spreads
294
+ // the tool payload to the TOP LEVEL of structuredContent, so { ok, attempt, … } is
295
+ // read there. Reuses mcp-client's transport error shaping.
296
+ const call = async (tool, args = {}) => {
297
+ // callTool derives repo/key/url from rafa.json + env; in the image RAFA_MCP_URL,
298
+ // RAFA_MCP_KEY, and the committed rafa.json are all present, and the reconcile
299
+ // tools scope by the agent key. Pass repo explicitly for parity with boot.sh.
300
+ return callTool(cwd, tool, { repo, ...args });
301
+ };
302
+
303
+ // Real judge seam — the org's own LLM (Agent SDK, ANTHROPIC_API_KEY from the image
304
+ // secrets). Claim-level truth ONLY; the doctrine decides everything else. Kept
305
+ // behind loadAgentSdk so tests never touch it.
306
+ const judge = async (candidate) => {
307
+ const sdk = await loadAgentSdk(cwd);
308
+ const run = sdk.query({
309
+ prompt:
310
+ "You are the distiller's claim judge. The checked-out repo IS merged main.\n" +
311
+ "Judge ONLY whether this note's normative claim still holds against the code as it now\n" +
312
+ "stands. Its citations already ground (the doctrine verified that). Reply with a single\n" +
313
+ `JSON line {"hold": true|false, "reason": "<cited reason if false>"}.\n\n` +
314
+ `Note (${candidate.path}):\n${candidate.content}`,
315
+ options: { cwd, permissionMode: "bypassPermissions", allowedTools: ["Read", "Grep", "Glob", "Bash"], settingSources: [], maxTurns: 30 },
316
+ });
317
+ let verdict = { hold: true };
318
+ let tokens = 0;
319
+ for await (const m of run) {
320
+ if (m.type === "assistant")
321
+ for (const b of m.message?.content ?? []) {
322
+ if (b.type !== "text") continue;
323
+ const mm = b.text.match(/\{[^{}]*"hold"[^{}]*\}/);
324
+ if (mm) { try { verdict = JSON.parse(mm[0]); } catch { /* keep last */ } }
325
+ }
326
+ if (m.type === "result") {
327
+ if (m.subtype !== "success") throw deterministicFailure(`judge did not complete (${m.subtype})`);
328
+ // Usage from the SDK result message (same field distill.mjs meters on) →
329
+ // the metering wrapper accumulates it into reconcile_report's meter.tokens.
330
+ tokens += (m.usage?.input_tokens ?? 0) + (m.usage?.output_tokens ?? 0);
331
+ }
332
+ }
333
+ return { ...verdict, tokens };
334
+ };
335
+
336
+ // Real working-set collector — the folded branches the run reconciles. The
337
+ // reconcile fold groups them; here we enumerate live branches and gather each.
338
+ const collectSets = async () => {
339
+ const { branches } = await call("list_working_sets", {});
340
+ const sets = [];
341
+ for (const b of branches ?? []) {
342
+ const branch = typeof b === "string" ? b : b.branch;
343
+ const allFiles = (await call("get_working_set", { branch, status: "active" })).files ?? [];
344
+ // Intent records are provenance, never arbitrated claims (P2) — the
345
+ // note-shaped doctrine must not judge them.
346
+ const files = allFiles.filter((f) => !f.path.startsWith("intent/"));
347
+ if (files.length)
348
+ sets.push({
349
+ branch,
350
+ // Set-level grounding: the newest per-row capture sha when the rows
351
+ // carry one (branch listings never did — b.capturedAtSha was always
352
+ // undefined), else the triggering merge sha as before.
353
+ capturedAtSha:
354
+ files
355
+ .filter((f) => typeof f.capturedAtSha === "string" && f.capturedAtSha)
356
+ .sort((a, b2) => (b2.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
357
+ ?.capturedAtSha || mergeShaOf(env),
358
+ mergedAt: (typeof b === "object" && b.mergedAt) || 0,
359
+ files: files.map((f) => ({
360
+ path: f.path,
361
+ content: f.content,
362
+ ...(f.capturedAtSha ? { capturedAtSha: f.capturedAtSha } : {}),
363
+ })),
364
+ });
365
+ }
366
+ return sets;
367
+ };
368
+
369
+ const brainRoot = join(cwd, ".rafa", "brain");
370
+ const authorSurvivor = ({ path, content }) => {
371
+ const abs = join(cwd, ".rafa", path);
372
+ mkdirSync(dirname(abs), { recursive: true });
373
+ writeFileSync(abs, content);
374
+ };
375
+ const removeNote = (path) => rmSync(join(cwd, ".rafa", path), { force: true });
376
+ const authorReport = ({ path, content }) => {
377
+ const abs = join(cwd, ".rafa", path);
378
+ mkdirSync(dirname(abs), { recursive: true });
379
+ writeFileSync(abs, content);
380
+ };
381
+
382
+ // Gates re-run OURS, trust-but-verify — the authoring push order (contract §11):
383
+ // okf emit → verify-citations → compile. A non-zero anywhere is a deterministic
384
+ // gate rejection.
385
+ const runGates = () => {
386
+ if (runOkfEmit([`--repo=${repo || ""}`]) !== 0)
387
+ return { ok: false, message: "okf emit failed on the authored brain" };
388
+ if (runVerifyCitations([]) !== 0)
389
+ return { ok: false, message: "citation checker failed on the authored brain" };
390
+ if (runCompile([`--repo=${repo || ""}`]) !== 0)
391
+ return { ok: false, message: "compile gate failed on the authored brain" };
392
+ return { ok: true };
393
+ };
394
+ const doPush = () => push(["--verb=distill"]); // push.mjs re-runs the same gates + pushes; NEVER --force
395
+
396
+ // Mirror the org brain first (the authoring target the survivors fold into).
397
+ await pull(["--full"]);
398
+ if (!existsSync(brainRoot)) mkdirSync(brainRoot, { recursive: true });
399
+
400
+ const result = await runDistiller({
401
+ env, cwd, call, judge, collectSets, authorSurvivor, removeNote, authorReport, runGates, doPush,
402
+ orgNotes: async () => loadBrainNotes(cwd), // the mirrored brain is the sweep source
403
+ });
404
+ if (!result.ok && !result.fenced) process.exitCode = 1;
405
+ return result;
406
+ }
407
+
408
+ function mergeShaOf(env) {
409
+ return env.RAFA_MERGE_SHA || "";
410
+ }
package/lib/doctor.mjs ADDED
@@ -0,0 +1,110 @@
1
+ // rafa doctor — prove the capture machinery end to end (capture-engine P0).
2
+ // Sensors fail silent BY DESIGN inside sessions (never block the dev); doctor
3
+ // is the loud, on-demand proof: provisioning → hook wiring → script sanity →
4
+ // the platform round-trip (the heartbeat actually LANDS). Run it after init /
5
+ // ci-setup, or whenever capture looks quiet. Exit 1 on any failure, with the
6
+ // named fix — an unfalsifiable "hooks are probably fine" is exactly what this
7
+ // command exists to kill.
8
+
9
+ import { execSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { callTool, resolveMcp } from "./mcp-client.mjs";
13
+ import { collectSensorHealth } from "./sensor-health.mjs";
14
+
15
+ // Callable form — init runs this as its final proof step (advisory there:
16
+ // init already succeeded; doctor names any remaining fix). Returns the
17
+ // failure count; the CLI command wraps it with the exit code.
18
+ export async function runDoctor(ROOT = process.cwd()) {
19
+ let failures = 0;
20
+ const ok = (m) => console.log(` ✓ ${m}`);
21
+ const bad = (m, fix) => {
22
+ failures++;
23
+ console.log(` ✗ ${m}`);
24
+ if (fix) console.log(` fix: ${fix}`);
25
+ };
26
+
27
+ console.log("rafa doctor — proving the capture machinery\n");
28
+
29
+ // 1 · provisioning — the same resolution the real tools use, so a doctor
30
+ // pass here means checkpoint/distill auth will resolve identically.
31
+ console.log("• provisioning");
32
+ let provisioned = false;
33
+ try {
34
+ const { repoId, mcpUrl, keySource, urlSource } = resolveMcp(ROOT);
35
+ provisioned = true;
36
+ ok(`repo "${repoId}" → ${mcpUrl} (key: ${keySource} · url: ${urlSource})`);
37
+ } catch (e) {
38
+ bad(
39
+ e instanceof Error ? e.message.split("\n")[0] : String(e),
40
+ "run `rafa init <setup-url>` (platform → repo → generate setup command)",
41
+ );
42
+ }
43
+
44
+ // 2 · sensors — wiring truth (settings ∧ script) + per-clone git hook.
45
+ console.log("• sensors");
46
+ const health = collectSensorHealth(ROOT);
47
+ for (const h of health.hooks) {
48
+ const life =
49
+ typeof h.entries === "number"
50
+ ? ` · ${h.entries} queue entrie(s)${h.lastFireAt ? `, last ${new Date(h.lastFireAt).toISOString()}` : ""}`
51
+ : "";
52
+ if (h.wired) ok(`${h.name} wired${life}`);
53
+ else
54
+ bad(
55
+ `${h.name} NOT wired`,
56
+ h.name === "pre-push"
57
+ ? "run `rafa init`/`rafa pull` in this clone — the git hook is per-clone"
58
+ : "run `rafa update` — re-merges the hooks into .claude/settings.json",
59
+ );
60
+ if (h.lastError)
61
+ bad(
62
+ `${h.name} last run failed: ${h.lastError}${h.lastErrorAt ? ` (${new Date(h.lastErrorAt).toISOString()})` : ""}`,
63
+ "run `rafa checkpoint` manually and read its output",
64
+ );
65
+ }
66
+ // Script sanity: a wired-but-unparseable hook would still fail silently.
67
+ for (const script of [
68
+ "session-start.mjs",
69
+ "post-tool.mjs",
70
+ "user-prompt-submit.mjs",
71
+ ]) {
72
+ const abs = join(ROOT, ".claude", "rafa", "hooks", script);
73
+ if (!existsSync(abs)) continue; // wiring check above already reported it
74
+ try {
75
+ execSync(`node --check "${abs}"`, { stdio: "ignore" });
76
+ ok(`${script} parses`);
77
+ } catch {
78
+ bad(`${script} has a syntax error`, "run `rafa update` to re-vendor it");
79
+ }
80
+ }
81
+
82
+ // 3 · the round-trip — the heartbeat must LAND, not just send. A recorded
83
+ // response proves key + url + repo scoping + the platform handler, end to end.
84
+ console.log("• platform round-trip");
85
+ if (provisioned) {
86
+ try {
87
+ const res = await callTool(ROOT, "report_sensor_health", health);
88
+ if (res?.recorded) ok("heartbeat landed (report_sensor_health → recorded)");
89
+ else bad("heartbeat sent but the platform did not confirm it", "deploy the latest platform (report_sensor_health ships with capture-engine P0)");
90
+ } catch (e) {
91
+ bad(
92
+ e instanceof Error ? e.message.split("\n")[0] : String(e),
93
+ "the message above names the failing piece (key/url/deploy)",
94
+ );
95
+ }
96
+ } else {
97
+ console.log(" · skipped (not provisioned)");
98
+ }
99
+
100
+ console.log(
101
+ failures
102
+ ? `\n✗ doctor: ${failures} problem(s) — capture is NOT fully live; fixes named above`
103
+ : "\n✓ doctor: all clear — sensors wired, scripts parse, heartbeat lands",
104
+ );
105
+ return failures;
106
+ }
107
+
108
+ export default async function doctor() {
109
+ if ((await runDoctor()) > 0) process.exit(1);
110
+ }
@@ -418,6 +418,35 @@ export function runCompile(argv = []) {
418
418
  return { domains };
419
419
  }
420
420
 
421
+ // Reconciliation reports (contract §11 file-type registry — a per-run
422
+ // outcome/provenance record the distiller authors into the bundle at merge-to-main;
423
+ // reconciliation orchestrator, spec 2026-07-14). A `structured` class: required
424
+ // fields FAIL LOUDLY here. Deletion/refutation records ride INSIDE the body
425
+ // (sections), so there is ONE class, not three. Validated like plans but does NOT
426
+ // ride the manifest — it travels on the brain-repo transport. The stamped
427
+ // `type: Reconciliation Report` (OKF §11) is validated by checkOkfFields.
428
+ const RECON_OUTCOMES = ["succeeded", "needs-attention", "superseded"];
429
+ const RECON_TIERS = ["canonical", "provisional"];
430
+ function compileReconciliations() {
431
+ let count = 0;
432
+ for (const path of walk(join(ROOT, "brain", "reconciliations"))) {
433
+ const name = path.split("/").pop();
434
+ const { data, error } = parseFrontmatter(read(path));
435
+ if (error) {
436
+ fail(path, "frontmatter", error);
437
+ continue;
438
+ }
439
+ count++;
440
+ checkVersion(data, path);
441
+ checkId(data, path, name);
442
+ checkOkfFields(data, path, { typed: true }); // stamped `type: Reconciliation Report`
443
+ reqStr(data, "run", path); // the merge commit sha this run reconciled (provenance)
444
+ reqEnum(data, "outcome", RECON_OUTCOMES, path);
445
+ reqEnum(data, "tier", RECON_TIERS, path);
446
+ }
447
+ return count;
448
+ }
449
+
421
450
  // citation-check.json — the checker's machine record of its last run (generated
422
451
  // by `rafa verify-citations`). Folded into manifest.citations so the platform
423
452
  // knows which gate level this brain passed. Absent file → null (an honest
@@ -849,6 +878,7 @@ export function runCompile(argv = []) {
849
878
  const ledger = compileLedger();
850
879
  const coverage = compileCoverage();
851
880
  const citations = compileCitations();
881
+ const reconciliations = compileReconciliations();
852
882
  const plans = compilePlans();
853
883
  const activePlanId = compileActivePlanId(plans);
854
884
  const agentCards = compileAgentCards();
@@ -908,6 +938,7 @@ export function runCompile(argv = []) {
908
938
  console.log(
909
939
  `✓ rafa compile: ${notes.length} notes · ${improvements.length} improvements · ` +
910
940
  `${plans.length} plans validated${activePlanId ? ` (active: ${activePlanId})` : ""} (plans travel via the plans channel, not the manifest) · ` +
941
+ `${reconciliations} reconciliation reports validated (brain-repo transport, not the manifest) · ` +
911
942
  `health ${health ? "ok" : "—"} · ledger ${ledger ? "ok" : "—"} · ` +
912
943
  `coverage ${coverage ? `${coverage.domains.length} domains` : "—"} · ` +
913
944
  `${agentCards} agent cards + ${shippedSops} SOPs${learnings ? ` + ${learnings} learnings` : ""} (local gate) → ${ROOT}/manifest.json`,
@@ -88,7 +88,11 @@ function walkBundleMd(dir, base = dir) {
88
88
  });
89
89
  }
90
90
 
91
- function gitGrep(token, cwd = process.cwd()) {
91
+ // EXPORTED (refinery-arc-p1-distiller): the distiller's arbitration doctrine
92
+ // reuses this SAME resolution machinery — a cite is re-grounded against main
93
+ // by re-grepping its token, and pruned when the grep comes back empty. One
94
+ // implementation of "does this token still live in the code," gate + doctrine.
95
+ export function gitGrep(token, cwd = process.cwd()) {
92
96
  try {
93
97
  const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8", cwd });
94
98
  return out.split("\n").filter(Boolean).map((l) => {
@@ -115,7 +119,10 @@ function gitLsGlob(glob, cwd = process.cwd()) {
115
119
  }
116
120
  }
117
121
 
118
- function citeResolves(file, start, end, token) {
122
+ // EXPORTED (refinery-arc-p1-distiller): the doctrine validates a candidate's
123
+ // citations against merged main through the exact same predicate the gate uses,
124
+ // so "resolves for the checker" and "resolves for arbitration" can never drift.
125
+ export function citeResolves(file, start, end, token) {
119
126
  if (!existsSync(file)) return { ok: false, reason: "file missing" };
120
127
  const src = readFileSync(file, "utf8").split("\n");
121
128
  if (start < 1 || end > src.length) return { ok: false, reason: `lines ${start}-${end} out of range (${src.length})` };