@rafinery/cli 0.8.6 → 0.8.10

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 CHANGED
@@ -50,7 +50,6 @@ const COMMANDS = [
50
50
  "doctor",
51
51
  "fold",
52
52
  "distill",
53
- "distiller",
54
53
  "ci-setup",
55
54
  "leverage",
56
55
  "benchmark",
@@ -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 (created lazily
4
- // from the current local state). Dirty brain surfaces are committed to the OLD
5
- // branch first (switch-carryover) — deterministic, nothing lost, nothing
6
- // blocks. argv: <prevHEAD> <newHEAD> <flag> (git's post-checkout contract;
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";
@@ -15,11 +26,25 @@ try {
15
26
  if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
16
27
  if (process.argv[4] !== "1") process.exit(0);
17
28
  if (!existsSync(join(ROOT, "rafa.json"))) process.exit(0);
18
- if (!existsSync(join(ROOT, ".rafa", ".git"))) process.exit(0);
19
29
 
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"));
30
+ const sh = (cmd, cwd = ROOT, timeout) =>
31
+ execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...(timeout ? { timeout } : {}) }).trim();
32
+ const shR = (cmd, timeout) => sh(cmd, join(ROOT, ".rafa"), timeout);
33
+
34
+ // SELF-HEAL (owner 2026-07-23): a repo with rafa.json but no materialized .rafa
35
+ // instance must still mirror — "clone → just works" means the FIRST branch checkout
36
+ // bootstraps the lazy .rafa itself (same npx precedent as the pre-push checkpoint
37
+ // hook), bounded so a checkout can never hang. A bootstrap failure degrades to the
38
+ // old silent no-op — a checkout must never break.
39
+ if (!existsSync(join(ROOT, ".rafa", ".git"))) {
40
+ try {
41
+ console.error("rafa · no local .rafa instance — bootstrapping (one-time) …");
42
+ sh("npx -y @rafinery/cli pull", ROOT, 90000);
43
+ } catch {
44
+ process.exit(0);
45
+ }
46
+ if (!existsSync(join(ROOT, ".rafa", ".git"))) process.exit(0);
47
+ }
23
48
 
24
49
  const branch = sh("git rev-parse --abbrev-ref HEAD");
25
50
  if (branch === "HEAD") process.exit(0); // detached — no mirror
@@ -33,14 +58,71 @@ try {
33
58
  } catch {
34
59
  /* nothing dirty */
35
60
  }
36
- // Trunk mirrors to the brain repo's SAME-NAMED branch when it exists — the
37
- // default branch stays the distiller's write target; the session only ever
38
- // reads there. A missing trunk-named brain branch is left alone.
61
+
62
+ // The brain trunk = the mirror of the code repo's default-ish branch (the
63
+ // distiller's write target; sessions only ever read there).
64
+ const trunkOf = () => {
65
+ for (const t of ["main", "master"]) {
66
+ try {
67
+ shR(`git rev-parse --verify -q "refs/remotes/origin/${t}"`);
68
+ return t;
69
+ } catch {
70
+ /* keep looking */
71
+ }
72
+ try {
73
+ shR(`git rev-parse --verify -q "refs/heads/${t}"`);
74
+ return t;
75
+ } catch {
76
+ /* keep looking */
77
+ }
78
+ }
79
+ return null;
80
+ };
81
+ const fetchTrunk = (trunk) => {
82
+ try {
83
+ shR(`git fetch -q origin "${trunk}"`, 8000); // bounded — a hook must never hang a checkout
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ };
89
+
39
90
  try {
91
+ // SWITCH — a same-named mirror branch exists: check it out. Switching TO the
92
+ // trunk additionally freshens it from remote (ff-only; divergence left alone —
93
+ // pull --full --force is the explicit adopt path).
40
94
  shR(`git rev-parse --verify -q "refs/heads/${branch}"`);
41
95
  shR(`git checkout -q "${branch}"`);
96
+ if (branch === "main" || branch === "master") {
97
+ if (fetchTrunk(branch)) {
98
+ try {
99
+ shR(`git merge -q --ff-only "origin/${branch}"`);
100
+ } catch {
101
+ /* diverged — loud adoption is pull --full --force, never a silent hook */
102
+ }
103
+ }
104
+ }
42
105
  } catch {
43
- if (branch !== "main" && branch !== "master") shR(`git checkout -q -b "${branch}"`);
106
+ // CUT no mirror branch yet. Never mirror-cut the trunk itself; for any other
107
+ // branch, base the new mirror on the freshest trunk we can reach.
108
+ if (branch !== "main" && branch !== "master") {
109
+ const trunk = trunkOf();
110
+ let base = null;
111
+ if (trunk) {
112
+ fetchTrunk(trunk); // best-effort freshness — offline still falls through
113
+ for (const ref of [`refs/remotes/origin/${trunk}`, `refs/heads/${trunk}`]) {
114
+ try {
115
+ shR(`git rev-parse --verify -q "${ref}"`);
116
+ base = ref;
117
+ break;
118
+ } catch {
119
+ /* next */
120
+ }
121
+ }
122
+ }
123
+ if (base) shR(`git checkout -q -b "${branch}" "${base}"`);
124
+ else shR(`git checkout -q -b "${branch}"`); // last resort: old behavior, current HEAD
125
+ }
44
126
  }
45
127
  } catch {
46
128
  /* silent by design */
File without changes
File without changes
File without changes
package/lib/releases.mjs CHANGED
@@ -245,6 +245,25 @@ export const RELEASES = [
245
245
  "sha and mints the node (brain ↔ code). Merges need NOT be PR-driven: a plain " +
246
246
  "`git merge` + push to main reconciles too. `rafa update` re-vendors the scan SOP.",
247
247
  },
248
+ {
249
+ version: "0.8.10",
250
+ contract: 1,
251
+ plans: 2,
252
+ requires: "update",
253
+ summary:
254
+ "(0.8.7/0.8.9 were unusable publishes — npm shipped the `workspace:*` dep " +
255
+ "literally; both unpublished, `pnpm publish` is the only publish path.) " +
256
+ "FRESH-SLATE BRANCH MIRRORING: a NEW code branch's brain-mirror branch is cut " +
257
+ "FROM THE REMOTE TRUNK (the published org brain; fetched fresh, offline-safe " +
258
+ "fallback ladder) — never from whatever branch the local .rafa mirror sat on. " +
259
+ "No session leftovers cross branches, local-vs-remote drift can't leak into a " +
260
+ "new branch, and the branch's diff vs trunk stays exactly what the branch " +
261
+ "captured. Switching TO the trunk ff-freshens it from remote (divergence stays " +
262
+ "loud — `rafa pull --full --force` is the explicit adopt). SELF-HEAL: a repo " +
263
+ "with rafa.json but no materialized .rafa bootstraps it automatically on the " +
264
+ "first branch checkout (bounded; a failure never breaks the checkout). " +
265
+ "`rafa update` re-vendors the post-checkout hook.",
266
+ },
248
267
  ];
249
268
 
250
269
  // The release this CLI build ships (last entry).
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.10",
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",
@@ -18,11 +17,16 @@
18
17
  "node": ">=18"
19
18
  },
20
19
  "dependencies": {
21
- "@rafinery/okf": "0.1.1"
20
+ "@rafinery/okf": "workspace:*"
22
21
  },
23
22
  "publishConfig": {
24
23
  "access": "public"
25
24
  },
25
+ "scripts": {
26
+ "bundle": "node scripts/bundle-blueprint.mjs",
27
+ "prepare": "node scripts/bundle-blueprint.mjs",
28
+ "test": "node --test"
29
+ },
26
30
  "keywords": [
27
31
  "rafa",
28
32
  "rafinery",
@@ -38,9 +42,5 @@
38
42
  "url": "git+https://github.com/rafinery-ai/rafinery.git",
39
43
  "directory": "packages/cli"
40
44
  },
41
- "homepage": "https://github.com/rafinery-ai/rafinery/tree/main/packages/cli",
42
- "scripts": {
43
- "bundle": "node scripts/bundle-blueprint.mjs",
44
- "test": "node --test"
45
- }
46
- }
45
+ "homepage": "https://github.com/rafinery-ai/rafinery/tree/main/packages/cli"
46
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- The MIT License
2
-
3
- Copyright (c) Atai Barkai
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
@@ -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
- }