@rafinery/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/distill.mjs DELETED
@@ -1,665 +0,0 @@
1
- // rafa distill --headless <branch> — merge-to-main DISTILLATION in the org's
2
- // own CI (working-set architecture, ratified 2026-07-10, rule 7).
3
- //
4
- // The rigor moment of the gradient: every working-set file the branch captured
5
- // is validated against the CHECKED-OUT MERGED MAIN (prism-shaped skepticism),
6
- // survivors are authored into the org brain (atlas-shaped), and only what
7
- // passes verify-citations + compile is pushed to the brain repo. Contested or
8
- // low-confidence items are NEVER guessed — flagged needs-adjudication for the
9
- // next dev session's digest offer.
10
- //
11
- // The judgment runs on the Agent SDK, driven by the ORG'S OWN LLM key
12
- // (ANTHROPIC_API_KEY from CI secrets — never stored on the rafinery platform).
13
- // The gates stay deterministic and OURS: this CLI collects, stages, re-runs
14
- // the checkers itself (trust-but-verify), pushes, and resolves. The agent
15
- // judges; it never ships.
16
- //
17
- // Collection is branch-match PLUS ancestry sweep (capture-engine spec P1,
18
- // 2026-07-17): any still-active row on ANY branch whose capturedAtSha is
19
- // reachable from this checkout's HEAD *off the first-parent mainline* is
20
- // provably-merged backlog — an earlier merge whose distill never ran — and
21
- // folds into this run. Mainline-grounded rows (a checkpoint before the
22
- // branch's first commit) and squash/rebase-merged commits are NOT provable;
23
- // those rows stay active for their own merge event or the platform
24
- // reconciler. Same-path collisions: the merged branch's copy is judged
25
- // (backlog never displaces the triggering merge); other copies stay active
26
- // for their own later judgment — a verdict never applies to unjudged content.
27
- //
28
- // Fallback chain when this can't run (no key, no CI): the dev-session flow —
29
- // /rafa distill <branch> in Claude Code (the bootstrap digest offers it).
30
-
31
- import { execSync } from "node:child_process";
32
- import {
33
- existsSync,
34
- mkdirSync,
35
- readFileSync,
36
- rmSync,
37
- writeFileSync,
38
- } from "node:fs";
39
- import { createRequire } from "node:module";
40
- import { dirname, join } from "node:path";
41
- import { pathToFileURL } from "node:url";
42
- import { callTool } from "./mcp-client.mjs";
43
- import { trunkBranchOf } from "./stamp.mjs";
44
- import { runCompile, SCHEMA_VERSION } from "./gate/compile.mjs";
45
- import { runVerifyCitations } from "./gate/verify-citations.mjs";
46
- import {
47
- applyLadder,
48
- brainSchemaVersion,
49
- classOf,
50
- resolveSchemaPlan,
51
- sourceSchemaVersion,
52
- walkBrainNotes,
53
- } from "./distiller/schema-ladder.mjs";
54
- import pull from "./pull.mjs";
55
- import push from "./push.mjs";
56
-
57
- const die = (m) => {
58
- console.error(`✗ ${m}`);
59
- process.exit(1);
60
- };
61
-
62
- // A brain-relative path from the platform must stay inside .rafa/ — a row that
63
- // would escape is refused loudly (never written, never guessed).
64
- const safeRel = (p) => {
65
- if (p.startsWith("/") || p.split("/").includes(".."))
66
- die(`unsafe working-set path: ${p}`);
67
- return p;
68
- };
69
-
70
- // The Agent SDK is deliberately NOT a dependency of this CLI (it is heavy and
71
- // only CI distillation needs it). Resolution order: $RAFA_AGENT_SDK_DIR (the
72
- // workflow's ISOLATED install — `npm i` inside the client checkout breaks on
73
- // pnpm workspaces, "workspace:*" is not an npm protocol) → normal import →
74
- // the host repo's node_modules.
75
- async function loadAgentSdk(cwd) {
76
- const roots = [process.env.RAFA_AGENT_SDK_DIR, null, cwd];
77
- for (const root of roots) {
78
- try {
79
- if (root === null) return await import("@anthropic-ai/claude-agent-sdk");
80
- if (!root) continue;
81
- const req = createRequire(join(root, "package.json"));
82
- return await import(
83
- pathToFileURL(req.resolve("@anthropic-ai/claude-agent-sdk")).href
84
- );
85
- } catch {
86
- // try the next root
87
- }
88
- }
89
- die(
90
- "@anthropic-ai/claude-agent-sdk is not installed — the reconcile workflow installs it\n" +
91
- " into an isolated dir and points RAFA_AGENT_SDK_DIR at it (see rafa ci-setup;\n" +
92
- " re-run `npx @rafinery/cli ci-setup --overwrite` if your workflow predates this).",
93
- );
94
- }
95
-
96
- const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
97
-
98
- // ── collection helpers (pure; exported for tests) ───────────────────────────
99
-
100
- // Same-path collision across branches. The MERGED branch's own row always
101
- // wins — this run exists to judge the merge that triggered it; a swept
102
- // backlog row must never displace it. Between swept rows, newest updatedAt
103
- // wins with a deterministic branch-name tiebreak (platform iteration order
104
- // must never pick the winner). Superseded copies are NOT resolved here —
105
- // each copy is judged on its own merits in a later run (a verdict never
106
- // applies to content the judge did not see).
107
- export function latestWinsByPath(rows, preferredBranch) {
108
- const beats = (a, b) => {
109
- const aPref = a.branch === preferredBranch,
110
- bPref = b.branch === preferredBranch;
111
- if (aPref !== bPref) return aPref;
112
- const aAt = a.updatedAt ?? 0,
113
- bAt = b.updatedAt ?? 0;
114
- if (aAt !== bAt) return aAt > bAt;
115
- return String(a.branch) > String(b.branch);
116
- };
117
- const byPath = new Map();
118
- for (const r of rows) {
119
- const prev = byPath.get(r.path);
120
- if (!prev) byPath.set(r.path, { winner: r, superseded: [] });
121
- else if (beats(r, prev.winner)) {
122
- prev.superseded.push(prev.winner);
123
- prev.winner = r;
124
- } else prev.superseded.push(r);
125
- }
126
- return [...byPath.values()];
127
- }
128
-
129
- // Rows whose capture commit git can PROVE belongs to merged branch work:
130
- // reachable from HEAD AND off the first-parent mainline. Reachability alone
131
- // is not proof — a session that checkpoints before its branch's first commit
132
- // grounds at a MAINLINE sha (the branch point), which is reachable forever;
133
- // sweeping it would steal in-flight rows from open branches. Off-mainline
134
- // reachable = the commit arrived via a merge. Rebase/squash-merged work is
135
- // unprovable here and stays active (safety over recall — the platform
136
- // reconciler and the session digest remain its path). Only full shas match
137
- // (checkpoint stamps `git rev-parse HEAD`); anything else counts unproven,
138
- // never guessed.
139
- export function sweepByAncestry(rows, provenMerged) {
140
- const swept = [];
141
- let unproven = 0;
142
- for (const r of rows) {
143
- if (
144
- typeof r.capturedAtSha === "string" &&
145
- /^[0-9a-f]{40,64}$/i.test(r.capturedAtSha) &&
146
- provenMerged(r.capturedAtSha.toLowerCase())
147
- )
148
- swept.push(r);
149
- else unproven++;
150
- }
151
- return { swept, unproven };
152
- }
153
-
154
- export default async function distill(args = []) {
155
- // U7 recursion guard, belt-and-braces: the Agent SDK worker must never fire
156
- // the M5 session sensors (a worker's own tool calls re-triggering hooks would
157
- // loop). The ci-setup workflow sets this too; this covers ad-hoc invocations.
158
- process.env.RAFA_HOOKS_DISABLED = "1";
159
- const branch = args.filter((a) => !a.startsWith("-"))[0];
160
- if (!args.includes("--headless"))
161
- die(
162
- "session distillation runs in Claude Code (/rafa distill <branch> — offer-driven at bootstrap).\n" +
163
- " This CLI runs the CI path: rafa distill --headless <branch>",
164
- );
165
- if (!branch) die("usage: rafa distill --headless <branch>");
166
- if (!process.env.ANTHROPIC_API_KEY)
167
- die(
168
- "ANTHROPIC_API_KEY is not set — CI distillation runs on the ORG'S OWN LLM key from CI\n" +
169
- " secrets (never stored on the rafinery platform). Add the secret (rafa ci-setup names\n" +
170
- " it), or fall back to the dev session: /rafa distill " +
171
- branch,
172
- );
173
-
174
- const ROOT = process.cwd();
175
- // Telemetry (owner feedback #9): every phase is timed and printed, so a CI
176
- // log answers "where is it, and where did the time go" at a glance.
177
- const t0 = Date.now();
178
- const secs = (t) => ((Date.now() - t) / 1000).toFixed(1);
179
- let agentModel = "unreported";
180
-
181
- // Run record (capture-engine P5): the Reconciliations tab renders CI runs
182
- // too — a terminal report_distill_run per NON-EMPTY run, keyed by the merge
183
- // commit (RAFA_MERGE_SHA from the workflow; HEAD of the merged checkout as
184
- // the fallback). Telemetry — best-effort, never fatal, and empty merges
185
- // write no row (honest no-op stays row-less).
186
- const mergeSha = (() => {
187
- const env = process.env.RAFA_MERGE_SHA ?? "";
188
- if (/^[0-9a-f]{7,64}$/i.test(env)) return env;
189
- try {
190
- return execSync("git rev-parse HEAD", {
191
- cwd: ROOT,
192
- encoding: "utf8",
193
- stdio: ["ignore", "pipe", "ignore"],
194
- }).trim();
195
- } catch {
196
- return "";
197
- }
198
- })();
199
- const reportRun = async (status, summary) => {
200
- if (!mergeSha) return;
201
- try {
202
- await callTool(ROOT, "report_distill_run", {
203
- branch,
204
- toBranch: process.env.RAFA_TARGET_BRANCH || trunkBranchOf(ROOT),
205
- mergeCommitSha: mergeSha,
206
- status,
207
- note: summary,
208
- actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
209
- });
210
- } catch {
211
- /* the run itself is the deliverable; its record is telemetry */
212
- }
213
- };
214
- const failRun = async (m) => {
215
- await reportRun("failed", m);
216
- die(m);
217
- };
218
-
219
- // Merged-work oracle: TWO subprocesses total — every commit reachable from
220
- // HEAD, and the first-parent mainline. provenMerged = reachable ∧
221
- // off-mainline (see sweepByAncestry). A shallow checkout truncates both
222
- // sets, which only ever UNDER-sweeps (rows stay active) — safe.
223
- const revList = (args) => {
224
- try {
225
- return new Set(
226
- execSync(`git rev-list ${args} HEAD`, {
227
- cwd: ROOT,
228
- encoding: "utf8",
229
- stdio: ["ignore", "pipe", "ignore"],
230
- maxBuffer: 256 * 1024 * 1024,
231
- })
232
- .trim()
233
- .split("\n")
234
- .filter(Boolean),
235
- );
236
- } catch {
237
- return new Set(); // no git history readable → sweep proves nothing
238
- }
239
- };
240
- const reachable = revList("");
241
- const mainline = revList("--first-parent");
242
- const provenMerged = (sha) => reachable.has(sha) && !mainline.has(sha);
243
- let shallow = false;
244
- try {
245
- shallow =
246
- execSync("git rev-parse --is-shallow-repository", {
247
- cwd: ROOT,
248
- encoding: "utf8",
249
- stdio: ["ignore", "pipe", "ignore"],
250
- }).trim() === "true";
251
- } catch {
252
- /* not a git repo would have failed checkout long before this */
253
- }
254
-
255
- // 1 · collect: the merged branch's own rows PLUS the ancestry sweep over
256
- // every other branch's active rows (provably-merged backlog folds in).
257
- // Adjudication-flagged rows are LEFT for a human session (CI can flag
258
- // divergence, never resolve it). The sweep is AUXILIARY: its failure warns
259
- // and narrows the run to the merged branch — it never kills the primary
260
- // distillation the merge event exists for.
261
- console.log(`• collecting working set for ${branch} …`);
262
- let branchAll;
263
- try {
264
- branchAll =
265
- (await callTool(ROOT, "get_working_set", { branch })).files ?? [];
266
- } catch (e) {
267
- die(e instanceof Error ? e.message : String(e));
268
- }
269
- const branchRows = branchAll.filter((r) => r.status === "active");
270
- const flagged = branchAll.filter((r) => r.status === "needs-adjudication");
271
- const sweptRows = [];
272
- let unproven = 0,
273
- sweptBranches = 0;
274
- try {
275
- const { branches } = await callTool(ROOT, "list_working_sets", {});
276
- const others = (branches ?? [])
277
- .map((b) => (typeof b === "string" ? b : b.branch))
278
- .filter((other) => other && other !== branch);
279
- const perBranch = await Promise.all(
280
- others.map(async (other) => {
281
- const rows =
282
- (
283
- await callTool(ROOT, "get_working_set", {
284
- branch: other,
285
- status: "active",
286
- })
287
- ).files ?? [];
288
- return sweepByAncestry(rows, provenMerged);
289
- }),
290
- );
291
- for (const { swept, unproven: u } of perBranch) {
292
- if (swept.length) sweptBranches++;
293
- sweptRows.push(...swept);
294
- unproven += u;
295
- }
296
- } catch (e) {
297
- console.log(
298
- ` ! ancestry sweep skipped (${e instanceof Error ? e.message : e}) — distilling ${branch}'s own rows only`,
299
- );
300
- }
301
- if (flagged.length)
302
- console.log(
303
- ` ! ${flagged.length} file(s) already need adjudication — left for the next dev session's digest`,
304
- );
305
- if (sweptRows.length)
306
- console.log(
307
- ` + ancestry sweep: ${sweptRows.length} provably-merged row(s) from ${sweptBranches} other branch(es) folded into this run`,
308
- );
309
- if (unproven > 0)
310
- console.log(
311
- ` · ${unproven} row(s) on other branches not provably merged — left intact` +
312
- (shallow
313
- ? `\n (shallow checkout limits the proof — re-run \`npx @rafinery/cli ci-setup --overwrite\` for full history)`
314
- : ""),
315
- );
316
-
317
- const allGroups = latestWinsByPath([...branchRows, ...sweptRows], branch);
318
- // P2 typed candidates: intent records are PROVENANCE, never truth claims —
319
- // they resolve as consumed without staging or judgment; notes and
320
- // improvements are the judged kinds (per-kind question in the prompt).
321
- const kindOf = (p) =>
322
- p.startsWith("improve/improvements/")
323
- ? "improvement"
324
- : p.startsWith("intent/")
325
- ? "intent"
326
- : "note";
327
- const intentGroups = allGroups.filter((g) => kindOf(g.winner.path) === "intent");
328
- const groups = allGroups.filter((g) => kindOf(g.winner.path) !== "intent");
329
- const active = groups.map((g) => g.winner);
330
- if (intentGroups.length)
331
- console.log(
332
- ` · ${intentGroups.length} intent record(s) — provenance, consumed without judgment`,
333
- );
334
- let intentConsumed = 0;
335
- for (const g of intentGroups) {
336
- for (const row of [g.winner, ...g.superseded]) {
337
- try {
338
- await callTool(ROOT, "resolve_working_file", {
339
- branch: row.branch ?? branch,
340
- path: row.path,
341
- resolution: "distilled",
342
- actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
343
- note: "intent record — provenance consumed by the merge run (never a truth claim)",
344
- ...(typeof row.version === "number" ? { baseVersion: row.version } : {}),
345
- });
346
- intentConsumed++;
347
- } catch {
348
- /* stays active for a later run — the brain branch keeps the trail */
349
- }
350
- }
351
- }
352
-
353
- if (active.length === 0) {
354
- // Diagnostic empty (owner catch 2026-07-17): silence must explain itself —
355
- // "never captured" and "already reconciled" are different states.
356
- if (branchAll.length === 0) {
357
- console.log(
358
- `✓ nothing to distill on ${branch} — 0 rows were ever captured on this branch.\n` +
359
- ` Capture never fired during its dev sessions (see \`rafa status\` → SENSORS; ` +
360
- `candidates land via \`rafa checkpoint\`).`,
361
- );
362
- } else {
363
- const counts = {};
364
- for (const r of branchAll) counts[r.status] = (counts[r.status] ?? 0) + 1;
365
- const parts = Object.entries(counts)
366
- .map(([s, n]) => `${n} ${s}`)
367
- .join(" · ");
368
- console.log(
369
- `✓ nothing to distill on ${branch} — ${branchAll.length} row(s) exist, none active: ${parts}\n` +
370
- ` (distilled/refuted = already reconciled by an earlier run · folded = moved to the parent branch)`,
371
- );
372
- }
373
- return;
374
- }
375
- console.log(` ${active.length} file(s) to distill`);
376
-
377
- // 2 · mirror the org brain locally — the authoring target + the base the
378
- // survivors fold into. (CI checkout is merged main = the validation target.)
379
- const tPull = Date.now();
380
- await pull(["--full"]);
381
- console.log(` ⏱ brain mirror: ${secs(tPull)}s`);
382
-
383
- // 2b · SCHEMA LADDER (0.11.0 — the owner's 4-case doctrine): resolve the
384
- // version lattice BEFORE any claim-level judging, so knowledge from two
385
- // lineages always meets on ONE schema — the newest this runner understands.
386
- // Lifted target notes ride THIS run's gates + push (one migration+reconcile
387
- // commit); an unknown future schema aborts loudly, never downgrades.
388
- {
389
- const target = brainSchemaVersion(join(ROOT, ".rafa"));
390
- const source = sourceSchemaVersion(active.map((f) => f.content));
391
- const plan = resolveSchemaPlan({ target, source, latest: SCHEMA_VERSION });
392
- console.log(
393
- `• schema ladder: target v${target} · source v${source} · runner v${SCHEMA_VERSION} → ${plan.mode}`,
394
- );
395
- if (plan.mode === "abort-newer-than-runner") die(plan.reason);
396
- if (plan.mode !== "diff") console.log(` ${plan.reason}`);
397
- // Per-OKF-type lifts (class derives from the PATH — typed-candidates
398
- // doctrine); non-laddered paths (intent records …) pass through untouched.
399
- if (plan.upgradeSource)
400
- for (const f of active) {
401
- const cls = classOf(f.path);
402
- if (cls) f.content = applyLadder(f.content, plan.toVersion, cls);
403
- }
404
- if (plan.rewriteTarget)
405
- for (const noteFile of walkBrainNotes(join(ROOT, ".rafa"))) {
406
- const cls = classOf(noteFile);
407
- if (cls)
408
- writeFileSync(noteFile, applyLadder(readFileSync(noteFile, "utf8"), plan.toVersion, cls));
409
- }
410
- }
411
-
412
- // 3 · stage the incoming working set for the agent (gitignored inside .rafa).
413
- const stagingRoot = join(ROOT, ".rafa", "distill-incoming");
414
- rmSync(stagingRoot, { recursive: true, force: true });
415
- for (const f of active) {
416
- const abs = join(stagingRoot, safeRel(f.path));
417
- mkdirSync(dirname(abs), { recursive: true });
418
- writeFileSync(
419
- abs,
420
- `<!-- incoming working-set file · branch: ${f.branch ?? branch} · author: ${f.lastAuthor} · v${f.version}` +
421
- `${f.baseBrainSha ? ` · base: ${f.baseBrainSha}` : ""}` +
422
- `${f.capturedAtSha ? ` · captured-at: ${f.capturedAtSha}` : ""} -->\n` +
423
- f.content,
424
- );
425
- }
426
- const verdictsPath = join(ROOT, ".rafa", "distill-verdicts.json");
427
- rmSync(verdictsPath, { force: true });
428
-
429
- // 4 · the judgment pass (org's own key; gates stay deterministic below).
430
- const sdk = await loadAgentSdk(ROOT);
431
- const fileList = active
432
- .map(
433
- (f) =>
434
- `- ${f.path} (kind: ${kindOf(f.path)} · branch: ${f.branch ?? branch} · author: ${f.lastAuthor})`,
435
- )
436
- .join("\n");
437
- const prompt = `You are running rafa's CI DISTILLATION — merge-time reconciliation of branch "${branch}"'s
438
- working set into the org brain. Some staged files were swept in from OTHER already-merged
439
- branches (their provenance comments name the source branch) — judge them identically.
440
- The checked-out repo IS merged main (the ground truth).
441
- The current org brain is mirrored at .rafa/brain/. The incoming candidate-grade files are
442
- staged under .rafa/distill-incoming/ (brain-relative paths mirrored; a provenance comment
443
- tops each file).
444
-
445
- Read .claude/skills/rafa-distill/SKILL.md (the SOP) and .claude/rafa/contract.md §2 (the
446
- note schema) + §3 (the improvement schema) first.
447
-
448
- Each staged file names its KIND in the list below — the judge question differs:
449
-
450
- For each kind:note file:
451
- 1. VALIDATE like prism — skeptical, code-grounded: does every claim hold against the code
452
- as it now stands on main? Confirm each citation resolves (the cited line contains the
453
- token; run \`git grep\` yourself). A claim you cannot confirm with a file:line is
454
- REFUTED (note the cited reason). Contested or genuinely uncertain → verdict
455
- "needs-adjudication" (NEVER guess; a wrong note is worse than none).
456
- 2. For survivors, AUTHOR like atlas — write/update the org-brain file under .rafa/brain/
457
- per the contract: valid frontmatter, real cites into main, \`anchor:\` on contracts;
458
- fold into an existing note when one already covers the topic (supersede, never
459
- duplicate). Never copy the provenance comment into the brain file.
460
-
461
- For each kind:improvement file — the LIFECYCLE question, not the claim question:
462
- 1. If it reports a FIX (status: fixed): is the cited defect actually GONE on merged main?
463
- Gone → verdict "distilled", note "confirmed-fixed — <cite>"; author the status change
464
- into .rafa/improve/improvements/<id>.md. Still present → verdict "refuted", note
465
- "still-open — <cite>" (the ledger keeps saying open, truthfully).
466
- 2. If it proposes a NEW improvement: does the defect actually EXIST on merged main, at the
467
- cited lines? Exists → verdict "distilled", note "opened"; author the file under
468
- .rafa/improve/improvements/. Not reproducible → "refuted" with the cited reason.
469
- 3. After ANY improvement change, regenerate .rafa/improve/ledger.md counts to match the
470
- rows — the ledger is DERIVED and compile cross-checks it.
471
-
472
- When all survivors are authored, run \`npx @rafinery/cli verify-citations\` and
473
- \`npx @rafinery/cli compile\` and fix your authored files until BOTH exit 0.
474
-
475
- Finally write .rafa/distill-verdicts.json:
476
- {"verdicts":[{"path":"<staged brain-relative path>","verdict":"distilled|refuted|needs-adjudication","note":"<cited reason — required for refuted and needs-adjudication>"}]}
477
- with EXACTLY one entry per staged file listed below. Do not modify anything outside
478
- .rafa/ except nothing — the code repo is read-only ground truth.
479
-
480
- Staged files:
481
- ${fileList}`;
482
-
483
- console.log(
484
- "• running the distillation agent (org's own ANTHROPIC_API_KEY) …",
485
- );
486
- const tAgent = Date.now();
487
- const run = sdk.query({
488
- prompt,
489
- options: {
490
- cwd: ROOT,
491
- permissionMode: "bypassPermissions",
492
- allowedTools: ["Read", "Grep", "Glob", "Bash", "Write", "Edit"],
493
- settingSources: [],
494
- maxTurns: 150,
495
- },
496
- });
497
- // Stream the agent's narration lines — a silent multi-minute CI step reads
498
- // as hung; a narrated one reads as working (and shows WHERE it hangs).
499
- let lastLine = "";
500
- for await (const message of run) {
501
- if (
502
- message.type === "assistant" &&
503
- typeof message.message?.model === "string"
504
- )
505
- agentModel = message.message.model;
506
- if (message.type === "assistant") {
507
- const blocks = message.message?.content ?? [];
508
- for (const b of blocks) {
509
- if (b.type !== "text" || !b.text?.trim()) continue;
510
- const line = b.text.trim().split("\n")[0].slice(0, 110);
511
- if (line && line !== lastLine) {
512
- console.log(` · ${line}`);
513
- lastLine = line;
514
- }
515
- }
516
- }
517
- if (message.type === "result") {
518
- if (message.subtype !== "success")
519
- await failRun(
520
- `distillation agent did not complete (${message.subtype}) — nothing resolved, nothing pushed`,
521
- );
522
- console.log(
523
- ` ⏱ agent: ${secs(tAgent)}s (${message.num_turns ?? "?"} turns · ` +
524
- `$${(message.total_cost_usd ?? 0).toFixed(4)} on the org's key)`,
525
- );
526
- // Self-report the metered run (backlog #6) — best-effort, never fatal.
527
- try {
528
- await callTool(ROOT, "log_usage", {
529
- provider: "anthropic",
530
- model: agentModel,
531
- inputTokens: message.usage?.input_tokens ?? 0,
532
- outputTokens: message.usage?.output_tokens ?? 0,
533
- ...(typeof message.total_cost_usd === "number"
534
- ? { costUsd: message.total_cost_usd }
535
- : {}),
536
- purpose: "distill",
537
- runner: "ci",
538
- });
539
- } catch (e) {
540
- console.log(
541
- ` ! usage not reported: ${e instanceof Error ? e.message : e}`,
542
- );
543
- }
544
- }
545
- }
546
-
547
- // 5 · verdicts must be complete — a missing entry is a hard stop, never a guess.
548
- if (!existsSync(verdictsPath))
549
- await failRun(
550
- "agent produced no .rafa/distill-verdicts.json — nothing resolved, nothing pushed; working set left intact",
551
- );
552
- let verdicts;
553
- try {
554
- verdicts = JSON.parse(readFileSync(verdictsPath, "utf8")).verdicts;
555
- } catch {
556
- await failRun(
557
- "distill-verdicts.json is not valid JSON — nothing resolved, nothing pushed",
558
- );
559
- }
560
- if (!Array.isArray(verdicts))
561
- await failRun("distill-verdicts.json has no verdicts[] — aborting");
562
- const byPath = new Map(verdicts.map((v) => [v.path, v]));
563
- for (const f of active) {
564
- const v = byPath.get(f.path);
565
- if (!v || !VERDICTS.includes(v.verdict))
566
- await failRun(
567
- `no valid verdict for ${f.path} — nothing resolved, nothing pushed; working set left intact`,
568
- );
569
- if (
570
- v.verdict !== "distilled" &&
571
- (typeof v.note !== "string" || v.note.trim() === "")
572
- )
573
- await failRun(`${f.path}: verdict "${v.verdict}" requires a cited note — aborting`);
574
- }
575
-
576
- // 6 · trust-but-verify: OUR gates re-run regardless of what the agent claims.
577
- console.log("• re-running the gates (trust-but-verify) …");
578
- const tGates = Date.now();
579
- if (runVerifyCitations([]) !== 0)
580
- await failRun(
581
- "citation checker failed on the authored brain — nothing resolved, nothing pushed",
582
- );
583
- if (runCompile([]) !== 0)
584
- await failRun(
585
- "compile gate failed on the authored brain — nothing resolved, nothing pushed",
586
- );
587
- console.log(` ⏱ gates: ${secs(tGates)}s`);
588
-
589
- // 7 · ship: brain repo push (webhook → ingest), staging cleaned first.
590
- rmSync(stagingRoot, { recursive: true, force: true });
591
- const distilledCount = active.filter(
592
- (f) => byPath.get(f.path).verdict === "distilled",
593
- ).length;
594
- if (distilledCount > 0) {
595
- // Descriptive reconciliation commit (owner 2026-07-26): the subject says
596
- // WHAT this run did — branch, per-verdict counts, the banked ids — so any
597
- // agent (ours or a foreign OKF reader) walking the brain log understands
598
- // the commit without opening it. brain-for trailer untouched (push owns it).
599
- {
600
- const counts = { distilled: 0, refuted: 0, "needs-adjudication": 0 };
601
- for (const v of verdicts) counts[v.verdict] = (counts[v.verdict] ?? 0) + 1;
602
- const banked = verdicts
603
- .filter((v) => v.verdict === "distilled")
604
- .map((v) => String(v.path).split("/").pop().replace(/\.md$/, ""));
605
- const idList = banked.slice(0, 4).join(", ") + (banked.length > 4 ? " …" : "");
606
- await push([
607
- "--verb=distill",
608
- `--message=reconcile(${branch}): ${counts.distilled} banked · ${counts.refuted} refuted · ${counts["needs-adjudication"]} adjudication${banked.length ? ` — ${idList}` : ""}`,
609
- ]);
610
- }
611
- } else {
612
- console.log("• no survivors — nothing to push to the brain repo");
613
- }
614
-
615
- // 8 · resolve the rows (CAS — a racing session's resolve loses loudly).
616
- // ONLY judged winners resolve, each under ITS OWN branch key and fenced on
617
- // the version this run READ — a checkpoint that landed mid-judgment wins,
618
- // never the stale verdict. Superseded same-path copies stay active: a
619
- // verdict must never apply to content the judge did not see; each copy is
620
- // judged on its own merits in a later run.
621
- let refuted = 0,
622
- adjudication = 0;
623
- for (const g of groups) {
624
- const f = g.winner;
625
- const v = byPath.get(f.path);
626
- if (v.verdict === "refuted") refuted++;
627
- if (v.verdict === "needs-adjudication") adjudication++;
628
- try {
629
- await callTool(ROOT, "resolve_working_file", {
630
- branch: f.branch ?? branch,
631
- path: f.path,
632
- resolution: v.verdict,
633
- actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
634
- ...(v.note ? { note: v.note } : {}),
635
- ...(typeof f.version === "number" ? { baseVersion: f.version } : {}),
636
- });
637
- console.log(
638
- ` ${v.verdict === "distilled" ? "✓" : "!"} ${f.branch ?? branch} · ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`,
639
- );
640
- } catch (e) {
641
- console.log(
642
- ` ✗ ${f.branch ?? branch} · ${f.path} — resolve failed: ${e instanceof Error ? e.message : e}`,
643
- );
644
- }
645
- for (const s of g.superseded)
646
- console.log(
647
- ` · ${s.branch ?? branch} · ${s.path} — older same-path copy left active for its own judgment`,
648
- );
649
- }
650
- rmSync(verdictsPath, { force: true });
651
-
652
- await reportRun(
653
- "succeeded",
654
- `${distilledCount} banked · ${refuted} refuted · ${adjudication} needs-adjudication` +
655
- (sweptRows.length ? ` · ${sweptRows.length} swept from earlier merges` : "") +
656
- (intentConsumed ? ` · ${intentConsumed} intent record(s) consumed` : ""),
657
- );
658
- console.log(
659
- `✓ distillation: ${distilledCount} into the org brain · ${refuted} refuted (reported, cited) · ` +
660
- `${adjudication} flagged needs-adjudication (next session's digest)` +
661
- (sweptRows.length ? ` · ${sweptRows.length} swept from earlier merges` : "") +
662
- (intentConsumed ? ` · ${intentConsumed} intent consumed` : "") +
663
- ` · total ${secs(t0)}s`,
664
- );
665
- }