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