@rafinery/cli 0.3.0 → 0.4.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +17 -7
  3. package/bin/rafa.mjs +68 -20
  4. package/blueprint/.claude/agents/atlas.md +28 -20
  5. package/blueprint/.claude/agents/bloom.md +15 -8
  6. package/blueprint/.claude/agents/compass.md +46 -0
  7. package/blueprint/.claude/agents/prism.md +42 -25
  8. package/blueprint/.claude/commands/rafa.md +151 -26
  9. package/blueprint/{.rafa → .claude/rafa}/contract.md +108 -22
  10. package/blueprint/.claude/skills/rafa-build/SKILL.md +76 -0
  11. package/blueprint/.claude/skills/rafa-distill/SKILL.md +87 -0
  12. package/blueprint/{.rafa/capabilities/improve.md → .claude/skills/rafa-improve/SKILL.md} +10 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +71 -0
  14. package/blueprint/{.rafa/capabilities/leverage.md → .claude/skills/rafa-leverage/SKILL.md} +9 -4
  15. package/blueprint/{.rafa/capabilities/plan.md → .claude/skills/rafa-plan/SKILL.md} +30 -3
  16. package/blueprint/{.rafa/capabilities/scan.md → .claude/skills/rafa-scan/SKILL.md} +14 -9
  17. package/blueprint/{.rafa/capabilities/validate.md → .claude/skills/rafa-validate/SKILL.md} +14 -5
  18. package/lib/blueprint.mjs +22 -12
  19. package/lib/brain-repo.mjs +100 -0
  20. package/lib/checkpoint.mjs +138 -0
  21. package/lib/ci-setup.mjs +109 -0
  22. package/lib/claude-config.mjs +5 -5
  23. package/lib/compile.mjs +6 -21
  24. package/lib/distill.mjs +237 -0
  25. package/lib/fold.mjs +53 -0
  26. package/lib/gate/compile.mjs +549 -0
  27. package/lib/gate/verify-citations.mjs +156 -0
  28. package/lib/hydrate.mjs +96 -0
  29. package/lib/init.mjs +15 -30
  30. package/lib/leverage/adapters/claude-code.mjs +5 -4
  31. package/lib/mcp-client.mjs +97 -0
  32. package/lib/mcp-config.mjs +1 -1
  33. package/lib/migrations/index.mjs +39 -3
  34. package/lib/pull.mjs +129 -0
  35. package/lib/push.mjs +93 -14
  36. package/lib/releases.mjs +33 -0
  37. package/lib/verify-citations.mjs +9 -0
  38. package/lib/working-set.mjs +113 -0
  39. package/package.json +2 -2
  40. package/blueprint/.rafa/bin/rafa-compile.mjs +0 -478
  41. package/blueprint/.rafa/bin/rafa-push.mjs +0 -75
  42. package/blueprint/.rafa/bin/verify-citations.mjs +0 -153
  43. package/blueprint/.rafa/capabilities/build.md +0 -39
