@rafinery/cli 0.8.1 → 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 (42) hide show
  1. package/CHANGELOG.md +9 -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/verify-citations.mjs +9 -2
  31. package/lib/githook.mjs +75 -33
  32. package/lib/init.mjs +14 -2
  33. package/lib/mcp-client.mjs +22 -5
  34. package/lib/pull.mjs +2 -2
  35. package/lib/push.mjs +67 -2
  36. package/lib/releases.mjs +13 -0
  37. package/lib/sensor-health.mjs +91 -0
  38. package/lib/status.mjs +47 -1
  39. package/lib/update.mjs +2 -2
  40. package/lib/working-set.mjs +19 -1
  41. package/package.json +11 -9
  42. package/LICENSE +0 -21
@@ -0,0 +1,379 @@
1
+ // The arbitration DOCTRINE (reconciliation orchestrator, spec §2.5) — the
2
+ // mechanical core of the distiller. Everything here is a pure function of the
3
+ // candidate working set + the checked-out merged main (git). The ONLY non-
4
+ // mechanical step in the whole run is claim-level truth ("does this normative
5
+ // claim still hold?"), and that is injected as a `judge` seam by the loop — it
6
+ // never lives here. So the doctrine is fully unit-testable against temp-dir git
7
+ // fixtures with a deterministic fake judge (refinery-arc-p1-distiller cases 1,3,4,5).
8
+ //
9
+ // Vocabulary (the per-row outcome the spec mandates, stamped with source branch
10
+ // + capturedAtSha + reconciliation id):
11
+ // banked — a survivor authored fresh into the org brain
12
+ // rewritten — a survivor whose citation moved on main and was re-grounded
13
+ // folded — a collision loser, superseded by the latest-merge winner
14
+ // refuted — a claim that no longer holds against main (cited reason)
15
+ // pruned — a note citing code the merge deleted, unrecoverably (deletion record)
16
+ //
17
+ // The citation machinery is REUSED verbatim from the gate (verify-citations):
18
+ // one implementation of "does this token still live in the code," gate + doctrine.
19
+
20
+ import { join } from "node:path";
21
+ import { execSync } from "node:child_process";
22
+ import { parseFrontmatter, parseCites } from "@rafinery/okf";
23
+ import { citeResolves, gitGrep } from "../gate/verify-citations.mjs";
24
+
25
+ // ── cite model ────────────────────────────────────────────────────────────────
26
+ // parseCites yields { file, line, token } where line is "55" or "14-18". Normalize
27
+ // to explicit start/end so citeResolves (the gate predicate) can consume it.
28
+ export function normalizeCite(c) {
29
+ const [a, b] = String(c.line).split("-");
30
+ const start = Number(a);
31
+ const end = b ? Number(b) : start;
32
+ return { file: c.file, start, end, token: c.token, loc: `${c.file}:${c.line}` };
33
+ }
34
+
35
+ // Parse a candidate note's frontmatter `cites:` DSL → normalized cites.
36
+ export function citesOf(content) {
37
+ const { data, error } = parseFrontmatter(content);
38
+ if (error || !data) return [];
39
+ return parseCites(data.cites).map(normalizeCite);
40
+ }
41
+
42
+ // The note id a candidate carries (frontmatter `id:`), else its path stem — the
43
+ // stable key an outcome/delta row is stamped with.
44
+ export function idOf(content, path = "") {
45
+ const { data } = parseFrontmatter(content) || {};
46
+ if (data && data.id) return String(data.id);
47
+ return path.split("/").pop()?.replace(/\.md$/, "") ?? "";
48
+ }
49
+
50
+ // ── single-cite arbitration against merged main ────────────────────────────────
51
+ // resolved — the cite still resolves as authored (grounded)
52
+ // regrounded — the exact line moved but the token still lives elsewhere → re-cite
53
+ // deleted — the token is nowhere in the code → a deletion (prune candidate)
54
+ export function arbitrateCite(cite, cwd = process.cwd()) {
55
+ const abs = join(cwd, cite.file);
56
+ if (citeResolves(abs, cite.start, cite.end, cite.token).ok)
57
+ return { status: "resolved", cite };
58
+ // The authored location is stale — is the knowledge merely MOVED, or GONE? One
59
+ // grep of the token over the merged tree settles it (the gate's gitGrep, .rafa
60
+ // excluded). A hit anywhere → the code moved, re-ground; no hit → deleted.
61
+ const hits = gitGrep(cite.token, cwd);
62
+ if (hits.length) {
63
+ const to = hits[0];
64
+ return {
65
+ status: "regrounded",
66
+ cite,
67
+ to: { file: to.file, start: to.line, end: to.line, token: cite.token, loc: `${to.file}:${to.line}` },
68
+ };
69
+ }
70
+ return {
71
+ status: "deleted",
72
+ cite,
73
+ record: `cited \`${cite.token}\` at ${cite.loc} — deleted on main (token nowhere in the merged tree)`,
74
+ };
75
+ }
76
+
77
+ // ── candidate-level arbitration (mechanical layer + the judge seam) ─────────────
78
+ // Returns a disposition the loop turns into an outcome + a delta entry.
79
+ // prune — the note cites code the merge deleted, no re-grounding possible
80
+ // refute — the citations ground but the claim no longer holds (judge said so)
81
+ // rewrite — a survivor whose citations were re-grounded (moved code)
82
+ // bank — a clean survivor (all cites resolve, claim holds)
83
+ // `judge` is the ONLY LLM touch-point; it is injected. A candidate whose cites
84
+ // don't even ground is decided WITHOUT the judge (mechanical refutation/prune).
85
+ // The judge is inherently async (a network LLM call), so this awaits it; a sync
86
+ // fake judge in tests satisfies `await` too. A verdict may report `tokens` — the
87
+ // loop's metering wrapper reads it off the return to accumulate meter.tokens.
88
+ export async function arbitrateCandidate(candidate, cwd, { judge }) {
89
+ const cites = citesOf(candidate.content);
90
+ const noteId = idOf(candidate.content, candidate.path);
91
+ const dispositions = cites.map((c) => arbitrateCite(c, cwd));
92
+
93
+ // A deletion the doctrine cannot re-ground → PRUNE with a citation-backed record
94
+ // (recoverable: the brain repo is git-versioned; a prune is a commit).
95
+ const deleted = dispositions.filter((d) => d.status === "deleted");
96
+ if (deleted.length) {
97
+ return {
98
+ outcome: "pruned",
99
+ noteId,
100
+ cites,
101
+ detail: deleted.map((d) => d.record).join("; "),
102
+ deletionRecords: deleted.map((d) => d.record),
103
+ };
104
+ }
105
+
106
+ const regroundings = dispositions.filter((d) => d.status === "regrounded");
107
+ const grounded = dispositions.map((d) => (d.status === "regrounded" ? d.to : d.cite));
108
+
109
+ // All citations now ground (as authored or re-grounded). Claim-level truth is
110
+ // the judge's call — the single LLM step, injected. Mechanical everywhere else.
111
+ const verdict = await judge(candidate, { cwd, grounded, regroundings });
112
+ if (!verdict || verdict.hold !== true) {
113
+ return {
114
+ outcome: "refuted",
115
+ noteId,
116
+ cites: grounded,
117
+ regroundings,
118
+ detail: verdict?.reason ?? "claim does not hold against merged main",
119
+ };
120
+ }
121
+ if (regroundings.length) {
122
+ return {
123
+ outcome: "rewritten",
124
+ noteId,
125
+ cites: grounded,
126
+ regroundings,
127
+ detail: `re-grounded ${regroundings.length} citation(s) that moved on main: ` +
128
+ regroundings.map((r) => `${r.cite.loc} → ${r.to.loc}`).join(", "),
129
+ };
130
+ }
131
+ return { outcome: "banked", noteId, cites: grounded, regroundings, detail: `banked as ${noteId}` };
132
+ }
133
+
134
+ // Rewrite a survivor's frontmatter `cites:` DSL so a re-grounded citation points
135
+ // at where the token NOW lives on main — otherwise banking it as authored would
136
+ // fail the checker (the stale line no longer resolves). Mechanical string
137
+ // substitution on the exact `file:line :: token` locator; untouched lines pass
138
+ // through. Returns the rewritten content.
139
+ export function rewriteCites(content, regroundings) {
140
+ let out = content;
141
+ for (const r of regroundings ?? []) {
142
+ const fromLoc = r.cite.loc; // file:line[-end]
143
+ const toLoc = r.to.loc; // file:line
144
+ // Match the DSL line ` - <fromLoc> :: <token>` and swap the locator only.
145
+ const esc = fromLoc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
146
+ const re = new RegExp(`(-\\s+)${esc}(\\s*::\\s*)`, "g");
147
+ out = out.replace(re, `$1${toLoc}$2`);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // ── folding N branch working sets into ONE run (case 1 · case 5) ────────────────
153
+ // Input: [{ branch, capturedAtSha, mergedAt, files: [{ path, content }] }].
154
+ // Output: candidates keyed by path; a path carried by >1 branch is a COLLISION
155
+ // (same-note collision across folded branches — claim-level arbitration + a
156
+ // latest-merge-wins tiebreak resolve it, spec §2.5).
157
+ export function foldCandidates(branchSets) {
158
+ const byPath = new Map();
159
+ for (const set of branchSets) {
160
+ for (const f of set.files) {
161
+ const cand = {
162
+ path: f.path,
163
+ branch: set.branch,
164
+ capturedAtSha: set.capturedAtSha,
165
+ mergedAt: set.mergedAt ?? 0,
166
+ content: f.content,
167
+ };
168
+ if (!byPath.has(f.path)) byPath.set(f.path, []);
169
+ byPath.get(f.path).push(cand);
170
+ }
171
+ }
172
+ const candidates = [];
173
+ const collisions = [];
174
+ for (const [path, versions] of byPath) {
175
+ if (versions.length === 1) candidates.push(versions[0]);
176
+ else collisions.push({ path, versions });
177
+ }
178
+ return { candidates, collisions };
179
+ }
180
+
181
+ // Deterministic latest-merge-wins ordering: LATEST mergedAt wins, branch name is
182
+ // the stable tiebreak. Never a coin-flip — CI has no one to ask (epic ADR: this
183
+ // supersedes needs-adjudication at merge time). Returns { winner, losers }.
184
+ export function latestMergeWins(versions) {
185
+ const ordered = [...versions].sort((a, b) => {
186
+ if (a.mergedAt !== b.mergedAt) return a.mergedAt - b.mergedAt;
187
+ return a.branch < b.branch ? -1 : a.branch > b.branch ? 1 : 0;
188
+ });
189
+ const winner = ordered[ordered.length - 1];
190
+ const losers = ordered.slice(0, -1);
191
+ return { winner, losers };
192
+ }
193
+
194
+ // Resolve a same-note collision (case 5): every version is judged (claim-level
195
+ // arbitration), THEN latest-merge-wins decides which SURVIVOR is banked. The other
196
+ // survivors become `folded, superseded by <winner branch>` (recorded in provenance);
197
+ // versions that did not survive keep their own outcome (refuted/pruned). Returns
198
+ // dispositions (each carrying its `candidate`) ready for the loop's outcome rows.
199
+ export async function resolveCollision(versions, cwd, { judge }) {
200
+ const judged = [];
201
+ for (const v of versions) judged.push({ candidate: v, ...(await arbitrateCandidate(v, cwd, { judge })) });
202
+ const survivors = judged.filter((d) => d.outcome === "banked" || d.outcome === "rewritten");
203
+ if (survivors.length <= 1) return judged; // 0 or 1 survivor → no tiebreak needed
204
+ const { winner } = latestMergeWins(survivors.map((s) => s.candidate));
205
+ return judged.map((d) => {
206
+ if ((d.outcome === "banked" || d.outcome === "rewritten") && d.candidate !== winner) {
207
+ return {
208
+ ...d,
209
+ outcome: "folded",
210
+ supersededBy: winner.branch,
211
+ detail: `folded, superseded by ${winner.branch} (latest merge @ ${winner.capturedAtSha})`,
212
+ };
213
+ }
214
+ return d;
215
+ });
216
+ }
217
+
218
+ // ── merge-range citation sweep (case 4, second half) ────────────────────────────
219
+ // The changed files since the reconciliation cursor — bounded, mechanical, never
220
+ // a re-scan. `git diff --name-only <cursor>..<merge>`.
221
+ export function changedFilesSince(cursorSha, mergeSha, cwd = process.cwd()) {
222
+ if (!cursorSha) return [];
223
+ try {
224
+ const out = execSync(
225
+ `git diff --name-only ${JSON.stringify(cursorSha)} ${JSON.stringify(mergeSha)}`,
226
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
227
+ );
228
+ return out.split("\n").filter(Boolean);
229
+ } catch {
230
+ return null; // unreachable range → caller treats as a transient sweep skip
231
+ }
232
+ }
233
+
234
+ // Re-resolve the citations of EXISTING org-brain notes that touch a changed file
235
+ // (never every note — only those in the blast radius of this merge range). Each
236
+ // touched cite re-grounds or prunes by the SAME rule as a candidate's. Returns
237
+ // [{ notePath, noteId, cite, disposition }] — the loop turns prunes into deletion
238
+ // records + delta.pruned rows and re-groundings into rewrite rows.
239
+ export function mergeRangeSweep({ changedFiles, orgNotes, cwd = process.cwd() }) {
240
+ const changed = new Set(changedFiles ?? []);
241
+ const results = [];
242
+ for (const note of orgNotes) {
243
+ const cites = citesOf(note.content);
244
+ const noteId = idOf(note.content, note.path);
245
+ for (const cite of cites) {
246
+ if (!changed.has(cite.file)) continue; // out of the merge range → untouched
247
+ const disposition = arbitrateCite(cite, cwd);
248
+ if (disposition.status !== "resolved")
249
+ results.push({ notePath: note.path, noteId, cite, disposition });
250
+ }
251
+ }
252
+ return results;
253
+ }
254
+
255
+ // ── failure classification (case 3) ─────────────────────────────────────────────
256
+ // Deterministic (config / gate rejection / schema): retrying only burns spend →
257
+ // reconcile_report failure class "deterministic" → needs-attention immediately.
258
+ // Transient (network / boot / OOM / clone / timeout): backoff retry while a later
259
+ // branch proceeds. An error may carry an explicit `errorClass`; else heuristics.
260
+ const TRANSIENT_RE =
261
+ /\b(fetch failed|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|timed? ?out|timeout|rate limit|429|50[0-9]\b|oom|out of memory|clone failed|unreachable)\b/i;
262
+ export function classifyError(err) {
263
+ const explicit = err && err.errorClass;
264
+ if (explicit === "deterministic" || explicit === "transient") return explicit;
265
+ const msg = String((err && err.message) || err || "");
266
+ return TRANSIENT_RE.test(msg) ? "transient" : "deterministic";
267
+ }
268
+
269
+ // Tagged-error helpers so the loop can raise a failure of a known class and the
270
+ // reconcile_report path carries it verbatim (never a re-guess at the boundary).
271
+ export function deterministicFailure(message) {
272
+ const e = new Error(message);
273
+ e.errorClass = "deterministic";
274
+ return e;
275
+ }
276
+ export function transientFailure(message) {
277
+ const e = new Error(message);
278
+ e.errorClass = "transient";
279
+ return e;
280
+ }
281
+
282
+ // ── per-run provenance assembly ─────────────────────────────────────────────────
283
+ // One outcome row per candidate/collision-member, stamped with its source branch,
284
+ // capturedAtSha, and the reconciliation id (spec §2.5 provenance requirement).
285
+ export function outcomeRow({ candidate, outcome, detail, reconciliationId, supersededBy }) {
286
+ return {
287
+ path: candidate.path,
288
+ branch: candidate.branch,
289
+ capturedAtSha: candidate.capturedAtSha,
290
+ reconciliationId,
291
+ outcome, // banked | rewritten | folded | refuted | pruned
292
+ detail: supersededBy ? `folded, superseded by ${supersededBy}` : detail,
293
+ };
294
+ }
295
+
296
+ // The reconcile_report `delta` shape (knowledgeGraph.deltaValidator): four buckets
297
+ // of { noteId, cites }. Built from the run's dispositions so the node the pointer
298
+ // advance mints carries exactly what this run refined. `folded` losers do not
299
+ // enter the delta (they were superseded, not banked) but ARE in the outcomes.
300
+ export function buildDelta(dispositions) {
301
+ const delta = { banked: [], rewritten: [], pruned: [], refuted: [] };
302
+ for (const d of dispositions) {
303
+ const entry = { noteId: d.noteId, cites: toWireCites(d.cites) };
304
+ if (d.outcome === "banked") delta.banked.push(entry);
305
+ else if (d.outcome === "rewritten") delta.rewritten.push(entry);
306
+ else if (d.outcome === "pruned") delta.pruned.push(entry);
307
+ else if (d.outcome === "refuted") delta.refuted.push(entry);
308
+ }
309
+ return delta;
310
+ }
311
+
312
+ // Normalized cites → the citeValidator wire shape ({ file, line, token }).
313
+ function toWireCites(cites = []) {
314
+ return cites.map((c) => ({
315
+ file: c.file,
316
+ line: c.start === c.end ? String(c.start) : `${c.start}-${c.end}`,
317
+ token: c.token,
318
+ }));
319
+ }
320
+
321
+ // Per-branch outcome rows for reconcile_report (perBranchOutcomeValidator:
322
+ // { branch, capturedAtSha, verdict }). One row per source branch, the verdict a
323
+ // short mechanical summary of that branch's fate in the fold.
324
+ export function perBranchOutcomes(branchSets, outcomeRows) {
325
+ return branchSets.map((set) => {
326
+ const rows = outcomeRows.filter((r) => r.branch === set.branch);
327
+ const tally = {};
328
+ for (const r of rows) tally[r.outcome] = (tally[r.outcome] ?? 0) + 1;
329
+ const verdict = rows.length
330
+ ? Object.entries(tally).map(([k, n]) => `${n} ${k}`).join(" · ")
331
+ : "no candidates";
332
+ return { branch: set.branch, capturedAtSha: set.capturedAtSha, verdict };
333
+ });
334
+ }
335
+
336
+ // ── the reconciliation-report OKF concept (contract §11 file class) ─────────────
337
+ // A per-run outcome/provenance record authored into brain/reconciliations/. type/
338
+ // title/description/tags are STAMPED by `rafa okf` at emit from run/outcome/tier —
339
+ // authored here WITHOUT them so the emitter owns derivation (no guessed values).
340
+ // Deletion + refutation records ride INSIDE the body (prose locators, NOT the
341
+ // frontmatter `cites:` DSL — a deletion record cites code that is GONE, so it must
342
+ // never enter the machine cite surface the checker re-verifies). ONE class, three
343
+ // record kinds as sections.
344
+ export function reconReportId(mergeSha) {
345
+ return `run-${String(mergeSha).slice(0, 12)}`;
346
+ }
347
+ export function renderReconReport({ mergeSha, outcome, tier, reconciliationId, outcomeRows, meter }) {
348
+ const id = reconReportId(mergeSha);
349
+ const by = (k) => outcomeRows.filter((r) => r.outcome === k);
350
+ const section = (heading, rows, fmt) => [
351
+ `## ${heading}`,
352
+ ...(rows.length ? rows.map(fmt) : ["* none this run."]),
353
+ "",
354
+ ];
355
+ const line = (r) => `* \`${r.path}\` (branch ${r.branch} @ ${r.capturedAtSha}) — ${r.detail}`;
356
+ const body = [
357
+ `<!-- reconciliation ${reconciliationId} · merge ${mergeSha} -->`,
358
+ "",
359
+ ...section("Refined", [...by("banked"), ...by("rewritten")], line),
360
+ ...section("Refuted", by("refuted"), line),
361
+ ...section("Pruned", by("pruned"), line),
362
+ ...section("Folded (superseded, latest-merge-wins)", by("folded"), line),
363
+ "## Meter",
364
+ `* machineSeconds: ${meter?.machineSeconds ?? 0}`,
365
+ `* tokens: ${meter?.tokens ?? 0}`,
366
+ "",
367
+ ].join("\n");
368
+ const frontmatter = [
369
+ "---",
370
+ "schemaVersion: 1",
371
+ `id: ${id}`,
372
+ `run: ${mergeSha}`,
373
+ `outcome: ${outcome}`,
374
+ `tier: ${tier}`,
375
+ "---",
376
+ "",
377
+ ].join("\n");
378
+ return { id, path: `brain/reconciliations/${id}.md`, content: frontmatter + body };
379
+ }
@@ -0,0 +1,159 @@
1
+ // The reconcile_* state-plane protocol, client side (reconciliation orchestrator,
2
+ // spec §2.6 / refinery-arc-p1-state-plane). boot.sh already won the claim and
3
+ // handed us the attempt token; from here the distiller must:
4
+ // heartbeat — extend the lease every ~2 min wall-clock (a dead run's lease
5
+ // expires and the queue sweep re-queues the row — completeness).
6
+ // log_append — batch narration ~1 s and ship it as chunks (the live tail).
7
+ // report — the terminal write: node delta + per-branch outcomes + meter
8
+ // (success) OR errorClass + message (failure).
9
+ //
10
+ // EVERY write carries the attempt fence token: a write from a superseded attempt
11
+ // (the run was killed and re-claimed → attempt++) is rejected by the queue as a
12
+ // zombie (`stale-attempt`). That is the fencing half of exit-gate case 2, provable
13
+ // at this boundary — we always send `attempt`, and a fenced write comes back
14
+ // `ok:false`. The queue-side detection + retry is proven in reconcileStatePlane.test.
15
+ //
16
+ // Timers + the platform `call` are INJECTED so the whole surface is unit-testable
17
+ // with a manual clock and a fake platform (no real 2-min waits, no real network).
18
+
19
+ // Heartbeat: fire reconcile_heartbeat on an interval. `beat()` also fires on demand
20
+ // (e.g. a phase transition). A beat that comes back fenced/not-running means WE are
21
+ // the zombie — stop and surface it via onFenced; the loop aborts rather than push.
22
+ export function startHeartbeat({
23
+ call,
24
+ reconciliationId,
25
+ attempt,
26
+ actor,
27
+ phase = "reconciling",
28
+ intervalMs = 120_000, // ~2 min wall-clock
29
+ setTimer = setInterval,
30
+ clearTimer = clearInterval,
31
+ onFenced = () => {},
32
+ }) {
33
+ let currentPhase = phase;
34
+ let stopped = false;
35
+ let handle = null;
36
+
37
+ async function beat() {
38
+ if (stopped) return { ok: false, reason: "stopped" };
39
+ let res;
40
+ try {
41
+ res = await call("reconcile_heartbeat", {
42
+ reconciliationId,
43
+ attempt,
44
+ phase: currentPhase,
45
+ actorMeta: actor,
46
+ });
47
+ } catch (e) {
48
+ // Transport hiccup on a single beat is non-fatal — the lease still has slack;
49
+ // the NEXT beat retries. A persistent outage lets the lease expire (sweep
50
+ // re-queues), which is the correct completeness behaviour.
51
+ return { ok: false, reason: "transport", error: e instanceof Error ? e.message : String(e) };
52
+ }
53
+ if (res && res.ok === false && (res.reason === "stale-attempt" || res.reason === "not-running")) {
54
+ stop();
55
+ onFenced(res.reason);
56
+ }
57
+ return res;
58
+ }
59
+
60
+ function setPhase(p) {
61
+ currentPhase = p;
62
+ }
63
+
64
+ function stop() {
65
+ stopped = true;
66
+ if (handle) clearTimer(handle);
67
+ handle = null;
68
+ }
69
+
70
+ handle = setTimer(() => {
71
+ void beat();
72
+ }, intervalMs);
73
+
74
+ return { beat, setPhase, stop };
75
+ }
76
+
77
+ // Log batcher: buffer narration lines and flush them as chunks on a ~1 s cadence
78
+ // (and on demand). A batch the queue rejects for a credential pattern is DROPPED
79
+ // with a value-free warning (names are contracts, values are secrets — the queue
80
+ // screens per line, we never echo the line). Attempt-fenced like every write.
81
+ export function makeLogBatcher({
82
+ call,
83
+ reconciliationId,
84
+ attempt,
85
+ actor,
86
+ flushMs = 1000,
87
+ setTimer = setInterval,
88
+ clearTimer = clearInterval,
89
+ warn = () => {},
90
+ }) {
91
+ let buffer = [];
92
+ let stopped = false;
93
+ let handle = null;
94
+
95
+ async function flush() {
96
+ if (buffer.length === 0) return { ok: true, appended: 0 };
97
+ const chunks = buffer;
98
+ buffer = [];
99
+ let res;
100
+ try {
101
+ res = await call("reconcile_log_append", {
102
+ reconciliationId,
103
+ attempt,
104
+ chunks,
105
+ actorMeta: actor,
106
+ });
107
+ } catch (e) {
108
+ // Never fatal: dropping a log batch loses narration, never correctness.
109
+ return { ok: false, reason: "transport", error: e instanceof Error ? e.message : String(e) };
110
+ }
111
+ if (res && res.ok === false && res.reason === "secret-detected") {
112
+ // The queue named the position, never the content — keep it that way.
113
+ warn(`log batch dropped: a line matched a credential pattern (chunk ${res.chunkIndex}) — not stored, value never echoed`);
114
+ }
115
+ return res;
116
+ }
117
+
118
+ function append(...lines) {
119
+ if (stopped) return;
120
+ for (const l of lines) if (l != null && String(l).length) buffer.push(String(l));
121
+ }
122
+
123
+ async function stop() {
124
+ stopped = true;
125
+ if (handle) clearTimer(handle);
126
+ handle = null;
127
+ return flush(); // final drain
128
+ }
129
+
130
+ handle = setTimer(() => {
131
+ void flush();
132
+ }, flushMs);
133
+
134
+ return { append, flush, stop };
135
+ }
136
+
137
+ // The terminal write. A SUCCESS payload carries the node delta + per-branch
138
+ // outcomes + meter (the pointer advance composes atomically queue-side). A run
139
+ // that refuted EVERYTHING is still a success — refutations are outcomes, not
140
+ // infrastructure failures (exit-gate: refute-everything is GREEN). A FAILURE
141
+ // payload carries the errorClass (deterministic → needs-attention now; transient
142
+ // → backoff retry) + a message.
143
+ export async function reportSuccess({ call, reconciliationId, attempt, actor, parents, delta, outcomes, meter }) {
144
+ return call("reconcile_report", {
145
+ reconciliationId,
146
+ attempt,
147
+ actorMeta: actor,
148
+ result: { kind: "success", parents, delta, outcomes, meter },
149
+ });
150
+ }
151
+
152
+ export async function reportFailure({ call, reconciliationId, attempt, actor, errorClass, message }) {
153
+ return call("reconcile_report", {
154
+ reconciliationId,
155
+ attempt,
156
+ actorMeta: actor,
157
+ result: { kind: "failure", errorClass, message },
158
+ });
159
+ }