@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 +49 -0
- package/blueprint/.claude/rafa/contract.md +17 -0
- package/blueprint/.claude/skills/rafa-commit/SKILL.md +22 -0
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +37 -4
- package/lib/checkpoint.mjs +14 -5
- package/lib/distill.mjs +56 -3
- package/lib/distiller/schema-ladder.mjs +186 -0
- package/lib/mcp-client.mjs +9 -2
- package/lib/push.mjs +9 -3
- package/lib/releases.mjs +27 -0
- package/lib/stamp.mjs +24 -0
- package/package.json +1 -1
- package/skills-bundle/skills-manifest.json +1 -1
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
package/lib/checkpoint.mjs
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 {
|
|
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 ||
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/mcp-client.mjs
CHANGED
|
@@ -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:
|
|
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 =
|
|
218
|
-
|
|
219
|
-
|
|
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