@@ -0,0 +1,549 @@
1
+ // The contract gate — the deterministic compiler between the agents' markdown and
2
+ // the platform. Lives INSIDE @rafinery/cli (blueprint split, 0.4.0): the gate moves
3
+ // with the CLI version, never vendored. Reads every brain file, parses frontmatter
4
+ // with a STRICT grammar (contract §0), validates against the contract, and on
5
+ // success emits .rafa/manifest.json — the exact JSON the platform ingests.
6
+ //
7
+ // NO ASSUMPTIONS: a missing required field, a bad enum, or a malformed cite is a
8
+ // hard error (path · field · rule). It never fills a default for a required field.
9
+ // Returns exit code 1 with a structured error list so the authoring agent can
10
+ // correct + retry.
11
+ //
12
+ // CLI: rafa compile [--repo=owner/repo] [--branch=main] [--codeSha=<sha>] [--root=.rafa]
13
+
14
+ import {
15
+ readFileSync,
16
+ writeFileSync,
17
+ readdirSync,
18
+ existsSync,
19
+ statSync,
20
+ } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { execSync } from "node:child_process";
23
+
24
+ const SCHEMA_VERSION = 1;
25
+
26
+ // ── strict frontmatter parser (contract §0 grammar) ─────────────────────────
27
+ // Legal value forms only: scalar | "quoted" | [flow,list] | { flow: map }.
28
+ // Plus `key:` followed by ` - item` block lists and folded scalars. Anything
29
+ // else → error.
30
+ function parseScalar(raw) {
31
+ const s = raw.trim();
32
+ if (s === "") return "";
33
+ if (/^".*"$/.test(s) || /^'.*'$/.test(s)) return s.slice(1, -1);
34
+ if (s === "true") return true;
35
+ if (s === "false") return false;
36
+ if (/^-?\d+$/.test(s)) return Number(s);
37
+ return s;
38
+ }
39
+ // Strip a trailing YAML comment ( whitespace + # … ) from an UNQUOTED top-level
40
+ // scalar. Not applied to block-list items (cites/links are exact strings that may
41
+ // legitimately contain `#`).
42
+ function stripComment(s) {
43
+ if (/^["']/.test(s.trim())) return s;
44
+ const i = s.search(/\s#/);
45
+ return i === -1 ? s : s.slice(0, i);
46
+ }
47
+ function parseFlowList(raw) {
48
+ const inner = raw.trim().slice(1, -1).trim();
49
+ if (inner === "") return [];
50
+ return inner.split(",").map((x) => parseScalar(x));
51
+ }
52
+ function parseFlowMap(raw) {
53
+ const inner = raw.trim().slice(1, -1).trim();
54
+ const out = {};
55
+ if (inner === "") return out;
56
+ for (const pair of inner.split(",")) {
57
+ const i = pair.indexOf(":");
58
+ if (i === -1) return null; // malformed
59
+ out[pair.slice(0, i).trim()] = parseScalar(pair.slice(i + 1));
60
+ }
61
+ return out;
62
+ }
63
+ // Returns { data } or { error: "reason" }.
64
+ export function parseFrontmatter(text) {
65
+ if (!text.startsWith("---")) return { error: "no frontmatter block" };
66
+ const end = text.indexOf("\n---", text.indexOf("\n"));
67
+ if (end === -1) return { error: "unterminated frontmatter" };
68
+ const lines = text.slice(text.indexOf("\n") + 1, end).split("\n");
69
+ const data = {};
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const line = lines[i];
72
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
73
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):(.*)$/);
74
+ if (!m) return { error: `illegal line: ${JSON.stringify(line)}` };
75
+ const key = m[1];
76
+ // Strip a trailing comment first, so `cites: # note` reads as an empty value
77
+ // (block list follows), not as the comment being the value.
78
+ const rest = stripComment(m[2]).trim();
79
+ if (rest === "") {
80
+ // block list follows
81
+ const items = [];
82
+ while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
83
+ items.push(parseScalar(lines[++i].replace(/^\s*-\s+/, "")));
84
+ }
85
+ data[key] = items;
86
+ } else if (rest === ">-" || rest === ">") {
87
+ // folded scalar (contract §0): indented continuation lines join with one space
88
+ const parts = [];
89
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
90
+ parts.push(lines[++i].trim());
91
+ }
92
+ data[key] = parts.join(" ");
93
+ } else if (rest.startsWith("[")) {
94
+ const close = rest.lastIndexOf("]"); // ignore any trailing comment after ]
95
+ if (close === -1) return { error: `unclosed flow list at ${key}` };
96
+ data[key] = parseFlowList(rest.slice(0, close + 1));
97
+ } else if (rest.startsWith("{")) {
98
+ const close = rest.lastIndexOf("}");
99
+ if (close === -1) return { error: `unclosed flow map at ${key}` };
100
+ const fm = parseFlowMap(rest.slice(0, close + 1));
101
+ if (fm === null) return { error: `malformed flow map at ${key}` };
102
+ data[key] = fm;
103
+ } else {
104
+ data[key] = parseScalar(rest);
105
+ }
106
+ }
107
+ return { data };
108
+ }
109
+
110
+ const CITE_RE = /^(.+):(\d+(?:-\d+)?)\s*::\s*(.+)$/;
111
+ const DUTY_RE = /^(\S[^:]*?)\s+::\s+(\S+)\s+::\s+(\S.*)$/;
112
+
113
+ export function runCompile(argv = []) {
114
+ const arg = (k, d) => {
115
+ const m = argv.find((a) => a.startsWith(`--${k}=`));
116
+ return m ? m.slice(k.length + 3) : d;
117
+ };
118
+ const ROOT = arg("root", ".rafa");
119
+ // Paths in the manifest are relative to the brain-repo root (`.rafa/` IS the root),
120
+ // so the platform can fetch a prose body straight from the brain repo.
121
+ const rel = (p) => (p.startsWith(ROOT + "/") ? p.slice(ROOT.length + 1) : p);
122
+
123
+ const errors = [];
124
+ const fail = (path, field, rule) => errors.push({ path, field, rule });
125
+
126
+ // ── validators (contract §2–§6) ────────────────────────────────────────────
127
+ function parseCites(list, path) {
128
+ if (!Array.isArray(list) || list.length === 0) {
129
+ fail(path, "cites", "required · ≥ 1");
130
+ return [];
131
+ }
132
+ const out = [];
133
+ for (const c of list) {
134
+ const m = String(c).match(CITE_RE);
135
+ if (!m) {
136
+ fail(path, "cites", `malformed cite: ${JSON.stringify(c)}`);
137
+ continue;
138
+ }
139
+ out.push({ file: m[1].trim(), line: m[2], token: m[3].trim() });
140
+ }
141
+ return out;
142
+ }
143
+ const isEnum = (v, allowed) => allowed.includes(v);
144
+ function reqStr(d, key, path) {
145
+ const v = d[key];
146
+ if (typeof v !== "string" || v.trim() === "") {
147
+ fail(path, key, "required · non-empty string");
148
+ return "";
149
+ }
150
+ return v;
151
+ }
152
+ function reqEnum(d, key, allowed, path) {
153
+ const v = d[key];
154
+ if (!isEnum(v, allowed)) {
155
+ fail(path, key, `required · one of ${allowed.join("|")}`);
156
+ return allowed[0];
157
+ }
158
+ return v;
159
+ }
160
+ function checkVersion(d, path) {
161
+ if (d.schemaVersion !== SCHEMA_VERSION)
162
+ fail(path, "schemaVersion", `must be ${SCHEMA_VERSION}`);
163
+ }
164
+ function checkId(d, path, name) {
165
+ const stem = name.replace(/\.md$/, "");
166
+ if (d.id !== stem) fail(path, "id", `must equal filename stem "${stem}"`);
167
+ return stem;
168
+ }
169
+
170
+ function walk(dir) {
171
+ if (!existsSync(dir)) return [];
172
+ return readdirSync(dir).flatMap((e) => {
173
+ const p = join(dir, e);
174
+ return statSync(p).isDirectory()
175
+ ? walk(p)
176
+ : e.endsWith(".md") && !e.startsWith("_")
177
+ ? [p]
178
+ : [];
179
+ });
180
+ }
181
+ const read = (p) => readFileSync(p, "utf8");
182
+
183
+ function compileNotes(kind, dir) {
184
+ const notes = [];
185
+ for (const path of walk(dir)) {
186
+ const name = path.split("/").pop();
187
+ const { data, error } = parseFrontmatter(read(path));
188
+ if (error) {
189
+ fail(path, "frontmatter", error);
190
+ continue;
191
+ }
192
+ checkVersion(data, path);
193
+ const id = checkId(data, path, name);
194
+ notes.push({
195
+ id,
196
+ kind,
197
+ type: reqEnum(data, "type", ["contract", "convention", "flow", "how-to"], path),
198
+ domain: reqStr(data, "domain", path),
199
+ title: reqStr(data, "title", path),
200
+ summary: reqStr(data, "summary", path),
201
+ links: Array.isArray(data.links) ? data.links.map(String) : [],
202
+ ...(data.failure === "silent" || data.failure === "loud"
203
+ ? { failure: data.failure }
204
+ : {}),
205
+ cites: parseCites(data.cites, path),
206
+ path: rel(path),
207
+ });
208
+ }
209
+ return notes;
210
+ }
211
+
212
+ function compileImprovements(dir) {
213
+ const out = [];
214
+ for (const path of walk(dir)) {
215
+ const name = path.split("/").pop();
216
+ const { data, error } = parseFrontmatter(read(path));
217
+ if (error) {
218
+ fail(path, "frontmatter", error);
219
+ continue;
220
+ }
221
+ checkVersion(data, path);
222
+ const id = checkId(data, path, name);
223
+ const lev = data.leverage;
224
+ if (
225
+ typeof lev !== "object" ||
226
+ !isEnum(lev?.impact, ["low", "medium", "high"]) ||
227
+ !isEnum(lev?.effort, ["low", "medium", "high"])
228
+ )
229
+ fail(path, "leverage", "required · { impact, effort } ∈ low|medium|high");
230
+ out.push({
231
+ id,
232
+ priority: reqEnum(data, "priority", ["P0", "P1", "P2", "P3"], path),
233
+ lens: reqEnum(
234
+ data,
235
+ "lens",
236
+ ["security", "correctness", "performance", "architecture", "product", "ops"],
237
+ path,
238
+ ),
239
+ status: reqEnum(data, "status", ["open", "backlog", "fixed", "wontfix"], path),
240
+ title: reqStr(data, "title", path),
241
+ summary: reqStr(data, "summary", path),
242
+ fix: reqStr(data, "fix", path),
243
+ leverage: {
244
+ impact: lev?.impact ?? "low",
245
+ effort: lev?.effort ?? "low",
246
+ },
247
+ blastRadius: Array.isArray(data.blast_radius)
248
+ ? data.blast_radius.map(String)
249
+ : [],
250
+ cites: parseCites(data.cites, path),
251
+ path: rel(path),
252
+ });
253
+ }
254
+ return out;
255
+ }
256
+
257
+ function compileHealth() {
258
+ const path = join(ROOT, "brain", "checklist.md");
259
+ if (!existsSync(path)) return null;
260
+ const { data, error } = parseFrontmatter(read(path));
261
+ if (error) {
262
+ fail(path, "frontmatter", error);
263
+ return null;
264
+ }
265
+ checkVersion(data, path);
266
+ const g = data.gates,
267
+ c = data.counts;
268
+ if (!isEnum(g?.fidelity, ["pass", "fail"]) || !isEnum(g?.coverage, ["pass", "fail"]))
269
+ fail(path, "gates", "required · { fidelity, coverage } ∈ pass|fail");
270
+ if (
271
+ typeof c?.blockers !== "number" ||
272
+ typeof c?.majors !== "number" ||
273
+ typeof c?.minors !== "number"
274
+ )
275
+ fail(path, "counts", "required · { blockers, majors, minors } ints");
276
+ if (typeof data.score !== "number") fail(path, "score", "required · 0–100 int");
277
+ return {
278
+ verdict: reqEnum(data, "verdict", ["PASS", "ITERATE"], path),
279
+ score: typeof data.score === "number" ? data.score : 0,
280
+ gates: { fidelity: g?.fidelity ?? "fail", coverage: g?.coverage ?? "fail" },
281
+ counts: {
282
+ blockers: c?.blockers ?? 0,
283
+ majors: c?.majors ?? 0,
284
+ minors: c?.minors ?? 0,
285
+ },
286
+ };
287
+ }
288
+
289
+ function compileLedger() {
290
+ const path = join(ROOT, "improve", "ledger.md");
291
+ if (!existsSync(path)) return null;
292
+ const { data, error } = parseFrontmatter(read(path));
293
+ if (error) {
294
+ fail(path, "frontmatter", error);
295
+ return null;
296
+ }
297
+ checkVersion(data, path);
298
+ const bp = data.by_priority;
299
+ const okBp =
300
+ bp && ["P0", "P1", "P2", "P3"].every((k) => typeof bp[k] === "number");
301
+ if (!okBp) fail(path, "by_priority", "required · { P0, P1, P2, P3 } ints");
302
+ if (typeof data.open !== "number") fail(path, "open", "required · int");
303
+ if (typeof data.debt_score !== "number")
304
+ fail(path, "debt_score", "required · int");
305
+ return {
306
+ open: typeof data.open === "number" ? data.open : 0,
307
+ debtScore: typeof data.debt_score === "number" ? data.debt_score : 0,
308
+ byPriority: okBp
309
+ ? { P0: bp.P0, P1: bp.P1, P2: bp.P2, P3: bp.P3 }
310
+ : { P0: 0, P1: 0, P2: 0, P3: 0 },
311
+ };
312
+ }
313
+
314
+ function compileCoverage() {
315
+ const path = join(ROOT, "brain", "coverage.md");
316
+ if (!existsSync(path)) return null;
317
+ const { data, error } = parseFrontmatter(read(path));
318
+ if (error) {
319
+ fail(path, "frontmatter", error);
320
+ return null;
321
+ }
322
+ checkVersion(data, path);
323
+ const d = data.domains;
324
+ if (typeof d !== "object" || Array.isArray(d)) {
325
+ fail(path, "domains", "required · { domain: status } flow map");
326
+ return { domains: [] };
327
+ }
328
+ const STATUS = ["mapped", "thin", "stubbed", "empty"];
329
+ const domains = [];
330
+ for (const [domain, status] of Object.entries(d)) {
331
+ if (!STATUS.includes(status))
332
+ fail(path, `domains.${domain}`, `status ∈ ${STATUS.join("|")}`);
333
+ domains.push({ domain, status: String(status) });
334
+ }
335
+ return { domains };
336
+ }
337
+
338
+ // Plans (contract §7): parent+child files under plans/**. Global id uniqueness,
339
+ // parent/child cross-checks, progress never stored, active.md → activePlanId.
340
+ // VALIDATED here (files are the authoring surface — determinism holds) but
341
+ // since 0.4.0 plans do NOT ride the manifest: they travel through the
342
+ // dedicated push-on-approval plans channel (push_plan). Terminal statuses
343
+ // (superseded/abandoned) close a plan honestly without pretending `done`.
344
+ const PLAN_STATUSES = [
345
+ "todo",
346
+ "in-progress",
347
+ "done",
348
+ "blocked",
349
+ "superseded",
350
+ "abandoned",
351
+ ];
352
+ function compilePlans() {
353
+ const plans = [];
354
+ const seen = new Map(); // id → path (global uniqueness across plans/**)
355
+ for (const path of walk(join(ROOT, "plans"))) {
356
+ const name = path.split("/").pop();
357
+ const { data, error } = parseFrontmatter(read(path));
358
+ if (error) {
359
+ fail(path, "frontmatter", error);
360
+ continue;
361
+ }
362
+ checkVersion(data, path);
363
+ const id = checkId(data, path, name);
364
+ if (seen.has(id))
365
+ fail(path, "id", `duplicate plan id "${id}" (also ${seen.get(id)}) · ids are globally unique across plans/**`);
366
+ seen.set(id, path);
367
+ if ("progress" in data)
368
+ fail(path, "progress", "never stored · parent progress is derived by counting children");
369
+ const kind = reqEnum(data, "kind", ["parent", "child"], path);
370
+ const plan = reqStr(data, "plan", path);
371
+ const status = data.status;
372
+ if (kind === "child") {
373
+ if (!isEnum(status, PLAN_STATUSES))
374
+ fail(path, "status", `required (child) · ${PLAN_STATUSES.join("|")}`);
375
+ if (data.parent !== plan)
376
+ fail(path, "parent", `must equal plan "${plan}" for a child (flat v1)`);
377
+ } else {
378
+ if (data.parent !== null && data.parent !== "null")
379
+ fail(path, "parent", "must be null for kind: parent");
380
+ if (plan !== id) fail(path, "plan", `must equal id "${id}" for kind: parent`);
381
+ if (status !== undefined && !isEnum(status, PLAN_STATUSES))
382
+ fail(path, "status", `optional (parent) · ${PLAN_STATUSES.join("|")}`);
383
+ }
384
+ plans.push({
385
+ id,
386
+ plan,
387
+ parent: kind === "parent" ? null : plan,
388
+ kind,
389
+ title: reqStr(data, "title", path),
390
+ ...(isEnum(status, PLAN_STATUSES) ? { status } : {}),
391
+ ...(typeof data.branch === "string" && data.branch !== ""
392
+ ? { branch: data.branch }
393
+ : {}),
394
+ ...(typeof data.baseSha === "string" && data.baseSha !== ""
395
+ ? { baseSha: String(data.baseSha) }
396
+ : {}),
397
+ path: rel(path),
398
+ });
399
+ }
400
+ // Cross-check: every child's plan resolves to a kind:parent in this compile.
401
+ const parents = new Set(plans.filter((p) => p.kind === "parent").map((p) => p.id));
402
+ for (const p of plans)
403
+ if (p.kind === "child" && !parents.has(p.plan))
404
+ fail(p.path, "plan", `no parent plan "${p.plan}" in this compile (dangling child)`);
405
+ return plans;
406
+ }
407
+
408
+ // active.md (generated pointer) → activePlanId. First line `# <plan-id>` must
409
+ // resolve to a kind:parent plan; `# No active plan` or a missing file → null
410
+ // (documented default: the pointer is generated, absence means "none").
411
+ function compileActivePlanId(plans) {
412
+ const path = join(ROOT, "active.md");
413
+ if (!existsSync(path)) return null;
414
+ const first = read(path).split("\n")[0].trim();
415
+ const m = first.match(/^#\s+(.+)$/);
416
+ if (!m) {
417
+ fail(path, "pointer", 'first line must be "# <plan-id>" or "# No active plan"');
418
+ return null;
419
+ }
420
+ const val = m[1].trim();
421
+ if (/^no active plan$/i.test(val)) return null;
422
+ if (!plans.some((p) => p.kind === "parent" && p.id === val)) {
423
+ fail(path, "pointer", `active plan "${val}" is not a kind:parent plan in this compile`);
424
+ return null;
425
+ }
426
+ return val;
427
+ }
428
+
429
+ // Agent cards (contract §10): the shipped agents are contract-governed — validated
430
+ // as a LOCAL gate, never emitted into the manifest. Cards live in the CODE repo at
431
+ // .claude/agents/*.md. Duty SOP paths resolve against the code-repo root
432
+ // (.claude/skills/rafa-*/SKILL.md); legacy `.rafa/`-relative paths still resolve
433
+ // during the migration window.
434
+ function compileAgentCards() {
435
+ const repoRoot = join(ROOT, "..");
436
+ const dir = join(repoRoot, ".claude", "agents");
437
+ let count = 0;
438
+ for (const path of walk(dir)) {
439
+ const name = path.split("/").pop();
440
+ const { data, error } = parseFrontmatter(read(path));
441
+ if (error) {
442
+ fail(path, "frontmatter", error);
443
+ continue;
444
+ }
445
+ count++;
446
+ const stem = name.replace(/\.md$/, "");
447
+ if (data.name !== stem) fail(path, "name", `must equal filename stem "${stem}"`);
448
+ if (!/^\d+\.\d+\.\d+$/.test(String(data.version ?? "")))
449
+ fail(path, "version", "required · semver MAJOR.MINOR.PATCH");
450
+ reqStr(data, "model", path);
451
+ reqStr(data, "description", path);
452
+ reqStr(data, "tools", path);
453
+ reqEnum(
454
+ data,
455
+ "groundTruth",
456
+ ["code-at-sha", "code-vs-claim", "code-trend", "sessions-over-time"],
457
+ path,
458
+ );
459
+ const duties = data.duties;
460
+ if (!Array.isArray(duties) || duties.length === 0) {
461
+ fail(path, "duties", "required · block list of ≥ 1 duty DSL strings");
462
+ continue;
463
+ }
464
+ for (const d of duties) {
465
+ const m = String(d).match(DUTY_RE);
466
+ if (!m) {
467
+ fail(path, "duties", `malformed duty (want "<duty> :: <sop> :: <bar>"): ${JSON.stringify(d)}`);
468
+ continue;
469
+ }
470
+ const sop = m[2];
471
+ if (!existsSync(join(repoRoot, sop)) && !existsSync(join(ROOT, sop)))
472
+ fail(path, "duties", `sop does not resolve: ${sop} (a duty without a procedure is a claim)`);
473
+ }
474
+ }
475
+ return count;
476
+ }
477
+
478
+ // ── assemble ─────────────────────────────────────────────────────────────
479
+ const gitOpts = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }; // no stderr leak
480
+ function gitSha() {
481
+ try {
482
+ return execSync("git rev-parse HEAD", gitOpts).trim();
483
+ } catch {
484
+ return arg("codeSha", "");
485
+ }
486
+ }
487
+ function gitBranch() {
488
+ try {
489
+ return execSync("git rev-parse --abbrev-ref HEAD", gitOpts).trim();
490
+ } catch {
491
+ return arg("branch", "main");
492
+ }
493
+ }
494
+
495
+ const notes = [
496
+ ...compileNotes("rule", join(ROOT, "brain", "rules")),
497
+ ...compileNotes("playbook", join(ROOT, "brain", "playbooks")),
498
+ ];
499
+ const improvements = compileImprovements(join(ROOT, "improve", "improvements"));
500
+ const health = compileHealth();
501
+ const ledger = compileLedger();
502
+ const coverage = compileCoverage();
503
+ const plans = compilePlans();
504
+ const activePlanId = compileActivePlanId(plans);
505
+ const agentCards = compileAgentCards();
506
+
507
+ // Cross-check: ledger.open / by_priority must match the actual open improvements.
508
+ if (ledger) {
509
+ const open = improvements.filter((i) => i.status === "open");
510
+ const bp = { P0: 0, P1: 0, P2: 0, P3: 0 };
511
+ for (const i of open) bp[i.priority]++;
512
+ if (ledger.open !== open.length)
513
+ fail("improve/ledger.md", "open", `says ${ledger.open}, rows have ${open.length}`);
514
+ for (const k of ["P0", "P1", "P2", "P3"])
515
+ if (ledger.byPriority[k] !== bp[k])
516
+ fail("improve/ledger.md", `by_priority.${k}`, `says ${ledger.byPriority[k]}, rows have ${bp[k]}`);
517
+ }
518
+
519
+ if (errors.length) {
520
+ console.error(`✗ rafa compile: ${errors.length} contract violation(s)\n`);
521
+ for (const e of errors) console.error(` ${e.path} · ${e.field} · ${e.rule}`);
522
+ console.error(`\nFix these files to match the contract (.claude/rafa/contract.md), then re-run compile.`);
523
+ return 1;
524
+ }
525
+
526
+ // The manifest carries KNOWLEDGE only (plans channel, 0.4.0): plans + the
527
+ // active pointer are validated above but travel through push_plan, never here.
528
+ const manifest = {
529
+ schemaVersion: SCHEMA_VERSION,
530
+ generatedAt: new Date().toISOString(),
531
+ repo: arg("repo", ""),
532
+ branch: gitBranch(),
533
+ codeSha: gitSha(),
534
+ health,
535
+ ledger,
536
+ coverage,
537
+ notes,
538
+ improvements,
539
+ };
540
+ writeFileSync(join(ROOT, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
541
+ console.log(
542
+ `✓ rafa compile: ${notes.length} notes · ${improvements.length} improvements · ` +
543
+ `${plans.length} plans validated${activePlanId ? ` (active: ${activePlanId})` : ""} (plans travel via the plans channel, not the manifest) · ` +
544
+ `health ${health ? "ok" : "—"} · ledger ${ledger ? "ok" : "—"} · ` +
545
+ `coverage ${coverage ? `${coverage.domains.length} domains` : "—"} · ` +
546
+ `${agentCards} agent cards (local gate) → ${ROOT}/manifest.json`,
547
+ );
548
+ return 0;
549
+ }