@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.
- package/CHANGELOG.md +9 -0
- package/bin/rafa-distiller.mjs +13 -0
- package/bin/rafa.mjs +9 -8
- package/blueprint/.claude/agents/atlas.md +5 -1
- package/blueprint/.claude/agents/bloom.md +5 -1
- package/blueprint/.claude/agents/compass.md +5 -1
- package/blueprint/.claude/agents/prism.md +5 -1
- package/blueprint/.claude/agents/sage.md +5 -1
- package/blueprint/.claude/commands/rafa.md +20 -2
- package/blueprint/.claude/rafa/contract.md +31 -3
- package/blueprint/.claude/rafa/hooks/brain-commit.mjs +81 -0
- package/blueprint/.claude/rafa/hooks/brain-map.mjs +64 -0
- package/blueprint/.claude/rafa/hooks/brain-switch.mjs +48 -0
- package/blueprint/.claude/rafa/hooks/post-checkout +10 -0
- package/blueprint/.claude/rafa/hooks/post-commit +11 -0
- package/blueprint/.claude/rafa/hooks/post-rewrite +10 -0
- package/blueprint/.claude/rafa/hooks/pre-push +22 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +93 -0
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +5 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +1 -1
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +6 -0
- package/lib/checkpoint.mjs +42 -0
- package/lib/ci-setup.mjs +54 -3
- package/lib/dirty.mjs +37 -0
- package/lib/distill.mjs +313 -37
- package/lib/distiller/doctrine.mjs +379 -0
- package/lib/distiller/state-plane.mjs +159 -0
- package/lib/distiller.mjs +410 -0
- package/lib/doctor.mjs +110 -0
- package/lib/gate/compile.mjs +31 -0
- package/lib/gate/verify-citations.mjs +9 -2
- package/lib/githook.mjs +75 -33
- package/lib/init.mjs +22 -23
- package/lib/mcp-client.mjs +22 -5
- package/lib/pull.mjs +2 -2
- package/lib/push.mjs +67 -2
- package/lib/releases.mjs +13 -0
- package/lib/sensor-health.mjs +91 -0
- package/lib/status.mjs +47 -1
- package/lib/update.mjs +2 -2
- package/lib/working-set.mjs +19 -1
- package/package.json +12 -10
- 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
|
+
}
|