@rafinery/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ The machine-readable version of this lives in [`lib/releases.mjs`](lib/releases.
4
4
  CLI reads it to tell a client, on `rafa update`, exactly what an upgrade requires (nothing,
5
5
  a re-scan, or a `rafa migrate`).
6
6
 
7
+ ## 0.11.0 — schema-aware reconciliation (the 4-case ladder)
8
+
9
+ Distillation is where knowledge from two lineages meets — so it is now also
10
+ where brain SCHEMA VERSIONS meet. Before any claim-level judging, the
11
+ reconciler resolves target (the org brain) vs source (the branch's candidates)
12
+ vs the runner's own latest schema:
13
+
14
+ 1. **target < source** → full target-brain rewrite onto the newer schema,
15
+ riding the same run's gates + push (one migration+reconcile commit).
16
+ 2. **target > source** → incoming old-schema candidates lift into the
17
+ target's schema before judging.
18
+ 3. **target == source, both < latest** → the merged target rewrites onto the
19
+ runner's latest.
20
+ 4. **target == source == latest** → plain diff reconcile — the happy flow,
21
+ exactly as before.
22
+
23
+ Either side NEWER than the runner aborts loudly — update the runner;
24
+ knowledge is never downgraded. Transforms are registered per step AND per
25
+ OKF type (`lib/distiller/schema-ladder.mjs` — each step declares rule ·
26
+ playbook · improvement explicitly: a function, or the EXPLICIT "unchanged",
27
+ which still re-stamps the version line; derived/generated files regenerate
28
+ at the new schema, they never migrate); a missing step or class fails
29
+ loudly — the ladder never guesses a schema. v1 is the only schema in existence, so today
30
+ every reconcile is case 4; this ships the SEAM (fully tested) that turns a
31
+ future v2 into a one-entry registry change instead of a migration crisis.
32
+
33
+ **Descriptive commits (owner 2026-07-26).** Reconciliation commits now SAY
34
+ what the run did: the headless distiller composes
35
+ `reconcile(<branch>): N banked · M refuted · K adjudication — <ids…>` and
36
+ passes it via the new `rafa push --message=` override (the brain-for trailer
37
+ is untouched — provenance consumers keep their key); the session SOP carries
38
+ the same rule. And the commit contract (rafa-commit) now REQUIRES an
39
+ agent-readable body on dev commits: what concretely changed, the why the diff
40
+ can't show, brain-note join keys, no session deixis — the next reader of a
41
+ commit is usually an agent.
42
+
43
+ **The configurable prod branch reaches the CLI.** `trunkBranchOf` resolves
44
+ the stamped `rafa.json → prodBranch` → git's origin/HEAD → "main"; the
45
+ checkpoint trunk guard and the CI distill target use it (with prodBranch set,
46
+ "main" can be a plain feature-carrying branch). The platform side shipped the
47
+ `repos.prodBranch` setting + repo-page card in the same arc.
48
+
49
+ Also in this release (landed after 0.10.0's publish): distill-refutation loop
50
+ events carry the required actor envelope + a dedupeKey (a retried distill
51
+ counts each resolution once); `cliActorMeta` honors `RAFA_ACTOR_RUNNER` for
52
+ sandbox/CI attribution; and dependsOn invalidation is documented as NOT
53
+ transitive, with breadth-of-declaration taught as the conservative pattern at
54
+ `rafa facts add`, the CLI help, and prism's citation rule (pinned by test).
55
+
7
56
  ## 0.10.0 — wave 6: the capability arc (skill dependencies + agent upgrades)
8
57
 
9
58
  rafa's agents get materially better at engineering — and the mechanism is the
@@ -455,6 +455,23 @@ imported comments are never scraped into decisions.
455
455
  fields do NOT bump it** — e.g. the note size stamps (`bodyTokens`,
456
456
  `cites[].targetTokens`, §1): ingest tolerates their absence, so a legacy manifest
457
457
  without them stays valid at the same `schemaVersion`.
458
+ - **The reconciliation schema ladder (0.11.0 — owner's 4-case doctrine).** When a
459
+ version bump DOES ship, distillation is the upgrade moment — the reconciler
460
+ resolves target-vs-source-vs-runner before judging: target below source → full
461
+ target rewrite onto the newer schema; source below target → incoming candidates
462
+ lift before judging; both equal-but-old → the merged target rewrites onto the
463
+ runner's latest; both current → plain diff (today). Either side newer than the
464
+ runner → **abort loudly** (update the runner; knowledge is never downgraded).
465
+ Transforms are registered per step AND per OKF type in the CLI
466
+ (`lib/distiller/schema-ladder.mjs`): each step declares rule · playbook · improvement
467
+ explicitly — a function, or the EXPLICIT `"unchanged"` (still re-stamps the note's
468
+ `schemaVersion:`); an unregistered step or class fails loudly — the ladder never
469
+ guesses a schema. Class resolves by CHAIN: the governed directory first (inside the
470
+ transport the path IS identity — the CAS row key), then the portable filename suffix
471
+ (`.rule.md` · `.playbook.md` · `.improvement.md`) for files travelling outside the
472
+ canonical tree; a contradiction between the two is a loud error. Only durable
473
+ knowledge migrates; derived/generated files
474
+ (coverage · checklist · ledger · reports · the manifest) regenerate at the new schema.
458
475
  - **Validate-and-correct:** `rafa compile` validates every file. On failure it emits
459
476
  a structured error — `path · field · rule` — to the **authoring agent** (atlas for
460
477
  notes, bloom for improvements/ledger, prism for checklist). The agent edits the
@@ -36,6 +36,28 @@ description: "rafa SOP — generate the commit message with the id join-key: the
36
36
  outcome one-line ("Done-check: prism PASS") — the receipt travels with the
37
37
  code.
38
38
 
39
+ ## The body is REQUIRED and written for an AGENT reader (owner 2026-07-26)
40
+
41
+ A subject-only commit is a broken contract here. The next reader of this
42
+ message is most often an AGENT — ours (atlas recalling why a line exists, the
43
+ distiller judging a claim against this change, sage reading shapes) or a
44
+ foreign one — so the body must be SELF-CONTAINED and joinable:
45
+
46
+ - **What, concretely** — the surfaces/behaviors that changed, named (not
47
+ "various fixes"); a reader must know the blast radius without the diff.
48
+ - **Why — the intent** — the one thing the diff cannot show. State the
49
+ problem/decision that made this change necessary; paraphrase the pivotal
50
+ choice if one was made (it should also be a `log_decision`).
51
+ - **Join keys** — mention the brain notes this change follows or invalidates
52
+ (`per rule <id>` / `staleness: touches <id>`), so the cite-graph and the
53
+ dirty queue read straight out of the log.
54
+ - **No session deixis** — never "as discussed", "per the above", "the same
55
+ fix as before": the message must stand alone in `git log` a year later,
56
+ with zero conversation context.
57
+
58
+ TDD-default red commits keep their fixed form (`[<task-id>] test: red —
59
+ <seam>`) — the seam IS the body's content there.
60
+
39
61
  ## Why this exists (don't skip the bracket)
40
62
 
41
63
  The brain-commit hook captures every subject into `intent/<sha>.md`;
@@ -39,6 +39,28 @@ code it describes.
39
39
  1. **Collect** — `get_working_set(repo, branch, status: active)`. Zero files →
40
40
  nothing to do, say so, stop. Rows already `needs-adjudication` are a HUMAN's
41
41
  to resolve — surface them in the digest, never fold them in silently.
42
+ 1b. **Schema ladder (0.11.0 — the owner's 4-case doctrine).** Before ANY
43
+ claim-level judging, resolve the version lattice: **target** = the org
44
+ brain's `manifest.json → schemaVersion` (else max over its notes; a
45
+ pre-manifest brain is the founding v1) · **source** = max over the
46
+ collected candidates' own `schemaVersion` · **latest** = the running CLI's
47
+ `SCHEMA_VERSION`. Then: **target < source → REWRITE-TARGET** (every org-
48
+ brain note lifts to the newer schema and rides THIS run's gates + push —
49
+ one migration+reconcile commit) · **target > source → UPGRADE-SOURCE**
50
+ (incoming candidates lift into the target's schema before judging) ·
51
+ **equal but < latest → REWRITE-MERGED** (both lift; the merged target is a
52
+ full rewrite onto latest) · **equal and == latest → DIFF** (the happy flow,
53
+ steps 2–6 exactly as written). **Either side > latest → ABORT LOUDLY** — a
54
+ newer CLI authored it; update the runner, knowledge is never downgraded.
55
+ Transforms are REGISTERED per step AND per OKF type
56
+ (`lib/distiller/schema-ladder.mjs` — each step declares rule · playbook ·
57
+ improvement explicitly: a function, or the EXPLICIT "unchanged", which
58
+ still re-stamps the note's schemaVersion line); a missing step OR class is
59
+ a loud error, never a guess. Only durable knowledge ladders — derived/
60
+ generated files (coverage · checklist · ledger · reports · manifest)
61
+ REGENERATE at the new schema; intent records pass through untouched. The
62
+ headless CLI runs this mechanically; the session path applies the same
63
+ lattice by hand.
42
64
  2. **Validate (prism, context-isolated)** — the target is **merged MAIN as of
43
65
  NOW, never the fork point and never a stale checkout**: `git fetch origin`
44
66
  first; judge every claim against the fetched trunk. Confirm every citation
@@ -51,7 +73,13 @@ code it describes.
51
73
  files lost it — re-declare); fold into an existing note when one covers the
52
74
  topic (supersede, never duplicate).
53
75
  4. **Gate + ship** — `rafa verify-citations` AND `rafa compile` to exit 0 →
54
- `rafa push`. Only now has anything entered the org brain. A failed gate
76
+ `rafa push --verb=distill --message="reconcile(<branch>): <N> banked ·
77
+ <M> refuted · <K> adjudication — <banked ids…>"`. The reconciliation
78
+ commit is DESCRIPTIVE (owner 2026-07-26): its subject states what THIS run
79
+ did — branch, per-verdict counts, the banked note ids — so any agent
80
+ walking the brain log understands the commit without opening it (the
81
+ headless CLI composes this automatically; the session path passes it
82
+ explicitly). Only now has anything entered the org brain. A failed gate
55
83
  aborts EVERYTHING: nothing resolved, nothing pushed, working set intact.
56
84
  5. **Resolve** — `resolve_working_file(path, distilled)` for survivors — authored
57
85
  per the OKF surface ([rafa-okf](../rafa-okf/SKILL.md); push emits the rest) —
@@ -61,9 +89,14 @@ code it describes.
61
89
  which files died and why (cited); refutation is feedback, not silence. At
62
90
  that same resolve beat emit **`report_loop_event(category:
63
91
  "distill-refutation", outcome: refuted|distilled, subject: <path or note
64
- id>)`** sage's evidence that a claim met the gates or was refuted (shapes
65
- only: the outcome + a file/note reference, never the refuted content, never
66
- the cited code). Checkpoint-adjacent, monotonic, never a session-end sweep.
92
+ id>, actorMeta: {model: <the judging model>, agent: "distiller@<ver>" (or
93
+ "prism@<ver>" on the session path), runner: session|ci|sandbox the tier
94
+ this distill runs in}, dedupeKey: "<branch>·<path>·<outcome>")`** sage's
95
+ evidence that a claim met the gates or was refuted (shapes only: the
96
+ outcome + a file/note reference, never the refuted content, never the
97
+ cited code). Wave 5 is STRICT: an emit without the actor envelope is
98
+ rejected loudly; the dedupeKey makes a re-run of the same distillation
99
+ count ONCE. Checkpoint-adjacent, monotonic, never a session-end sweep.
67
100
  `resolve_working_file(path, needs-adjudication, note)` for the undecidable —
68
101
  the next session's digest offers the decision.
69
102
 
@@ -108,11 +108,20 @@ export default async function checkpoint() {
108
108
 
109
109
  // The working set is BRANCH state. On the trunk, knowledge changes go
110
110
  // through scan → compile → rafa push (the org brain's one writer surface).
111
- if (branch === "main" || branch === "master") {
112
- console.log(
113
- `• on ${branch} the working set is branch-scoped; org-brain changes on the trunk go through /rafa scan + rafa push. Nothing to checkpoint.`,
114
- );
115
- return;
111
+ // The trunk is RESOLVED, never hardcoded (owner 2026-07-26): the stamped
112
+ // prodBranch → origin/HEAD → "main"; the main|master pair stays as a belt
113
+ // ONLY for unconfigured repos (with prodBranch set, "main" can be a plain
114
+ // feature-carrying branch and its working set checkpoints normally).
115
+ {
116
+ const { trunkBranchOf, readStamp } = await import("./stamp.mjs");
117
+ const trunk = trunkBranchOf(ROOT);
118
+ const configured = typeof readStamp(ROOT).prodBranch === "string";
119
+ if (branch === trunk || (!configured && (branch === "main" || branch === "master"))) {
120
+ console.log(
121
+ `• on ${branch} (the trunk) — the working set is branch-scoped; org-brain changes on the trunk go through /rafa scan + rafa push. Nothing to checkpoint.`,
122
+ );
123
+ return;
124
+ }
116
125
  }
117
126
 
118
127
  // The commit this capture is grounded against (contract: capturedAtSha —
package/lib/distill.mjs CHANGED
@@ -40,8 +40,17 @@ import { createRequire } from "node:module";
40
40
  import { dirname, join } from "node:path";
41
41
  import { pathToFileURL } from "node:url";
42
42
  import { callTool } from "./mcp-client.mjs";
43
- import { runCompile } from "./gate/compile.mjs";
43
+ import { trunkBranchOf } from "./stamp.mjs";
44
+ import { runCompile, SCHEMA_VERSION } from "./gate/compile.mjs";
44
45
  import { runVerifyCitations } from "./gate/verify-citations.mjs";
46
+ import {
47
+ applyLadder,
48
+ brainSchemaVersion,
49
+ classOf,
50
+ resolveSchemaPlan,
51
+ sourceSchemaVersion,
52
+ walkBrainNotes,
53
+ } from "./distiller/schema-ladder.mjs";
45
54
  import pull from "./pull.mjs";
46
55
  import push from "./push.mjs";
47
56
 
@@ -192,7 +201,7 @@ export default async function distill(args = []) {
192
201
  try {
193
202
  await callTool(ROOT, "report_distill_run", {
194
203
  branch,
195
- toBranch: process.env.RAFA_TARGET_BRANCH || "main",
204
+ toBranch: process.env.RAFA_TARGET_BRANCH || trunkBranchOf(ROOT),
196
205
  mergeCommitSha: mergeSha,
197
206
  status,
198
207
  note: summary,
@@ -371,6 +380,35 @@ export default async function distill(args = []) {
371
380
  await pull(["--full"]);
372
381
  console.log(` ⏱ brain mirror: ${secs(tPull)}s`);
373
382
 
383
+ // 2b · SCHEMA LADDER (0.11.0 — the owner's 4-case doctrine): resolve the
384
+ // version lattice BEFORE any claim-level judging, so knowledge from two
385
+ // lineages always meets on ONE schema — the newest this runner understands.
386
+ // Lifted target notes ride THIS run's gates + push (one migration+reconcile
387
+ // commit); an unknown future schema aborts loudly, never downgrades.
388
+ {
389
+ const target = brainSchemaVersion(join(ROOT, ".rafa"));
390
+ const source = sourceSchemaVersion(active.map((f) => f.content));
391
+ const plan = resolveSchemaPlan({ target, source, latest: SCHEMA_VERSION });
392
+ console.log(
393
+ `• schema ladder: target v${target} · source v${source} · runner v${SCHEMA_VERSION} → ${plan.mode}`,
394
+ );
395
+ if (plan.mode === "abort-newer-than-runner") die(plan.reason);
396
+ if (plan.mode !== "diff") console.log(` ${plan.reason}`);
397
+ // Per-OKF-type lifts (class derives from the PATH — typed-candidates
398
+ // doctrine); non-laddered paths (intent records …) pass through untouched.
399
+ if (plan.upgradeSource)
400
+ for (const f of active) {
401
+ const cls = classOf(f.path);
402
+ if (cls) f.content = applyLadder(f.content, plan.toVersion, cls);
403
+ }
404
+ if (plan.rewriteTarget)
405
+ for (const noteFile of walkBrainNotes(join(ROOT, ".rafa"))) {
406
+ const cls = classOf(noteFile);
407
+ if (cls)
408
+ writeFileSync(noteFile, applyLadder(readFileSync(noteFile, "utf8"), plan.toVersion, cls));
409
+ }
410
+ }
411
+
374
412
  // 3 · stage the incoming working set for the agent (gitignored inside .rafa).
375
413
  const stagingRoot = join(ROOT, ".rafa", "distill-incoming");
376
414
  rmSync(stagingRoot, { recursive: true, force: true });
@@ -554,7 +592,22 @@ ${fileList}`;
554
592
  (f) => byPath.get(f.path).verdict === "distilled",
555
593
  ).length;
556
594
  if (distilledCount > 0) {
557
- await push(["--verb=distill"]);
595
+ // Descriptive reconciliation commit (owner 2026-07-26): the subject says
596
+ // WHAT this run did — branch, per-verdict counts, the banked ids — so any
597
+ // agent (ours or a foreign OKF reader) walking the brain log understands
598
+ // the commit without opening it. brain-for trailer untouched (push owns it).
599
+ {
600
+ const counts = { distilled: 0, refuted: 0, "needs-adjudication": 0 };
601
+ for (const v of verdicts) counts[v.verdict] = (counts[v.verdict] ?? 0) + 1;
602
+ const banked = verdicts
603
+ .filter((v) => v.verdict === "distilled")
604
+ .map((v) => String(v.path).split("/").pop().replace(/\.md$/, ""));
605
+ const idList = banked.slice(0, 4).join(", ") + (banked.length > 4 ? " …" : "");
606
+ await push([
607
+ "--verb=distill",
608
+ `--message=reconcile(${branch}): ${counts.distilled} banked · ${counts.refuted} refuted · ${counts["needs-adjudication"]} adjudication${banked.length ? ` — ${idList}` : ""}`,
609
+ ]);
610
+ }
558
611
  } else {
559
612
  console.log("• no survivors — nothing to push to the brain repo");
560
613
  }
@@ -0,0 +1,186 @@
1
+ // The schema ladder (0.11.0 — schema-aware reconciliation, owner's 4-case
2
+ // doctrine, 2026-07-25). Distillation is where knowledge from two lineages
3
+ // meets, so it is ALSO where brain schema versions meet — the reconciler
4
+ // resolves the version lattice BEFORE any claim-level judging, and the merged
5
+ // result always lands on the newest schema the RUNNER understands:
6
+ //
7
+ // 1. target < source → REWRITE-TARGET: the whole target brain
8
+ // lifts to the newer schema (the incoming side already speaks it).
9
+ // 2. target > source → UPGRADE-SOURCE: incoming old-schema
10
+ // candidates lift into the target's newer schema before judging.
11
+ // 3. target == source < latest → REWRITE-MERGED: both sides lift; the
12
+ // merged target is a full rewrite onto the latest schema.
13
+ // 4. target == source == latest → DIFF: the happy flow, exactly today.
14
+ // ∅. either side > latest → ABORT LOUDLY: a newer CLI authored this;
15
+ // this runner cannot judge a schema it does not know — update the
16
+ // runner, NEVER downgrade knowledge.
17
+ //
18
+ // Every non-abort mode converges in ONE pass: toVersion is always the
19
+ // runner's latest (which case 1/2's higher side already equals or precedes —
20
+ // the runner can never know less than what it authored). Transforms are
21
+ // REGISTERED per step (v(N-1)→vN) in LADDER below; a missing step throws —
22
+ // the ladder never guesses a schema (no assumed values). v1 is the baseline
23
+ // and the only schema in existence today, so the registry ships empty: this
24
+ // module is the SEAM, proven by tests, that makes a future v2 a one-entry
25
+ // change instead of a migration crisis.
26
+
27
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ // Per-step, PER-OKF-TYPE transforms (owner 2026-07-26: each file class has its
31
+ // own shape in the contract's type registry, so each ladder step declares each
32
+ // class EXPLICITLY):
33
+ //
34
+ // LADDER[2] = {
35
+ // describe: "what v2 changes",
36
+ // rule: (content) => content2, // a real shape change for rules
37
+ // playbook: "unchanged", // EXPLICIT identity — visible, auditable
38
+ // improvement: (content) => …,
39
+ // }
40
+ //
41
+ // Only the three DURABLE knowledge classes ladder (rule · playbook ·
42
+ // improvement — the classes distillation judges and the brain keeps). Derived/
43
+ // generated files (coverage, checklist, ledger, reconciliation reports,
44
+ // manifest) are NEVER migrated — their producers regenerate them at the new
45
+ // schema. A step missing a class entry is a LOUD error, not implicit identity:
46
+ // "unchanged" must be declared, never assumed. Every applied step (identity
47
+ // included) re-stamps the note's own `schemaVersion:` line.
48
+ export const LADDER = {};
49
+
50
+ export const LADDER_CLASSES = ["rule", "playbook", "improvement"];
51
+
52
+ // Class resolution chain (owner 2026-07-26): DIRECTORY first — inside rafa's
53
+ // transport the path is governed identity (the CAS row key, the registry
54
+ // glob, the hydrate target; it cannot drift without becoming a different
55
+ // row) — then the PORTABLE filename suffix (`.rule.md` · `.playbook.md` ·
56
+ // `.improvement.md`), which travels WITH a file that leaves the canonical
57
+ // tree (foreign OKF bundles, out-of-context shares) while staying outside
58
+ // the corruptible content. When BOTH are present they must AGREE — a
59
+ // contradiction throws loudly, never a guess. Returns null for non-laddered
60
+ // paths (intent records, reports, …) — those pass through untouched.
61
+ const SUFFIX_CLASS = {
62
+ ".rule.md": "rule",
63
+ ".playbook.md": "playbook",
64
+ ".improvement.md": "improvement",
65
+ };
66
+ export function classOf(path) {
67
+ const p = String(path).replace(/\\/g, "/");
68
+ const byDir = p.includes("brain/rules/")
69
+ ? "rule"
70
+ : p.includes("brain/playbooks/")
71
+ ? "playbook"
72
+ : p.includes("improve/improvements/")
73
+ ? "improvement"
74
+ : null;
75
+ const bySuffix =
76
+ Object.entries(SUFFIX_CLASS).find(([s]) => p.endsWith(s))?.[1] ?? null;
77
+ if (byDir && bySuffix && byDir !== bySuffix)
78
+ throw new Error(
79
+ `schema ladder: directory class "${byDir}" contradicts filename class "${bySuffix}" (${p}) — ` +
80
+ "fix the file's home or its name; the ladder never guesses",
81
+ );
82
+ return byDir ?? bySuffix;
83
+ }
84
+
85
+ // A note's OWN declared schema (first-fence `schemaVersion:`). Default 1 — the
86
+ // founding schema, same convention as stampedVersions defaulting to the first
87
+ // release (a pre-stamp file predates versioning, it isn't unversioned).
88
+ export function noteSchemaVersion(content) {
89
+ if (typeof content !== "string" || !content.startsWith("---")) return 1;
90
+ const end = content.indexOf("\n---", 3);
91
+ const fm = end === -1 ? content : content.slice(0, end);
92
+ const m = fm.match(/^schemaVersion:\s*(\d+)\s*$/m);
93
+ return m ? Number(m[1]) : 1;
94
+ }
95
+
96
+ const NOTE_DIRS = ["brain/rules", "brain/playbooks", "improve/improvements"];
97
+
98
+ export function walkBrainNotes(rafaDir) {
99
+ const out = [];
100
+ const walk = (dir) => {
101
+ if (!existsSync(dir)) return;
102
+ for (const e of readdirSync(dir)) {
103
+ const p = join(dir, e);
104
+ if (statSync(p).isDirectory()) walk(p);
105
+ else if (e.endsWith(".md") && !e.startsWith("_") && !e.endsWith(".theirs.md"))
106
+ out.push(p);
107
+ }
108
+ };
109
+ for (const d of NOTE_DIRS) walk(join(rafaDir, d));
110
+ return out;
111
+ }
112
+
113
+ // The TARGET brain's schema: manifest.json first (the compiled truth), else
114
+ // the max over its notes' own declarations, else the founding schema.
115
+ export function brainSchemaVersion(rafaDir) {
116
+ try {
117
+ const m = JSON.parse(readFileSync(join(rafaDir, "manifest.json"), "utf8"));
118
+ if (Number.isInteger(m.schemaVersion)) return m.schemaVersion;
119
+ } catch {
120
+ /* no manifest — fall through to the notes */
121
+ }
122
+ const versions = walkBrainNotes(rafaDir).map((f) =>
123
+ noteSchemaVersion(readFileSync(f, "utf8")),
124
+ );
125
+ return versions.length ? Math.max(...versions) : 1;
126
+ }
127
+
128
+ // The SOURCE side's schema: the max over the incoming candidates' own
129
+ // declarations (a working set may be mixed if it straddled an upgrade).
130
+ export function sourceSchemaVersion(contents) {
131
+ const versions = (contents ?? []).map(noteSchemaVersion);
132
+ return versions.length ? Math.max(...versions) : 1;
133
+ }
134
+
135
+ // The lattice. Returns {mode, toVersion, rewriteTarget, upgradeSource, reason}.
136
+ export function resolveSchemaPlan({ target, source, latest }) {
137
+ if (target > latest || source > latest)
138
+ return {
139
+ mode: "abort-newer-than-runner",
140
+ toVersion: null,
141
+ rewriteTarget: false,
142
+ upgradeSource: false,
143
+ reason:
144
+ `brain schema v${Math.max(target, source)} is NEWER than this runner's v${latest} — ` +
145
+ "a newer CLI authored it. Update @rafinery/cli and re-run; knowledge is never downgraded.",
146
+ };
147
+ const base = { toVersion: latest, rewriteTarget: target < latest, upgradeSource: source < latest };
148
+ if (target < source)
149
+ return { mode: "rewrite-target", ...base, reason: `target v${target} < source v${source} — full target rewrite onto v${latest}` };
150
+ if (source < target)
151
+ return { mode: "upgrade-source", ...base, reason: `source v${source} < target v${target} — incoming candidates lift into v${latest}` };
152
+ if (target < latest)
153
+ return { mode: "rewrite-merged", ...base, reason: `both sides v${target} < runner v${latest} — merged result rewrites onto v${latest}` };
154
+ return { mode: "diff", ...base, reason: `both sides already v${latest} — plain diff reconcile` };
155
+ }
156
+
157
+ const stampVersion = (content, v) =>
158
+ content.replace(/^schemaVersion:\s*\d+\s*$/m, `schemaVersion: ${v}`);
159
+
160
+ // Lift ONE note of ONE class from its own declared version to `to`, one
161
+ // registered step at a time. A missing step OR a missing class entry within a
162
+ // step is a loud error, never a guess; the explicit "unchanged" is the only
163
+ // legal identity, and even it re-stamps the note's schemaVersion line.
164
+ export function applyLadder(content, to, cls) {
165
+ if (!LADDER_CLASSES.includes(cls))
166
+ throw new Error(
167
+ `schema ladder: "${cls}" is not a laddered class (${LADDER_CLASSES.join("|")}) — ` +
168
+ "derived/generated files regenerate at the new schema, they never migrate",
169
+ );
170
+ let from = noteSchemaVersion(content);
171
+ let out = content;
172
+ while (from < to) {
173
+ const step = LADDER[from + 1];
174
+ const t = step?.[cls];
175
+ if (t === undefined)
176
+ throw new Error(
177
+ `schema ladder: no registered ${cls} transform v${from}→v${from + 1} — every step ` +
178
+ 'declares every class (a function, or the EXPLICIT "unchanged"); ' +
179
+ "update @rafinery/cli to a release that ships it (the ladder never guesses a schema)",
180
+ );
181
+ out = t === "unchanged" ? out : t(out);
182
+ out = stampVersion(out, from + 1);
183
+ from += 1;
184
+ }
185
+ return out;
186
+ }
@@ -18,12 +18,19 @@ import { CLI_VERSION } from "./releases.mjs";
18
18
 
19
19
  // The CLI's own actor envelope for state-plane writes (wave 5: REQUIRED on every
20
20
  // loop event — strict, no legacy path). model is the explicit "mechanical"
21
- // sentinel: no LLM ruled here, and the split stays grep-able for sage.
21
+ // sentinel: no LLM ruled here, and the split stays grep-able for sage. The
22
+ // runner honors RAFA_ACTOR_RUNNER first (the platform's sandbox distiller and
23
+ // other tiers export it — sandbox|ci|session), else CI env, else session.
22
24
  export function cliActorMeta() {
25
+ const declared = process.env.RAFA_ACTOR_RUNNER;
23
26
  return {
24
27
  model: "mechanical",
25
28
  agent: `cli@${CLI_VERSION}`,
26
- runner: process.env.CI ? "ci" : "session",
29
+ runner: ["sandbox", "ci", "session"].includes(declared)
30
+ ? declared
31
+ : process.env.CI
32
+ ? "ci"
33
+ : "session",
27
34
  };
28
35
  }
29
36
 
package/lib/push.mjs CHANGED
@@ -189,6 +189,11 @@ export default async function push(args = []) {
189
189
  // on it.
190
190
  const verbArg = args.find((a) => a.startsWith("--verb="));
191
191
  const verb = verbArg ? verbArg.slice("--verb=".length) : "update";
192
+ // Custom subject (owner 2026-07-26: reconciliation commits are DESCRIPTIVE):
193
+ // --message="…" replaces the generic manifest-derived subject; the brain-for
194
+ // trailer machinery below is untouched — provenance consumers keep their key.
195
+ const msgArg = args.find((a) => a.startsWith("--message="));
196
+ const customSubject = msgArg ? msgArg.slice("--message=".length).trim().replace(/"/g, "'") : null;
192
197
  const subjectParts = [];
193
198
  try {
194
199
  const m = JSON.parse(readFileSync(join(rafaDir, "manifest.json"), "utf8"));
@@ -214,9 +219,10 @@ export default async function push(args = []) {
214
219
  } catch {
215
220
  /* stat is garnish */
216
221
  }
217
- const subject = `brain(${verb}): ${
218
- subjectParts.length ? subjectParts.join(" · ") : "state sync"
219
- } · brain-for: ${codeSha}`.replace(/"/g, "'");
222
+ const subject = (
223
+ customSubject ??
224
+ `brain(${verb}): ${subjectParts.length ? subjectParts.join(" · ") : "state sync"} · brain-for: ${codeSha}`
225
+ ).replace(/"/g, "'");
220
226
  try {
221
227
  inRafa(
222
228
  `git commit -m "${subject}" -m "brain-for: ${codeSha}${stagedStat ? ` · ${stagedStat}` : ""} · cli: ${CLI_VERSION}"`,
package/lib/releases.mjs CHANGED
@@ -382,6 +382,33 @@ export const RELEASES = [
382
382
  "--json` carries the harness-neutral agentSkills + skillDeps blocks; " +
383
383
  "doctor gains a capabilities section.",
384
384
  },
385
+ {
386
+ version: "0.11.0",
387
+ contract: 1,
388
+ plans: 2,
389
+ requires: "update",
390
+ summary:
391
+ "SCHEMA-AWARE RECONCILIATION (owner's 4-case doctrine): distillation " +
392
+ "resolves the version lattice BEFORE judging — target below source → " +
393
+ "full target rewrite onto the newer schema (rides the run's gates + " +
394
+ "push); source below target → incoming candidates lift before judging; " +
395
+ "equal-but-old → the merged target rewrites onto the runner's latest; " +
396
+ "equal-and-current → plain diff (today). Either side newer than the " +
397
+ "runner aborts LOUDLY (update the runner; knowledge is never " +
398
+ "downgraded); transforms are registered per step and a missing step " +
399
+ "fails loudly — the ladder never guesses a schema. Also in this " +
400
+ "release (missed 0.10.0's publish): distill-refutation emits carry the " +
401
+ "required actor envelope + dedupeKey (a re-run counts once); " +
402
+ "cliActorMeta honors RAFA_ACTOR_RUNNER (sandbox|ci|session); dependsOn " +
403
+ "invalidation documented NOT transitive with breadth-as-conservative " +
404
+ "taught at facts add/help/prism's card (pinned by test). DESCRIPTIVE " +
405
+ "COMMITS: reconciliation pushes compose reconcile(<branch>): N banked " +
406
+ "· M refuted · K adjudication (rafa push --message= override); the " +
407
+ "commit contract requires an agent-readable body on dev commits. The " +
408
+ "configurable prod branch reaches the CLI: trunkBranchOf (stamp → " +
409
+ "origin/HEAD → main) drives the checkpoint trunk guard + CI distill " +
410
+ "target.",
411
+ },
385
412
  ];
386
413
 
387
414
  // The release this CLI build ships (last entry).
package/lib/stamp.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // stamp against what the running CLI ships to know what changed and what to migrate FROM.
4
4
  // (Also holds platform identity fields when the repo was provisioned via a setup URL.)
5
5
 
6
+ import { execSync } from "node:child_process";
6
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { CURRENT, CLI_VERSION, RELEASES } from "./releases.mjs";
@@ -34,6 +35,29 @@ export function writeStamp(cwd, patch = {}) {
34
35
  return next;
35
36
  }
36
37
 
38
+ // The code repo's canonical (prod) branch (owner 2026-07-26: configurable,
39
+ // never hardcoded). Resolution chain: the rafa.json stamp's `prodBranch`
40
+ // (mirrors the platform's per-repo setting — carried down so the CLI's trunk
41
+ // guards agree with the platform's routing) → git's own origin/HEAD symref
42
+ // (the repo's actual default) → "main".
43
+ export function trunkBranchOf(cwd) {
44
+ const s = readStamp(cwd);
45
+ if (typeof s.prodBranch === "string" && s.prodBranch.trim() !== "")
46
+ return s.prodBranch.trim();
47
+ try {
48
+ const out = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
49
+ cwd,
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ }).trim();
53
+ const m = out.match(/refs\/remotes\/origin\/(.+)$/);
54
+ if (m) return m[1];
55
+ } catch {
56
+ /* no remote / no symref — fall through */
57
+ }
58
+ return "main";
59
+ }
60
+
37
61
  // The versions this repo was last stamped at. Un-stamped repos predate the stamp (added in
38
62
  // 0.2.0), so default to the FIRST release's schema — never raise a false migration alarm.
39
63
  export function stampedVersions(cwd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-07-25T18:10:32.629Z",
2
+ "generatedAt": "2026-07-25T21:14:46.864Z",
3
3
  "skills": {
4
4
  "tdd": {
5
5
  "version": "1.0.0",