@rafinery/cli 0.8.0 → 0.8.2

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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/rafa-distiller.mjs +13 -0
  3. package/bin/rafa.mjs +4 -0
  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 +19 -1
  10. package/blueprint/.claude/rafa/contract.md +31 -3
  11. package/blueprint/.claude/rafa/hooks/brain-commit.mjs +80 -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-validate/SKILL.md +6 -0
  21. package/lib/checkpoint.mjs +42 -0
  22. package/lib/ci-setup.mjs +48 -3
  23. package/lib/dirty.mjs +37 -0
  24. package/lib/distill.mjs +313 -37
  25. package/lib/distiller/doctrine.mjs +379 -0
  26. package/lib/distiller/state-plane.mjs +159 -0
  27. package/lib/distiller.mjs +410 -0
  28. package/lib/doctor.mjs +110 -0
  29. package/lib/gate/compile.mjs +31 -0
  30. package/lib/gate/okf-emit.mjs +12 -0
  31. package/lib/gate/verify-citations.mjs +9 -2
  32. package/lib/githook.mjs +75 -33
  33. package/lib/init.mjs +14 -2
  34. package/lib/mcp-client.mjs +22 -5
  35. package/lib/okf.mjs +11 -1
  36. package/lib/pull.mjs +2 -2
  37. package/lib/push.mjs +67 -2
  38. package/lib/releases.mjs +26 -0
  39. package/lib/sensor-health.mjs +91 -0
  40. package/lib/status.mjs +47 -1
  41. package/lib/update.mjs +2 -2
  42. package/lib/working-set.mjs +19 -1
  43. package/package.json +11 -9
  44. package/LICENSE +0 -21
package/lib/distill.mjs CHANGED
@@ -14,9 +14,21 @@
14
14
  // the checkers itself (trust-but-verify), pushes, and resolves. The agent
15
15
  // judges; it never ships.
16
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
+ //
17
28
  // Fallback chain when this can't run (no key, no CI): the dev-session flow —
18
29
  // /rafa distill <branch> in Claude Code (the bootstrap digest offers it).
19
30
 
