@rafinery/cli 0.8.6 → 0.8.8
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/bin/rafa.mjs +0 -1
- package/blueprint/.claude/rafa/hooks/brain-switch.mjs +79 -11
- package/package.json +2 -3
- package/bin/rafa-distiller.mjs +0 -13
- package/lib/distiller/state-plane.mjs +0 -159
- package/lib/distiller.mjs +0 -431
package/bin/rafa.mjs
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// rafa brain-switch — the post-checkout worker (capture-engine P1, spec r2
|
|
2
2
|
// §2.2). Keeps the nested .rafa/ repo's branch in LOCKSTEP with the code
|
|
3
|
-
// branch: code branch cut/switch ⇒ brain branch cut/switch
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// branch: code branch cut/switch ⇒ brain branch cut/switch. Dirty brain
|
|
4
|
+
// surfaces are committed to the OLD branch first (switch-carryover) —
|
|
5
|
+
// deterministic, nothing lost, nothing blocks.
|
|
6
|
+
//
|
|
7
|
+
// FRESH-SLATE RULE (owner 2026-07-23): a NEW branch's mirror is cut FROM THE
|
|
8
|
+
// REMOTE TRUNK (the published org brain), never from whatever branch the local
|
|
9
|
+
// mirror happened to sit on. That makes the slate exact — no session leftovers
|
|
10
|
+
// from other branches, no local-vs-remote drift — and keeps the branch's diff
|
|
11
|
+
// vs trunk equal to precisely what the branch captures (the reconciler's
|
|
12
|
+
// git-plane semantic). Cutting an EMPTY tree instead would read as mass
|
|
13
|
+
// deletion of the org brain in that diff — never that.
|
|
14
|
+
// Fallback ladder (offline-safe, never blocks a checkout):
|
|
15
|
+
// origin/<trunk> (freshly fetched, bounded) → local <trunk> → current HEAD.
|
|
16
|
+
//
|
|
17
|
+
// argv: <prevHEAD> <newHEAD> <flag> (git's post-checkout contract;
|
|
7
18
|
// flag "1" = branch checkout, "0" = file checkout → no-op).
|
|
8
19
|
|
|
9
20
|
import { execSync } from "node:child_process";
|
|
@@ -17,9 +28,9 @@ try {
|
|
|
17
28
|
if (!existsSync(join(ROOT, "rafa.json"))) process.exit(0);
|
|
18
29
|
if (!existsSync(join(ROOT, ".rafa", ".git"))) process.exit(0);
|
|
19
30
|
|
|
20
|
-
const sh = (cmd, cwd = ROOT) =>
|
|
21
|
-
execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
22
|
-
const shR = (cmd) => sh(cmd, join(ROOT, ".rafa"));
|
|
31
|
+
const sh = (cmd, cwd = ROOT, timeout) =>
|
|
32
|
+
execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...(timeout ? { timeout } : {}) }).trim();
|
|
33
|
+
const shR = (cmd, timeout) => sh(cmd, join(ROOT, ".rafa"), timeout);
|
|
23
34
|
|
|
24
35
|
const branch = sh("git rev-parse --abbrev-ref HEAD");
|
|
25
36
|
if (branch === "HEAD") process.exit(0); // detached — no mirror
|
|
@@ -33,14 +44,71 @@ try {
|
|
|
33
44
|
} catch {
|
|
34
45
|
/* nothing dirty */
|
|
35
46
|
}
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
47
|
+
|
|
48
|
+
// The brain trunk = the mirror of the code repo's default-ish branch (the
|
|
49
|
+
// distiller's write target; sessions only ever read there).
|
|
50
|
+
const trunkOf = () => {
|
|
51
|
+
for (const t of ["main", "master"]) {
|
|
52
|
+
try {
|
|
53
|
+
shR(`git rev-parse --verify -q "refs/remotes/origin/${t}"`);
|
|
54
|
+
return t;
|
|
55
|
+
} catch {
|
|
56
|
+
/* keep looking */
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
shR(`git rev-parse --verify -q "refs/heads/${t}"`);
|
|
60
|
+
return t;
|
|
61
|
+
} catch {
|
|
62
|
+
/* keep looking */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
const fetchTrunk = (trunk) => {
|
|
68
|
+
try {
|
|
69
|
+
shR(`git fetch -q origin "${trunk}"`, 8000); // bounded — a hook must never hang a checkout
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
39
76
|
try {
|
|
77
|
+
// SWITCH — a same-named mirror branch exists: check it out. Switching TO the
|
|
78
|
+
// trunk additionally freshens it from remote (ff-only; divergence left alone —
|
|
79
|
+
// pull --full --force is the explicit adopt path).
|
|
40
80
|
shR(`git rev-parse --verify -q "refs/heads/${branch}"`);
|
|
41
81
|
shR(`git checkout -q "${branch}"`);
|
|
82
|
+
if (branch === "main" || branch === "master") {
|
|
83
|
+
if (fetchTrunk(branch)) {
|
|
84
|
+
try {
|
|
85
|
+
shR(`git merge -q --ff-only "origin/${branch}"`);
|
|
86
|
+
} catch {
|
|
87
|
+
/* diverged — loud adoption is pull --full --force, never a silent hook */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
42
91
|
} catch {
|
|
43
|
-
|
|
92
|
+
// CUT — no mirror branch yet. Never mirror-cut the trunk itself; for any other
|
|
93
|
+
// branch, base the new mirror on the freshest trunk we can reach.
|
|
94
|
+
if (branch !== "main" && branch !== "master") {
|
|
95
|
+
const trunk = trunkOf();
|
|
96
|
+
let base = null;
|
|
97
|
+
if (trunk) {
|
|
98
|
+
fetchTrunk(trunk); // best-effort freshness — offline still falls through
|
|
99
|
+
for (const ref of [`refs/remotes/origin/${trunk}`, `refs/heads/${trunk}`]) {
|
|
100
|
+
try {
|
|
101
|
+
shR(`git rev-parse --verify -q "${ref}"`);
|
|
102
|
+
base = ref;
|
|
103
|
+
break;
|
|
104
|
+
} catch {
|
|
105
|
+
/* next */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (base) shR(`git checkout -q -b "${branch}" "${base}"`);
|
|
110
|
+
else shR(`git checkout -q -b "${branch}"`); // last resort: old behavior, current HEAD
|
|
111
|
+
}
|
|
44
112
|
}
|
|
45
113
|
} catch {
|
|
46
114
|
/* silent by design */
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rafinery/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
4
4
|
"description": "rafa — the AI engineer CLI. Vendors the rafa blueprint into your repo (shadcn-style) and runs the deterministic contract gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rafa": "bin/rafa.mjs",
|
|
8
|
-
"cli": "bin/rafa.mjs"
|
|
9
|
-
"rafa-distiller": "bin/rafa-distiller.mjs"
|
|
8
|
+
"cli": "bin/rafa.mjs"
|
|
10
9
|
},
|
|
11
10
|
"files": [
|
|
12
11
|
"bin",
|
package/bin/rafa-distiller.mjs
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// rafa-distiller — the distiller loop's binary (reconciliation orchestrator, spec
|
|
3
|
-
// §2.6). boot.sh's DISTILLER_ENTRY seam: the sandbox image's boot.sh wins the claim
|
|
4
|
-
// (claim-on-start, exports RAFA_ATTEMPT) and `exec rafa-distiller`. From here the
|
|
5
|
-
// metered loop owns the run: heartbeat + arbitrate against merged main + author
|
|
6
|
-
// survivors through the gates + push (never force) + reconcile_report.
|
|
7
|
-
//
|
|
8
|
-
// Thin entry only — the loop lives in ../lib/distiller.mjs so it stays importable +
|
|
9
|
-
// unit-testable (runDistiller) without a sandbox.
|
|
10
|
-
|
|
11
|
-
import distiller from "../lib/distiller.mjs";
|
|
12
|
-
|
|
13
|
-
await distiller(process.argv.slice(2));
|
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/distiller.mjs
DELETED
|
@@ -1,431 +0,0 @@
|
|
|
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
|
-
// Prep failures MUST self-report (live-caught 2026-07-18: a safe.directory
|
|
398
|
-
// throw here died before the state plane ever started — running row, no
|
|
399
|
-
// lastError, no logs, invisible until someone ssh'd the sandbox). Best-effort
|
|
400
|
-
// report, then rethrow: the row shows the reason and the sweep retries.
|
|
401
|
-
try {
|
|
402
|
-
await pull(["--full"]);
|
|
403
|
-
} catch (e) {
|
|
404
|
-
const message = `brain mirror (pull --full) failed: ${e instanceof Error ? e.message.split("\n")[0] : e}`;
|
|
405
|
-
try {
|
|
406
|
-
await reportFailure({
|
|
407
|
-
call,
|
|
408
|
-
reconciliationId: env.RAFA_LEAD_RECONCILIATION_ID,
|
|
409
|
-
attempt: Number(env.RAFA_ATTEMPT),
|
|
410
|
-
actor: { model: env.RAFA_MODEL || "claude-opus-4-8", agent: "distiller", runner: "sandbox" },
|
|
411
|
-
errorClass: "transient",
|
|
412
|
-
message,
|
|
413
|
-
});
|
|
414
|
-
} catch {
|
|
415
|
-
/* the report is best-effort — the lease/sweep loop still recovers */
|
|
416
|
-
}
|
|
417
|
-
throw e;
|
|
418
|
-
}
|
|
419
|
-
if (!existsSync(brainRoot)) mkdirSync(brainRoot, { recursive: true });
|
|
420
|
-
|
|
421
|
-
const result = await runDistiller({
|
|
422
|
-
env, cwd, call, judge, collectSets, authorSurvivor, removeNote, authorReport, runGates, doPush,
|
|
423
|
-
orgNotes: async () => loadBrainNotes(cwd), // the mirrored brain is the sweep source
|
|
424
|
-
});
|
|
425
|
-
if (!result.ok && !result.fenced) process.exitCode = 1;
|
|
426
|
-
return result;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function mergeShaOf(env) {
|
|
430
|
-
return env.RAFA_MERGE_SHA || "";
|
|
431
|
-
}
|