@rafinery/cli 0.8.3 → 0.8.5

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.
@@ -7,7 +7,8 @@
7
7
  // non-blocking always — a brain problem must never block a code commit.
8
8
 
9
9
  import { execSync } from "node:child_process";
10
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
 
13
14
  const ROOT = process.cwd();
@@ -21,6 +22,31 @@ try {
21
22
  const rafa = join(ROOT, ".rafa");
22
23
  const shR = (cmd) => sh(cmd, rafa);
23
24
 
25
+ // DISPOSAL (MIRRORS working-set.isDisposableHydration — the ONE rule, both planes):
26
+ // an UNEDITED hydration must never enter a brain commit, or we re-push org content as a
27
+ // branch delta → stale-override at reconcile. After staging, unstage every unedited
28
+ // hydration (it stays in the working tree as disposable cache) so only real deltas commit.
29
+ const disposeHydrations = (br) => {
30
+ try {
31
+ const sc = JSON.parse(readFileSync(join(rafa, "hydration.json"), "utf8"));
32
+ const synced = (sc.sync ?? {})[br] ?? {};
33
+ for (const [p, rec] of Object.entries(sc.files ?? {})) {
34
+ const abs = join(rafa, p);
35
+ if (!existsSync(abs) || !/^[A-Za-z0-9._/-]+$/.test(p)) continue;
36
+ const hash = createHash("sha256").update(readFileSync(abs, "utf8")).digest("hex");
37
+ if (!synced[p] && rec?.hash === hash) {
38
+ try {
39
+ shR(`git reset -q -- "${p}"`);
40
+ } catch {
41
+ /* not staged (never tracked) — already excluded */
42
+ }
43
+ }
44
+ }
45
+ } catch {
46
+ /* no/corrupt sidecar → nothing hydrated to dispose */
47
+ }
48
+ };
49
+
24
50
  const branch = sh("git rev-parse --abbrev-ref HEAD");
25
51
  // Trunk commits never mirror — the brain default branch has one writer, the
26
52
  // distiller. Conservative charset guard: a refname git allows but a shell
@@ -34,6 +60,7 @@ try {
34
60
  if (cur !== branch) {
35
61
  try {
36
62
  shR("git add -A");
63
+ disposeHydrations(cur);
37
64
  shR(`git commit -q -m "brain(switch-carryover)" -m "switch-carryover-from: ${cur}"`);
38
65
  } catch {
39
66
  /* nothing dirty */
@@ -46,6 +73,38 @@ try {
46
73
  }
47
74
  }
48
75
 
76
+ // Merge-union (spec r2 §2.2): a LOCAL merge of branch X into this branch Y ⇒ union
77
+ // brain/X into brain/Y, the merged branch winning per path (fold doctrine on the git
78
+ // plane, `-X theirs`). TRUNK is excluded — merging main into a branch is a resync, not
79
+ // a knowledge transport (unioning main's brain would re-pull org content = stale-
80
+ // override). Best-effort: an unrecoverable branch name just leaves the 1-1 commit, and
81
+ // the reconciler's ancestry sweep still catches the merged commits.
82
+ try {
83
+ const parents = sh("git rev-list --parents -n 1 HEAD").split(/\s+/).slice(1);
84
+ if (parents.length >= 2) {
85
+ const m = sh("git log -1 --pretty=%B").match(
86
+ /Merge branch '([^']+)'|Merge branch "([^"]+)"|Merge remote-tracking branch '[^/]+\/([^']+)'/,
87
+ );
88
+ const merged = m ? m[1] || m[2] || m[3] : null;
89
+ if (
90
+ merged &&
91
+ merged !== branch &&
92
+ merged !== "main" &&
93
+ merged !== "master" &&
94
+ /^[A-Za-z0-9._/-]+$/.test(merged)
95
+ ) {
96
+ try {
97
+ shR(`git rev-parse --verify -q "refs/heads/${merged}"`);
98
+ shR(`git merge -q -m "brain(merge): ${merged} -> ${branch}" -X theirs "${merged}"`);
99
+ } catch {
100
+ /* no brain branch for the merged code branch, or already up to date */
101
+ }
102
+ }
103
+ }
104
+ } catch {
105
+ /* not a merge / rev-list unavailable — normal 1-1 path */
106
+ }
107
+
49
108
  // The intent record — the commit's end-to-end intent, mechanically joined.
50
109
  // Minimal here (sha · subject · files); the P3 capture worker enriches.
51
110
  const fullSha = sh("git rev-parse HEAD");
@@ -71,6 +130,7 @@ try {
71
130
  );
72
131
 
73
132
  shR("git add -A");
133
+ disposeHydrations(branch);
74
134
  shR(
75
135
  `git commit --allow-empty -q -m "brain(${branch}): ${subject}" ` +
76
136
  `-m "code-commit: ${fullSha}" -m "code-branch: ${branch}"`,
@@ -253,9 +253,16 @@ pipeline; **`--brain-only`** stops after the brain is validated (step 5 PASS)
253
253
  6. **Improve** *(skip if `--brain-only`)* — run the improve pass ([rafa-improve](../rafa-improve/SKILL.md)):
254
254
  spawn `bloom` → `.rafa/improve/`. It reads the *validated* brain as its index, so it only
255
255
  runs after PASS.
256
- 7. **Push** — present the full summary (brain verdict/score + top improvements). **On the
257
- dev's explicit approval**, `npx @rafinery/cli push --verb=scan` commits `.rafa/` and pushes to the
258
- brain remote using the dev's own git auth, stamped `brain-for: <code sha>`. Never without approval.
256
+ 7. **Land it** — present the full summary (brain verdict/score + top improvements). **On the
257
+ dev's explicit approval**, land the brain the way EVERY brain change lands through the
258
+ commit hooks, **never a direct trunk push** (the reconciler is the org brain's only writer).
259
+ The dev MUST be on a feature branch (the post-commit hook no-ops on `main`). Then:
260
+ `git add -A && git commit` → **post-commit** mirrors the validated `.rafa/` brain 1-1 onto
261
+ the matching brain branch (stamped `code-commit: <sha>`); `git push` → **pre-push**
262
+ checkpoints the working set to the platform; then land it on main (open a PR **or** a plain
263
+ `git merge` + push — both work) → detection enqueues and the **reconciler** authors the org
264
+ brain at the merge sha, minting the node (brain ↔ code). Do **not** run `rafa push` (retired:
265
+ it wrote the trunk directly, bypassing the reconciler). Never land without approval.
259
266
  8. **The coach offer (founding scan only)** — if this was the repo's FIRST scan and the dev's
260
267
  user brain is empty (`list_dev_insights` → none), offer ONCE: *"the code side is mapped —
261
268
  want me to bootstrap YOUR insights from your usage report?"* Accepted = run `## insights`
@@ -20,7 +20,7 @@ import { execSync } from "node:child_process";
20
20
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { callTool } from "./mcp-client.mjs";
23
- import { hashOf, readSidecar, scanWorkingFiles, writeSidecar } from "./working-set.mjs";
23
+ import { hashOf, isDisposableHydration, readSidecar, scanWorkingFiles, writeSidecar } from "./working-set.mjs";
24
24
 
25
25
  const die = (m) => {
26
26
  console.error(`✗ ${m}`);
@@ -85,7 +85,7 @@ export default async function checkpoint() {
85
85
  const synced = syncBranch[path];
86
86
 
87
87
  if (synced?.hash === hash && synced.conflictSeen === undefined) continue; // already checkpointed
88
- if (!synced && hydrated?.hash === hash) continue; // unedited hydration = disposable cache
88
+ if (isDisposableHydration({ synced, hydrated, hash })) continue; // unedited hydration = disposable cache
89
89
 
90
90
  candidates.push({
91
91
  path,
@@ -131,6 +131,38 @@ export async function arbitrateCandidate(candidate, cwd, { judge }) {
131
131
  return { outcome: "banked", noteId, cites: grounded, regroundings, detail: `banked as ${noteId}` };
132
132
  }
133
133
 
134
+ // ── mechanical grounding for the brain-grounded agent path (agent.mjs) ──────────
135
+ // The agent owns claim-level JUDGMENT (does this hold? redundant? contradicted?);
136
+ // grounding stays MECHANICAL here — it is grep, not judgment, so an LLM must never
137
+ // decide it. groundCandidate is the PRE-PASS: it resolves a candidate's cites against
138
+ // merged main exactly as arbitrateCandidate's mechanical half does, but WITHOUT the
139
+ // judge, and returns the raw grounding so the agent path can (a) prune a candidate
140
+ // citing deleted code without spending a token and (b) hand the agent only grounded /
141
+ // re-grounded claims to judge. `deleted` non-empty ⇒ a mechanical prune (deletion is
142
+ // ground truth). Same one implementation of "does the token still live" as the gate.
143
+ export function groundCandidate(candidate, cwd = process.cwd()) {
144
+ const cites = citesOf(candidate.content);
145
+ const noteId = idOf(candidate.content, candidate.path);
146
+ const perCite = cites.map((c) => arbitrateCite(c, cwd));
147
+ const deleted = perCite.filter((d) => d.status === "deleted");
148
+ const regroundings = perCite.filter((d) => d.status === "regrounded");
149
+ const grounded = perCite.map((d) => (d.status === "regrounded" ? d.to : d.cite));
150
+ return { cites, noteId, deleted, regroundings, grounded };
151
+ }
152
+
153
+ // citesGround is the POST-VERIFY GUARDRAIL: whatever content the agent proposes to
154
+ // author, EVERY cite in it must resolve AS AUTHORED against merged main (the agent is
155
+ // responsible for having re-grounded any moved cite itself in the content it returns).
156
+ // An unresolved cite means the agent hallucinated or mis-grounded — the loop downgrades
157
+ // that ONE disposition to needs-adjudication rather than banking ungrounded knowledge.
158
+ // This sits in FRONT of the verify-citations gate: it fails a single note to
159
+ // adjudication instead of letting the whole run's gate reject. Returns { ok, unresolved }.
160
+ export function citesGround(content, cwd = process.cwd()) {
161
+ const cites = citesOf(content);
162
+ const unresolved = cites.filter((c) => arbitrateCite(c, cwd).status !== "resolved");
163
+ return { ok: unresolved.length === 0, unresolved };
164
+ }
165
+
134
166
  // Rewrite a survivor's frontmatter `cites:` DSL so a re-grounded citation points
135
167
  // at where the token NOW lives on main — otherwise banking it as authored would
136
168
  // fail the checker (the stale line no longer resolves). Mechanical string
package/lib/distiller.mjs CHANGED
@@ -394,7 +394,28 @@ export default async function distiller(_args = []) {
394
394
  const doPush = () => push(["--verb=distill"]); // push.mjs re-runs the same gates + pushes; NEVER --force
395
395
 
396
396
  // Mirror the org brain first (the authoring target the survivors fold into).
397
- await pull(["--full"]);
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
+ }
398
419
  if (!existsSync(brainRoot)) mkdirSync(brainRoot, { recursive: true });
399
420
 
400
421
  const result = await runDistiller({
package/lib/gather.mjs ADDED
@@ -0,0 +1,64 @@
1
+ // git-native gather (capture-engine spec r2 §2.4) — the ONE mechanism that reads candidate
2
+ // brain deltas from a brain branch's history, unioned latest-wins per path. Two consumers:
3
+ //
4
+ // • session hydration — pull the branch's accumulated NON-main knowledge (an epic's task
5
+ // branches, inherited through git), so a dev has it while building the feature.
6
+ // • the distiller — gather a merged branch's deltas since it diverged, to reconcile into
7
+ // the org brain at main.
8
+ //
9
+ // Union-latest-wins is FREE: a branch tip already holds the latest version of every note in
10
+ // its ancestry (git inheritance from parent branches), so the net `base..tip` diff IS the
11
+ // union. The base picks the lens:
12
+ // • distill → base = branchPoint(branch, main): the branch's OWN deltas since it forked.
13
+ // • pull → base = main's brain tip: everything NOT yet distilled into the org brain.
14
+
15
+ import { execSync } from "node:child_process";
16
+
17
+ // The candidate authoring surfaces (mirrors working-set WORKING_DIRS; intent/ is provenance,
18
+ // never a candidate, so it is excluded here).
19
+ const CANDIDATE_DIRS = ["brain/rules", "brain/playbooks", "improve/improvements"];
20
+
21
+ const git = (dir, cmd) =>
22
+ execSync(cmd, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
23
+
24
+ // The branch point: where `branch` diverged from `from` (both brain refs). The distiller's
25
+ // base — the branch's own accumulated deltas are everything since here. "" if no common
26
+ // ancestor (an orphan/first branch → gather the whole branch).
27
+ export function branchPoint(brainDir, branch, from = "main") {
28
+ try {
29
+ return git(brainDir, `git merge-base ${JSON.stringify(from)} ${JSON.stringify(branch)}`);
30
+ } catch {
31
+ return "";
32
+ }
33
+ }
34
+
35
+ // Gather candidate deltas from `branch` since `base` (both brain refs) in the brain repo at
36
+ // `brainDir`. base "" ⇒ the whole branch. Returns:
37
+ // { tip, candidates: [{ path, content }], deleted: [path] }
38
+ // candidates = notes present at the tip that changed since base (latest-wins content).
39
+ // deleted = notes that existed at base and are GONE at the tip → prune signals.
40
+ export function gather({ brainDir, branch, base = "" }) {
41
+ const tip = git(brainDir, `git rev-parse ${JSON.stringify(branch)}`);
42
+ const dirspec = CANDIDATE_DIRS.map((d) => JSON.stringify(d)).join(" ");
43
+ const names = base
44
+ ? git(brainDir, `git diff --name-only ${base} ${tip} -- ${dirspec}`)
45
+ : git(brainDir, `git ls-tree -r --name-only ${tip} -- ${dirspec}`);
46
+
47
+ const candidates = [];
48
+ const deleted = [];
49
+ for (const path of names.split("\n").filter(Boolean)) {
50
+ const leaf = path.split("/").pop() ?? "";
51
+ if (path.endsWith(".theirs.md") || leaf.startsWith("_")) continue; // conflict/scratch
52
+ try {
53
+ const content = execSync(`git show ${tip}:${JSON.stringify(path)}`, {
54
+ cwd: brainDir,
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ });
58
+ candidates.push({ path, content });
59
+ } catch {
60
+ deleted.push(path); // in the diff but not at the tip ⇒ deleted since base
61
+ }
62
+ }
63
+ return { tip, candidates, deleted };
64
+ }
package/lib/push.mjs CHANGED
@@ -19,6 +19,23 @@ import { ensureBrainRepo, remoteDefaultBranch, inDir, die } from "./brain-repo.m
19
19
  import { CLI_VERSION } from "./releases.mjs";
20
20
 
21
21
  export default async function push(args = []) {
22
+ // Doctrine A (2026-07-20): `rafa push` wrote the brain TRUNK directly, bypassing the
23
+ // reconciler — which is now the org brain's ONLY writer. A scan no longer publishes the
24
+ // trunk; its validated `.rafa/` brain lands through the commit hooks (post-commit mirrors
25
+ // it 1-1 onto the matching brain branch → pre-push checkpoints → merge → the reconciler
26
+ // authors the trunk at the merge sha). Reconciler runs (`--verb=distill`) still pass, and
27
+ // `--force-trunk` stays as an explicit emergency override.
28
+ if (!args.includes("--verb=distill") && !args.includes("--force-trunk")) {
29
+ console.log(
30
+ "✗ `rafa push` (direct brain-trunk write) is retired — the reconciler is the org brain's\n" +
31
+ " only writer. Land a scan the normal way, ON A FEATURE BRANCH:\n" +
32
+ " • git add -A && git commit → post-commit mirrors the validated .rafa/ brain 1-1\n" +
33
+ " • git push → pre-push checkpoints the working set to the platform\n" +
34
+ " • merge to main (PR or plain git merge) → the reconciler authors the org brain\n" +
35
+ " (Emergency direct trunk write, discouraged: rerun with --force-trunk.)",
36
+ );
37
+ return;
38
+ }
22
39
  const ROOT = process.cwd();
23
40
  const { rafaDir } = ensureBrainRepo(ROOT);
24
41
  const inRafa = inDir(rafaDir);
package/lib/releases.mjs CHANGED
@@ -229,6 +229,20 @@ export const RELEASES = [
229
229
  "value is needed it is rafa.json → repoId, never a folder-name guess. `rafa " +
230
230
  "update` re-vendors the cards.",
231
231
  },
232
+ {
233
+ version: "0.8.5",
234
+ contract: 1,
235
+ plans: 2,
236
+ requires: "update",
237
+ summary:
238
+ "Doctrine: the reconciler is the org brain's ONLY trunk writer. `rafa push` " +
239
+ "(direct brain-trunk write) is RETIRED — a scan now LANDS through the commit hooks: " +
240
+ "commit your code on a feature branch (post-commit mirrors the validated .rafa/ " +
241
+ "brain 1-1 onto the matching brain branch) → git push (pre-push checkpoints the " +
242
+ "working set) → merge to main → the reconciler authors the org brain at the merge " +
243
+ "sha and mints the node (brain ↔ code). Merges need NOT be PR-driven: a plain " +
244
+ "`git merge` + push to main reconciles too. `rafa update` re-vendors the scan SOP.",
245
+ },
232
246
  ];
233
247
 
234
248
  // The release this CLI build ships (last entry).
@@ -48,6 +48,16 @@ export function kindOfPath(path) {
48
48
 
49
49
  export const hashOf = (text) => createHash("sha256").update(text).digest("hex");
50
50
 
51
+ // The ONE disposal rule of the working-set architecture: a file is a DISPOSABLE
52
+ // unedited hydration — never a candidate on EITHER plane — iff it is not a synced
53
+ // working-set file and its content still hashes to its hydration base. Extracted so the
54
+ // checkpoint (platform plane) and the brain-commit hook (git plane) apply the IDENTICAL
55
+ // test. The hook is standalone by design (node built-ins only), so it mirrors this
56
+ // one-liner inline — keep the two in sync (there is only one rule).
57
+ export function isDisposableHydration({ synced, hydrated, hash }) {
58
+ return !synced && hydrated?.hash === hash;
59
+ }
60
+
51
61
  export function sidecarPath(cwd) {
52
62
  return join(cwd, ".rafa", "hydration.json");
53
63
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "rafa \u2014 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",
8
9
  "rafa-distiller": "bin/rafa-distiller.mjs"
9
10
  },
10
11
  "files": [