31
+ import { execSync } from "node:child_process";
20
32
  import {
21
33
  existsSync,
22
34
  mkdirSync,
@@ -74,6 +86,62 @@ async function loadAgentSdk(cwd) {
74
86
 
75
87
  const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
76
88
 
89
+ // ── collection helpers (pure; exported for tests) ───────────────────────────
90
+
91
+ // Same-path collision across branches. The MERGED branch's own row always
92
+ // wins — this run exists to judge the merge that triggered it; a swept
93
+ // backlog row must never displace it. Between swept rows, newest updatedAt
94
+ // wins with a deterministic branch-name tiebreak (platform iteration order
95
+ // must never pick the winner). Superseded copies are NOT resolved here —
96
+ // each copy is judged on its own merits in a later run (a verdict never
97
+ // applies to content the judge did not see).
98
+ export function latestWinsByPath(rows, preferredBranch) {
99
+ const beats = (a, b) => {
100
+ const aPref = a.branch === preferredBranch,
101
+ bPref = b.branch === preferredBranch;
102
+ if (aPref !== bPref) return aPref;
103
+ const aAt = a.updatedAt ?? 0,
104
+ bAt = b.updatedAt ?? 0;
105
+ if (aAt !== bAt) return aAt > bAt;
106
+ return String(a.branch) > String(b.branch);
107
+ };
108
+ const byPath = new Map();
109
+ for (const r of rows) {
110
+ const prev = byPath.get(r.path);
111
+ if (!prev) byPath.set(r.path, { winner: r, superseded: [] });
112
+ else if (beats(r, prev.winner)) {
113
+ prev.superseded.push(prev.winner);
114
+ prev.winner = r;
115
+ } else prev.superseded.push(r);
116
+ }
117
+ return [...byPath.values()];
118
+ }
119
+
120
+ // Rows whose capture commit git can PROVE belongs to merged branch work:
121
+ // reachable from HEAD AND off the first-parent mainline. Reachability alone
122
+ // is not proof — a session that checkpoints before its branch's first commit
123
+ // grounds at a MAINLINE sha (the branch point), which is reachable forever;
124
+ // sweeping it would steal in-flight rows from open branches. Off-mainline
125
+ // reachable = the commit arrived via a merge. Rebase/squash-merged work is
126
+ // unprovable here and stays active (safety over recall — the platform
127
+ // reconciler and the session digest remain its path). Only full shas match
128
+ // (checkpoint stamps `git rev-parse HEAD`); anything else counts unproven,
129
+ // never guessed.
130
+ export function sweepByAncestry(rows, provenMerged) {
131
+ const swept = [];
132
+ let unproven = 0;
133
+ for (const r of rows) {
134
+ if (
135
+ typeof r.capturedAtSha === "string" &&
136
+ /^[0-9a-f]{40,64}$/i.test(r.capturedAtSha) &&
137
+ provenMerged(r.capturedAtSha.toLowerCase())
138
+ )
139
+ swept.push(r);
140
+ else unproven++;
141
+ }
142
+ return { swept, unproven };
143
+ }
144
+
77
145
  export default async function distill(args = []) {
78
146
  // U7 recursion guard, belt-and-braces: the Agent SDK worker must never fire
79
147
  // the M5 session sensors (a worker's own tool calls re-triggering hooks would
@@ -99,31 +167,200 @@ export default async function distill(args = []) {
99
167
  // log answers "where is it, and where did the time go" at a glance.
100
168
  const t0 = Date.now();
101
169
  const secs = (t) => ((Date.now() - t) / 1000).toFixed(1);
170
+ let agentModel = "unreported";
102
171
 
103
- // 1 · collect the branch working set. Adjudication-flagged rows are LEFT for
104
- // a human session (CI can flag divergence, never resolve it).
172
+ // Run record (capture-engine P5): the Reconciliations tab renders CI runs
173
+ // too — a terminal report_distill_run per NON-EMPTY run, keyed by the merge
174
+ // commit (RAFA_MERGE_SHA from the workflow; HEAD of the merged checkout as
175
+ // the fallback). Telemetry — best-effort, never fatal, and empty merges
176
+ // write no row (honest no-op stays row-less).
177
+ const mergeSha = (() => {
178
+ const env = process.env.RAFA_MERGE_SHA ?? "";
179
+ if (/^[0-9a-f]{7,64}$/i.test(env)) return env;
180
+ try {
181
+ return execSync("git rev-parse HEAD", {
182
+ cwd: ROOT,
183
+ encoding: "utf8",
184
+ stdio: ["ignore", "pipe", "ignore"],
185
+ }).trim();
186
+ } catch {
187
+ return "";
188
+ }
189
+ })();
190
+ const reportRun = async (status, summary) => {
191
+ if (!mergeSha) return;
192
+ try {
193
+ await callTool(ROOT, "report_distill_run", {
194
+ branch,
195
+ toBranch: process.env.RAFA_TARGET_BRANCH || "main",
196
+ mergeCommitSha: mergeSha,
197
+ status,
198
+ note: summary,
199
+ actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
200
+ });
201
+ } catch {
202
+ /* the run itself is the deliverable; its record is telemetry */
203
+ }
204
+ };
205
+ const failRun = async (m) => {
206
+ await reportRun("failed", m);
207
+ die(m);
208
+ };
209
+
210
+ // Merged-work oracle: TWO subprocesses total — every commit reachable from
211
+ // HEAD, and the first-parent mainline. provenMerged = reachable ∧
212
+ // off-mainline (see sweepByAncestry). A shallow checkout truncates both
213
+ // sets, which only ever UNDER-sweeps (rows stay active) — safe.
214
+ const revList = (args) => {
215
+ try {
216
+ return new Set(
217
+ execSync(`git rev-list ${args} HEAD`, {
218
+ cwd: ROOT,
219
+ encoding: "utf8",
220
+ stdio: ["ignore", "pipe", "ignore"],
221
+ maxBuffer: 256 * 1024 * 1024,
222
+ })
223
+ .trim()
224
+ .split("\n")
225
+ .filter(Boolean),
226
+ );
227
+ } catch {
228
+ return new Set(); // no git history readable → sweep proves nothing
229
+ }
230
+ };
231
+ const reachable = revList("");
232
+ const mainline = revList("--first-parent");
233
+ const provenMerged = (sha) => reachable.has(sha) && !mainline.has(sha);
234
+ let shallow = false;
235
+ try {
236
+ shallow =
237
+ execSync("git rev-parse --is-shallow-repository", {
238
+ cwd: ROOT,
239
+ encoding: "utf8",
240
+ stdio: ["ignore", "pipe", "ignore"],
241
+ }).trim() === "true";
242
+ } catch {
243
+ /* not a git repo would have failed checkout long before this */
244
+ }
245
+
246
+ // 1 · collect: the merged branch's own rows PLUS the ancestry sweep over
247
+ // every other branch's active rows (provably-merged backlog folds in).
248
+ // Adjudication-flagged rows are LEFT for a human session (CI can flag
249
+ // divergence, never resolve it). The sweep is AUXILIARY: its failure warns
250
+ // and narrows the run to the merged branch — it never kills the primary
251
+ // distillation the merge event exists for.
105
252
  console.log(`• collecting working set for ${branch} …`);
106
- let active, flagged;
253
+ let branchAll;
107
254
  try {
108
- active =
109
- (await callTool(ROOT, "get_working_set", { branch, status: "active" }))
110
- .files ?? [];
111
- flagged =
112
- (
113
- await callTool(ROOT, "get_working_set", {
114
- branch,
115
- status: "needs-adjudication",
116
- })
117
- ).files ?? [];
255
+ branchAll =
256
+ (await callTool(ROOT, "get_working_set", { branch })).files ?? [];
118
257
  } catch (e) {
119
258
  die(e instanceof Error ? e.message : String(e));
120
259
  }
260
+ const branchRows = branchAll.filter((r) => r.status === "active");
261
+ const flagged = branchAll.filter((r) => r.status === "needs-adjudication");
262
+ const sweptRows = [];
263
+ let unproven = 0,
264
+ sweptBranches = 0;
265
+ try {
266
+ const { branches } = await callTool(ROOT, "list_working_sets", {});
267
+ const others = (branches ?? [])
268
+ .map((b) => (typeof b === "string" ? b : b.branch))
269
+ .filter((other) => other && other !== branch);
270
+ const perBranch = await Promise.all(
271
+ others.map(async (other) => {
272
+ const rows =
273
+ (
274
+ await callTool(ROOT, "get_working_set", {
275
+ branch: other,
276
+ status: "active",
277
+ })
278
+ ).files ?? [];
279
+ return sweepByAncestry(rows, provenMerged);
280
+ }),
281
+ );
282
+ for (const { swept, unproven: u } of perBranch) {
283
+ if (swept.length) sweptBranches++;
284
+ sweptRows.push(...swept);
285
+ unproven += u;
286
+ }
287
+ } catch (e) {
288
+ console.log(
289
+ ` ! ancestry sweep skipped (${e instanceof Error ? e.message : e}) — distilling ${branch}'s own rows only`,
290
+ );
291
+ }
121
292
  if (flagged.length)
122
293
  console.log(
123
294
  ` ! ${flagged.length} file(s) already need adjudication — left for the next dev session's digest`,
124
295
  );
296
+ if (sweptRows.length)
297
+ console.log(
298
+ ` + ancestry sweep: ${sweptRows.length} provably-merged row(s) from ${sweptBranches} other branch(es) folded into this run`,
299
+ );
300
+ if (unproven > 0)
301
+ console.log(
302
+ ` · ${unproven} row(s) on other branches not provably merged — left intact` +
303
+ (shallow
304
+ ? `\n (shallow checkout limits the proof — re-run \`npx @rafinery/cli ci-setup --overwrite\` for full history)`
305
+ : ""),
306
+ );
307
+
308
+ const allGroups = latestWinsByPath([...branchRows, ...sweptRows], branch);
309
+ // P2 typed candidates: intent records are PROVENANCE, never truth claims —
310
+ // they resolve as consumed without staging or judgment; notes and
311
+ // improvements are the judged kinds (per-kind question in the prompt).
312
+ const kindOf = (p) =>
313
+ p.startsWith("improve/improvements/")
314
+ ? "improvement"
315
+ : p.startsWith("intent/")
316
+ ? "intent"
317
+ : "note";
318
+ const intentGroups = allGroups.filter((g) => kindOf(g.winner.path) === "intent");
319
+ const groups = allGroups.filter((g) => kindOf(g.winner.path) !== "intent");
320
+ const active = groups.map((g) => g.winner);
321
+ if (intentGroups.length)
322
+ console.log(
323
+ ` · ${intentGroups.length} intent record(s) — provenance, consumed without judgment`,
324
+ );
325
+ let intentConsumed = 0;
326
+ for (const g of intentGroups) {
327
+ for (const row of [g.winner, ...g.superseded]) {
328
+ try {
329
+ await callTool(ROOT, "resolve_working_file", {
330
+ branch: row.branch ?? branch,
331
+ path: row.path,
332
+ resolution: "distilled",
333
+ actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
334
+ note: "intent record — provenance consumed by the merge run (never a truth claim)",
335
+ ...(typeof row.version === "number" ? { baseVersion: row.version } : {}),
336
+ });
337
+ intentConsumed++;
338
+ } catch {
339
+ /* stays active for a later run — the brain branch keeps the trail */
340
+ }
341
+ }
342
+ }
343
+
125
344
  if (active.length === 0) {
126
- console.log(`✓ nothing to distill on ${branch}`);
345
+ // Diagnostic empty (owner catch 2026-07-17): silence must explain itself —
346
+ // "never captured" and "already reconciled" are different states.
347
+ if (branchAll.length === 0) {
348
+ console.log(
349
+ `✓ nothing to distill on ${branch} — 0 rows were ever captured on this branch.\n` +
350
+ ` Capture never fired during its dev sessions (see \`rafa status\` → SENSORS; ` +
351
+ `candidates land via \`rafa checkpoint\`).`,
352
+ );
353
+ } else {
354
+ const counts = {};
355
+ for (const r of branchAll) counts[r.status] = (counts[r.status] ?? 0) + 1;
356
+ const parts = Object.entries(counts)
357
+ .map(([s, n]) => `${n} ${s}`)
358
+ .join(" · ");
359
+ console.log(
360
+ `✓ nothing to distill on ${branch} — ${branchAll.length} row(s) exist, none active: ${parts}\n` +
361
+ ` (distilled/refuted = already reconciled by an earlier run · folded = moved to the parent branch)`,
362
+ );
363
+ }
127
364
  return;
128
365
  }
129
366
  console.log(` ${active.length} file(s) to distill`);
@@ -142,8 +379,9 @@ export default async function distill(args = []) {
142
379
  mkdirSync(dirname(abs), { recursive: true });
143
380
  writeFileSync(
144
381
  abs,
145
- `<!-- incoming working-set file · branch: ${branch} · author: ${f.lastAuthor} · v${f.version}` +
146
- `${f.baseBrainSha ? ` · base: ${f.baseBrainSha}` : ""} -->\n` +
382
+ `<!-- incoming working-set file · branch: ${f.branch ?? branch} · author: ${f.lastAuthor} · v${f.version}` +
383
+ `${f.baseBrainSha ? ` · base: ${f.baseBrainSha}` : ""}` +
384
+ `${f.capturedAtSha ? ` · captured-at: ${f.capturedAtSha}` : ""} -->\n` +
147
385
  f.content,
148
386
  );
149
387
  }
@@ -153,18 +391,25 @@ export default async function distill(args = []) {
153
391
  // 4 · the judgment pass (org's own key; gates stay deterministic below).
154
392
  const sdk = await loadAgentSdk(ROOT);
155
393
  const fileList = active
156
- .map((f) => `- ${f.path} (author: ${f.lastAuthor})`)
394
+ .map(
395
+ (f) =>
396
+ `- ${f.path} (kind: ${kindOf(f.path)} · branch: ${f.branch ?? branch} · author: ${f.lastAuthor})`,
397
+ )
157
398
  .join("\n");
158
399
  const prompt = `You are running rafa's CI DISTILLATION — merge-time reconciliation of branch "${branch}"'s
159
- working set into the org brain. The checked-out repo IS merged main (the ground truth).
400
+ working set into the org brain. Some staged files were swept in from OTHER already-merged
401
+ branches (their provenance comments name the source branch) — judge them identically.
402
+ The checked-out repo IS merged main (the ground truth).
160
403
  The current org brain is mirrored at .rafa/brain/. The incoming candidate-grade files are
161
404
  staged under .rafa/distill-incoming/ (brain-relative paths mirrored; a provenance comment
162
405
  tops each file).
163
406
 
164
407
  Read .claude/skills/rafa-distill/SKILL.md (the SOP) and .claude/rafa/contract.md §2 (the
165
- note schema) first.
408
+ note schema) + §3 (the improvement schema) first.
409
+
410
+ Each staged file names its KIND in the list below — the judge question differs:
166
411
 
167
- For EACH staged file:
412
+ For each kind:note file:
168
413
  1. VALIDATE like prism — skeptical, code-grounded: does every claim hold against the code
169
414
  as it now stands on main? Confirm each citation resolves (the cited line contains the
170
415
  token; run \`git grep\` yourself). A claim you cannot confirm with a file:line is
@@ -174,8 +419,20 @@ For EACH staged file:
174
419
  per the contract: valid frontmatter, real cites into main, \`anchor:\` on contracts;
175
420
  fold into an existing note when one already covers the topic (supersede, never
176
421
  duplicate). Never copy the provenance comment into the brain file.
177
- 3. When all survivors are authored, run \`npx @rafinery/cli verify-citations\` and
178
- \`npx @rafinery/cli compile\` and fix your authored files until BOTH exit 0.
422
+
423
+ For each kind:improvement file the LIFECYCLE question, not the claim question:
424
+ 1. If it reports a FIX (status: fixed): is the cited defect actually GONE on merged main?
425
+ Gone → verdict "distilled", note "confirmed-fixed — <cite>"; author the status change
426
+ into .rafa/improve/improvements/<id>.md. Still present → verdict "refuted", note
427
+ "still-open — <cite>" (the ledger keeps saying open, truthfully).
428
+ 2. If it proposes a NEW improvement: does the defect actually EXIST on merged main, at the
429
+ cited lines? Exists → verdict "distilled", note "opened"; author the file under
430
+ .rafa/improve/improvements/. Not reproducible → "refuted" with the cited reason.
431
+ 3. After ANY improvement change, regenerate .rafa/improve/ledger.md counts to match the
432
+ rows — the ledger is DERIVED and compile cross-checks it.
433
+
434
+ When all survivors are authored, run \`npx @rafinery/cli verify-citations\` and
435
+ \`npx @rafinery/cli compile\` and fix your authored files until BOTH exit 0.
179
436
 
180
437
  Finally write .rafa/distill-verdicts.json:
181
438
  {"verdicts":[{"path":"<staged brain-relative path>","verdict":"distilled|refuted|needs-adjudication","note":"<cited reason — required for refuted and needs-adjudication>"}]}
@@ -202,7 +459,6 @@ ${fileList}`;
202
459
  // Stream the agent's narration lines — a silent multi-minute CI step reads
203
460
  // as hung; a narrated one reads as working (and shows WHERE it hangs).
204
461
  let lastLine = "";
205
- let agentModel = "unreported";
206
462
  for await (const message of run) {
207
463
  if (
208
464
  message.type === "assistant" &&
@@ -222,7 +478,7 @@ ${fileList}`;
222
478
  }
223
479
  if (message.type === "result") {
224
480
  if (message.subtype !== "success")
225
- die(
481
+ await failRun(
226
482
  `distillation agent did not complete (${message.subtype}) — nothing resolved, nothing pushed`,
227
483
  );
228
484
  console.log(
@@ -252,42 +508,42 @@ ${fileList}`;
252
508
 
253
509
  // 5 · verdicts must be complete — a missing entry is a hard stop, never a guess.
254
510
  if (!existsSync(verdictsPath))
255
- die(
511
+ await failRun(
256
512
  "agent produced no .rafa/distill-verdicts.json — nothing resolved, nothing pushed; working set left intact",
257
513
  );
258
514
  let verdicts;
259
515
  try {
260
516
  verdicts = JSON.parse(readFileSync(verdictsPath, "utf8")).verdicts;
261
517
  } catch {
262
- die(
518
+ await failRun(
263
519
  "distill-verdicts.json is not valid JSON — nothing resolved, nothing pushed",
264
520
  );
265
521
  }
266
522
  if (!Array.isArray(verdicts))
267
- die("distill-verdicts.json has no verdicts[] — aborting");
523
+ await failRun("distill-verdicts.json has no verdicts[] — aborting");
268
524
  const byPath = new Map(verdicts.map((v) => [v.path, v]));
269
525
  for (const f of active) {
270
526
  const v = byPath.get(f.path);
271
527
  if (!v || !VERDICTS.includes(v.verdict))
272
- die(
528
+ await failRun(
273
529
  `no valid verdict for ${f.path} — nothing resolved, nothing pushed; working set left intact`,
274
530
  );
275
531
  if (
276
532
  v.verdict !== "distilled" &&
277
533
  (typeof v.note !== "string" || v.note.trim() === "")
278
534
  )
279
- die(`${f.path}: verdict "${v.verdict}" requires a cited note — aborting`);
535
+ await failRun(`${f.path}: verdict "${v.verdict}" requires a cited note — aborting`);
280
536
  }
281
537
 
282
538
  // 6 · trust-but-verify: OUR gates re-run regardless of what the agent claims.
283
539
  console.log("• re-running the gates (trust-but-verify) …");
284
540
  const tGates = Date.now();
285
541
  if (runVerifyCitations([]) !== 0)
286
- die(
542
+ await failRun(
287
543
  "citation checker failed on the authored brain — nothing resolved, nothing pushed",
288
544
  );
289
545
  if (runCompile([]) !== 0)
290
- die(
546
+ await failRun(
291
547
  "compile gate failed on the authored brain — nothing resolved, nothing pushed",
292
548
  );
293
549
  console.log(` ⏱ gates: ${secs(tGates)}s`);
@@ -298,39 +554,59 @@ ${fileList}`;
298
554
  (f) => byPath.get(f.path).verdict === "distilled",
299
555
  ).length;
300
556
  if (distilledCount > 0) {
301
- await push([]);
557
+ await push(["--verb=distill"]);
302
558
  } else {
303
559
  console.log("• no survivors — nothing to push to the brain repo");
304
560
  }
305
561
 
306
562
  // 8 · resolve the rows (CAS — a racing session's resolve loses loudly).
563
+ // ONLY judged winners resolve, each under ITS OWN branch key and fenced on
564
+ // the version this run READ — a checkpoint that landed mid-judgment wins,
565
+ // never the stale verdict. Superseded same-path copies stay active: a
566
+ // verdict must never apply to content the judge did not see; each copy is
567
+ // judged on its own merits in a later run.
307
568
  let refuted = 0,
308
569
  adjudication = 0;
309
- for (const f of active) {
570
+ for (const g of groups) {
571
+ const f = g.winner;
310
572
  const v = byPath.get(f.path);
311
573
  if (v.verdict === "refuted") refuted++;
312
574
  if (v.verdict === "needs-adjudication") adjudication++;
313
575
  try {
314
576
  await callTool(ROOT, "resolve_working_file", {
315
- branch,
577
+ branch: f.branch ?? branch,
316
578
  path: f.path,
317
579
  resolution: v.verdict,
318
580
  actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
319
581
  ...(v.note ? { note: v.note } : {}),
582
+ ...(typeof f.version === "number" ? { baseVersion: f.version } : {}),
320
583
  });
321
584
  console.log(
322
- ` ${v.verdict === "distilled" ? "✓" : "!"} ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`,
585
+ ` ${v.verdict === "distilled" ? "✓" : "!"} ${f.branch ?? branch} · ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`,
323
586
  );
324
587
  } catch (e) {
325
588
  console.log(
326
- ` ✗ ${f.path} — resolve failed: ${e instanceof Error ? e.message : e}`,
589
+ ` ✗ ${f.branch ?? branch} · ${f.path} — resolve failed: ${e instanceof Error ? e.message : e}`,
327
590
  );
328
591
  }
592
+ for (const s of g.superseded)
593
+ console.log(
594
+ ` · ${s.branch ?? branch} · ${s.path} — older same-path copy left active for its own judgment`,
595
+ );
329
596
  }
330
597
  rmSync(verdictsPath, { force: true });
331
598
 
599
+ await reportRun(
600
+ "succeeded",
601
+ `${distilledCount} banked · ${refuted} refuted · ${adjudication} needs-adjudication` +
602
+ (sweptRows.length ? ` · ${sweptRows.length} swept from earlier merges` : "") +
603
+ (intentConsumed ? ` · ${intentConsumed} intent record(s) consumed` : ""),
604
+ );
332
605
  console.log(
333
606
  `✓ distillation: ${distilledCount} into the org brain · ${refuted} refuted (reported, cited) · ` +
334
- `${adjudication} flagged needs-adjudication (next session's digest) · total ${secs(t0)}s`,
607
+ `${adjudication} flagged needs-adjudication (next session's digest)` +
608
+ (sweptRows.length ? ` · ${sweptRows.length} swept from earlier merges` : "") +
609
+ (intentConsumed ? ` · ${intentConsumed} intent consumed` : "") +
610
+ ` · total ${secs(t0)}s`,
335
611
  );
336
612
  }