@rafinery/cli 0.5.0 → 0.7.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rafa.mjs +37 -5
  3. package/blueprint/.claude/agents/atlas.md +2 -1
  4. package/blueprint/.claude/agents/sage.md +66 -0
  5. package/blueprint/.claude/commands/rafa.md +214 -269
  6. package/blueprint/.claude/rafa/contract.md +204 -115
  7. package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
  8. package/blueprint/.claude/rafa/hooks/pre-push +24 -0
  9. package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
  10. package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
  11. package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
  12. package/blueprint/.claude/skills/rafa-build/SKILL.md +20 -5
  13. package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
  15. package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
  16. package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
  17. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  18. package/lib/benchmark.mjs +573 -0
  19. package/lib/blueprint.mjs +11 -1
  20. package/lib/brain-repo.mjs +10 -4
  21. package/lib/ci-setup.mjs +2 -0
  22. package/lib/claude-config.mjs +77 -0
  23. package/lib/dirty.mjs +114 -0
  24. package/lib/distill.mjs +4 -0
  25. package/lib/gate/compile.mjs +293 -44
  26. package/lib/gate/verify-citations.mjs +214 -23
  27. package/lib/githook.mjs +54 -0
  28. package/lib/init.mjs +18 -0
  29. package/lib/pull.mjs +7 -0
  30. package/lib/push.mjs +21 -0
  31. package/lib/reflex.mjs +76 -0
  32. package/lib/releases.mjs +35 -0
  33. package/lib/status.mjs +152 -0
  34. package/lib/update.mjs +13 -0
  35. package/package.json +1 -1
@@ -119,6 +119,41 @@ export function runCompile(argv = []) {
119
119
  // Paths in the manifest are relative to the brain-repo root (`.rafa/` IS the root),
120
120
  // so the platform can fetch a prose body straight from the brain repo.
121
121
  const rel = (p) => (p.startsWith(ROOT + "/") ? p.slice(ROOT.length + 1) : p);
122
+ // The code repo root — cite `file`s are relative to it (same anchor the checker
123
+ // resolves against). `.rafa/` sits inside the code repo, so `..` reaches the root.
124
+ const repoRoot = join(ROOT, "..");
125
+
126
+ // ── deterministic size estimation (recall-savings, additive optional) ────────
127
+ // Feeds the platform's recall-savings estimator: a note's token cost and each
128
+ // cited file's token cost, so it can price "read the note" vs "re-read the code".
129
+ // Estimate = ceil(chars / 4) — the ~4-bytes-per-token rule of thumb for English
130
+ // source/prose. DETERMINISTIC: pure function of the file bytes, no LLM, no network.
131
+ // These are ADDITIVE OPTIONAL manifest fields (contract §1) — a note without them
132
+ // still compiles and ingest tolerates their absence; a target we cannot read is
133
+ // OMITTED, never defaulted to a guessed 0 (contract §0 / compile's own law above).
134
+ const estimateTokens = (s) => Math.ceil(s.length / 4);
135
+ // The prose body = everything after the closing `---` fence (never parsed, only sized).
136
+ function noteBody(text) {
137
+ if (!text.startsWith("---")) return text;
138
+ const end = text.indexOf("\n---", text.indexOf("\n"));
139
+ if (end === -1) return "";
140
+ const nl = text.indexOf("\n", end + 1); // newline after the closing `---` line
141
+ return nl === -1 ? "" : text.slice(nl + 1);
142
+ }
143
+ // Cited-file token estimate, cached per path (a file is cited by many notes).
144
+ // null = unreadable at compile time → the caller OMITS the field (honest absence).
145
+ const citeTokenCache = new Map();
146
+ function citeTargetTokens(file) {
147
+ if (citeTokenCache.has(file)) return citeTokenCache.get(file);
148
+ let tokens = null;
149
+ try {
150
+ tokens = estimateTokens(readFileSync(join(repoRoot, file), "utf8"));
151
+ } catch {
152
+ tokens = null; // missing/unreadable → omit, never guess
153
+ }
154
+ citeTokenCache.set(file, tokens);
155
+ return tokens;
156
+ }
122
157
 
123
158
  const errors = [];
124
159
  const fail = (path, field, rule) => errors.push({ path, field, rule });
@@ -184,17 +219,38 @@ export function runCompile(argv = []) {
184
219
  const notes = [];
185
220
  for (const path of walk(dir)) {
186
221
  const name = path.split("/").pop();
187
- const { data, error } = parseFrontmatter(read(path));
222
+ const raw = read(path);
223
+ const { data, error } = parseFrontmatter(raw);
188
224
  if (error) {
189
225
  fail(path, "frontmatter", error);
190
226
  continue;
191
227
  }
192
228
  checkVersion(data, path);
193
229
  const id = checkId(data, path, name);
230
+ // `anchor:` / `absent:` are checker-side declarations (verify-citations gates
231
+ // B2/B3 on them) — optional here, but if present they must be non-empty.
232
+ for (const key of ["anchor", "absent"])
233
+ if (
234
+ key in data &&
235
+ (typeof data[key] !== "string" || data[key].trim() === "")
236
+ )
237
+ fail(path, key, "optional · non-empty token (or `none`)");
238
+ // Size stamps (additive optional): body cost + per-cite target-file cost. A
239
+ // target we can't read → the cite simply carries no `targetTokens` (omitted).
240
+ const cites = parseCites(data.cites, path);
241
+ for (const c of cites) {
242
+ const t = citeTargetTokens(c.file);
243
+ if (t !== null) c.targetTokens = t;
244
+ }
194
245
  notes.push({
195
246
  id,
196
247
  kind,
197
- type: reqEnum(data, "type", ["contract", "convention", "flow", "how-to"], path),
248
+ type: reqEnum(
249
+ data,
250
+ "type",
251
+ ["contract", "convention", "flow", "how-to"],
252
+ path,
253
+ ),
198
254
  domain: reqStr(data, "domain", path),
199
255
  title: reqStr(data, "title", path),
200
256
  summary: reqStr(data, "summary", path),
@@ -202,7 +258,8 @@ export function runCompile(argv = []) {
202
258
  ...(data.failure === "silent" || data.failure === "loud"
203
259
  ? { failure: data.failure }
204
260
  : {}),
205
- cites: parseCites(data.cites, path),
261
+ bodyTokens: estimateTokens(noteBody(raw)),
262
+ cites,
206
263
  path: rel(path),
207
264
  });
208
265
  }
@@ -226,17 +283,33 @@ export function runCompile(argv = []) {
226
283
  !isEnum(lev?.impact, ["low", "medium", "high"]) ||
227
284
  !isEnum(lev?.effort, ["low", "medium", "high"])
228
285
  )
229
- fail(path, "leverage", "required · { impact, effort } ∈ low|medium|high");
286
+ fail(
287
+ path,
288
+ "leverage",
289
+ "required · { impact, effort } ∈ low|medium|high",
290
+ );
230
291
  out.push({
231
292
  id,
232
293
  priority: reqEnum(data, "priority", ["P0", "P1", "P2", "P3"], path),
233
294
  lens: reqEnum(
234
295
  data,
235
296
  "lens",
236
- ["security", "correctness", "performance", "architecture", "product", "ops"],
297
+ [
298
+ "security",
299
+ "correctness",
300
+ "performance",
301
+ "architecture",
302
+ "product",
303
+ "ops",
304
+ ],
305
+ path,
306
+ ),
307
+ status: reqEnum(
308
+ data,
309
+ "status",
310
+ ["open", "backlog", "fixed", "wontfix"],
237
311
  path,
238
312
  ),
239
- status: reqEnum(data, "status", ["open", "backlog", "fixed", "wontfix"], path),
240
313
  title: reqStr(data, "title", path),
241
314
  summary: reqStr(data, "summary", path),
242
315
  fix: reqStr(data, "fix", path),
@@ -265,7 +338,10 @@ export function runCompile(argv = []) {
265
338
  checkVersion(data, path);
266
339
  const g = data.gates,
267
340
  c = data.counts;
268
- if (!isEnum(g?.fidelity, ["pass", "fail"]) || !isEnum(g?.coverage, ["pass", "fail"]))
341
+ if (
342
+ !isEnum(g?.fidelity, ["pass", "fail"]) ||
343
+ !isEnum(g?.coverage, ["pass", "fail"])
344
+ )
269
345
  fail(path, "gates", "required · { fidelity, coverage } ∈ pass|fail");
270
346
  if (
271
347
  typeof c?.blockers !== "number" ||
@@ -273,11 +349,15 @@ export function runCompile(argv = []) {
273
349
  typeof c?.minors !== "number"
274
350
  )
275
351
  fail(path, "counts", "required · { blockers, majors, minors } ints");
276
- if (typeof data.score !== "number") fail(path, "score", "required · 0–100 int");
352
+ if (typeof data.score !== "number")
353
+ fail(path, "score", "required · 0–100 int");
277
354
  return {
278
355
  verdict: reqEnum(data, "verdict", ["PASS", "ITERATE"], path),
279
356
  score: typeof data.score === "number" ? data.score : 0,
280
- gates: { fidelity: g?.fidelity ?? "fail", coverage: g?.coverage ?? "fail" },
357
+ gates: {
358
+ fidelity: g?.fidelity ?? "fail",
359
+ coverage: g?.coverage ?? "fail",
360
+ },
281
361
  counts: {
282
362
  blockers: c?.blockers ?? 0,
283
363
  majors: c?.majors ?? 0,
@@ -332,15 +412,70 @@ export function runCompile(argv = []) {
332
412
  fail(path, `domains.${domain}`, `status ∈ ${STATUS.join("|")}`);
333
413
  domains.push({ domain, status: String(status) });
334
414
  }
415
+ // Optional `inventory:` block list — `<name> :: <glob> :: <count>` per entry.
416
+ // Checker-side (verify-citations recomputes each via git ls-files); validated
417
+ // here so a malformed declaration fails loudly at the gate, never silently.
418
+ if ("inventory" in data) {
419
+ if (!Array.isArray(data.inventory) || data.inventory.length === 0) {
420
+ fail(
421
+ path,
422
+ "inventory",
423
+ "optional · block list of `<name> :: <glob> :: <count>`",
424
+ );
425
+ } else {
426
+ for (const entry of data.inventory)
427
+ if (!/^.+?\s*::\s*.+?\s*::\s*\d+$/.test(String(entry)))
428
+ fail(
429
+ path,
430
+ "inventory",
431
+ `malformed entry: ${JSON.stringify(entry)} · must be \`<name> :: <glob> :: <count>\``,
432
+ );
433
+ }
434
+ }
335
435
  return { domains };
336
436
  }
337
437
 
438
+ // citation-check.json — the checker's machine record of its last run (generated
439
+ // by `rafa verify-citations`). Folded into manifest.citations so the platform
440
+ // knows which gate level this brain passed. Absent file → null (an honest
441
+ // "no recorded run"); a malformed one is a tool bug and fails loudly.
442
+ function compileCitations() {
443
+ const path = join(ROOT, "brain", "citation-check.json");
444
+ if (!existsSync(path)) return null;
445
+ let rec;
446
+ try {
447
+ rec = JSON.parse(read(path));
448
+ } catch (e) {
449
+ fail(path, "json", `unparseable: ${e instanceof Error ? e.message : e}`);
450
+ return null;
451
+ }
452
+ if (
453
+ typeof rec.checkerVersion !== "number" ||
454
+ typeof rec.pass !== "boolean" ||
455
+ typeof rec.at !== "string"
456
+ ) {
457
+ fail(
458
+ path,
459
+ "shape",
460
+ "required · { checkerVersion: int, pass: bool, at: ISO string }",
461
+ );
462
+ return null;
463
+ }
464
+ return { checkerVersion: rec.checkerVersion, pass: rec.pass, at: rec.at };
465
+ }
466
+
338
467
  // Plans (contract §7 v2 — the work-item tree, plans data version 2): one epic
339
468
  // (root) → tasks → subtasks. Vendor-blended vocabulary; `blocked` is DERIVED
340
469
  // (unresolved blocked_by / blocked_reason), never stored. VALIDATED here
341
470
  // (files are the authoring surface — determinism holds) but plans do NOT ride
342
471
  // the manifest: they travel through the push-on-approval plans channel.
343
- const PLAN_STATUSES = ["todo", "in-progress", "done", "superseded", "abandoned"];
472
+ const PLAN_STATUSES = [
473
+ "todo",
474
+ "in-progress",
475
+ "done",
476
+ "superseded",
477
+ "abandoned",
478
+ ];
344
479
  const PLAN_KINDS = ["epic", "task", "subtask"];
345
480
  function compilePlans() {
346
481
  const plans = [];
@@ -355,46 +490,95 @@ export function runCompile(argv = []) {
355
490
  checkVersion(data, path);
356
491
  const id = checkId(data, path, name);
357
492
  if (seen.has(id))
358
- fail(path, "id", `duplicate plan id "${id}" (also ${seen.get(id)}) · ids are globally unique across plans/**`);
493
+ fail(
494
+ path,
495
+ "id",
496
+ `duplicate plan id "${id}" (also ${seen.get(id)}) · ids are globally unique across plans/**`,
497
+ );
359
498
  seen.set(id, path);
360
499
  if ("progress" in data)
361
- fail(path, "progress", "never stored · progress = done leaves / total leaves, derived");
500
+ fail(
501
+ path,
502
+ "progress",
503
+ "never stored · progress = done leaves / total leaves, derived",
504
+ );
362
505
  if (data.kind === "parent" || data.kind === "child")
363
- fail(path, "kind", `plans v1 shape ("${data.kind}") — run \`rafa migrate\` (v2: epic|task|subtask)`);
506
+ fail(
507
+ path,
508
+ "kind",
509
+ `plans v1 shape ("${data.kind}") — run \`rafa migrate\` (v2: epic|task|subtask)`,
510
+ );
364
511
  const kind = reqEnum(data, "kind", PLAN_KINDS, path);
365
512
  const plan = reqStr(data, "plan", path);
366
513
  const status = data.status;
367
514
  if (status === "blocked")
368
- fail(path, "status", "removed in v2 — blocked is DERIVED from blocked_by/blocked_reason (run `rafa migrate`)");
515
+ fail(
516
+ path,
517
+ "status",
518
+ "removed in v2 — blocked is DERIVED from blocked_by/blocked_reason (run `rafa migrate`)",
519
+ );
369
520
  if (kind === "epic") {
370
521
  if (data.parent !== null && data.parent !== "null")
371
522
  fail(path, "parent", "must be null for kind: epic (the root)");
372
- if (plan !== id) fail(path, "plan", `must equal id "${id}" for kind: epic`);
523
+ if (plan !== id)
524
+ fail(path, "plan", `must equal id "${id}" for kind: epic`);
373
525
  if (status !== undefined && !isEnum(status, PLAN_STATUSES))
374
526
  fail(path, "status", `optional (epic) · ${PLAN_STATUSES.join("|")}`);
375
527
  } else {
376
528
  if (!isEnum(status, PLAN_STATUSES))
377
- fail(path, "status", `required (${kind}) · ${PLAN_STATUSES.join("|")}`);
529
+ fail(
530
+ path,
531
+ "status",
532
+ `required (${kind}) · ${PLAN_STATUSES.join("|")}`,
533
+ );
378
534
  if (typeof data.parent !== "string" || data.parent === "")
379
535
  fail(path, "parent", `required (${kind}) · the parent item id`);
380
536
  if (data.domains !== undefined)
381
- fail(path, "domains", "epic only · the blast radius belongs to the plan, not an item");
537
+ fail(
538
+ path,
539
+ "domains",
540
+ "epic only · the blast radius belongs to the plan, not an item",
541
+ );
382
542
  }
383
543
  if (data.domains !== undefined && !Array.isArray(data.domains))
384
544
  fail(path, "domains", "optional · flow list of domain strings");
385
545
  if (data.blocked_by !== undefined && !Array.isArray(data.blocked_by))
386
- fail(path, "blocked_by", "optional · flow list of item ids this item waits on");
546
+ fail(
547
+ path,
548
+ "blocked_by",
549
+ "optional · flow list of item ids this item waits on",
550
+ );
387
551
  if (
388
552
  data.priority !== undefined &&
389
- !(Number.isInteger(data.priority) && data.priority >= 0 && data.priority <= 4)
553
+ !(
554
+ Number.isInteger(data.priority) &&
555
+ data.priority >= 0 &&
556
+ data.priority <= 4
557
+ )
558
+ )
559
+ fail(
560
+ path,
561
+ "priority",
562
+ "optional · int 0–4 (0 none · 1 urgent · 2 high · 3 medium · 4 low)",
563
+ );
564
+ if (
565
+ data.estimate !== undefined &&
566
+ !(Number.isInteger(data.estimate) && data.estimate >= 0)
390
567
  )
391
- fail(path, "priority", "optional · int 0–4 (0 none · 1 urgent · 2 high · 3 medium · 4 low)");
392
- if (data.estimate !== undefined && !(Number.isInteger(data.estimate) && data.estimate >= 0))
393
568
  fail(path, "estimate", "optional · non-negative int (points)");
394
569
  if (data.external !== undefined) {
395
570
  const ex = data.external;
396
- if (typeof ex !== "object" || Array.isArray(ex) || typeof ex.provider !== "string" || typeof ex.key !== "string")
397
- fail(path, "external", "optional · { provider, key, url? } flow map — tracker identity (read-only to sessions)");
571
+ if (
572
+ typeof ex !== "object" ||
573
+ Array.isArray(ex) ||
574
+ typeof ex.provider !== "string" ||
575
+ typeof ex.key !== "string"
576
+ )
577
+ fail(
578
+ path,
579
+ "external",
580
+ "optional · { provider, key, url? } flow map — tracker identity (read-only to sessions)",
581
+ );
398
582
  }
399
583
  plans.push({
400
584
  id,
@@ -412,8 +596,11 @@ export function runCompile(argv = []) {
412
596
  ...(typeof data.assignee === "string" && data.assignee !== ""
413
597
  ? { assignee: data.assignee }
414
598
  : {}),
415
- ...(Array.isArray(data.blocked_by) ? { blockedBy: data.blocked_by.map(String) } : {}),
416
- ...(typeof data.blocked_reason === "string" && data.blocked_reason !== ""
599
+ ...(Array.isArray(data.blocked_by)
600
+ ? { blockedBy: data.blocked_by.map(String) }
601
+ : {}),
602
+ ...(typeof data.blocked_reason === "string" &&
603
+ data.blocked_reason !== ""
417
604
  ? { blockedReason: data.blocked_reason }
418
605
  : {}),
419
606
  ...(Number.isInteger(data.priority) ? { priority: data.priority } : {}),
@@ -427,7 +614,9 @@ export function runCompile(argv = []) {
427
614
  ...(Array.isArray(data.domains) && kind === "epic"
428
615
  ? { domains: data.domains.map(String) }
429
616
  : {}),
430
- ...(typeof data.external === "object" && !Array.isArray(data.external) && data.external !== null
617
+ ...(typeof data.external === "object" &&
618
+ !Array.isArray(data.external) &&
619
+ data.external !== null
431
620
  ? { external: data.external }
432
621
  : {}),
433
622
  path: rel(path),
@@ -439,24 +628,45 @@ export function runCompile(argv = []) {
439
628
  for (const p of plans) if (p.kind === "epic") roots.set(p.id, p);
440
629
  for (const p of plans) {
441
630
  if (!roots.has(p.plan))
442
- fail(p.path, "plan", `no kind:epic "${p.plan}" in this compile (dangling item)`);
631
+ fail(
632
+ p.path,
633
+ "plan",
634
+ `no kind:epic "${p.plan}" in this compile (dangling item)`,
635
+ );
443
636
  if (p.kind === "task") {
444
637
  const parent = byId.get(p.parent);
445
638
  if (!parent || parent.kind !== "epic" || parent.id !== p.plan)
446
- fail(p.path, "parent", `a task's parent must be its epic ("${p.plan}")`);
639
+ fail(
640
+ p.path,
641
+ "parent",
642
+ `a task's parent must be its epic ("${p.plan}")`,
643
+ );
447
644
  }
448
645
  if (p.kind === "subtask") {
449
646
  const parent = byId.get(p.parent);
450
647
  if (!parent || parent.kind !== "task")
451
- fail(p.path, "parent", "a subtask's parent must be a task in the same plan");
648
+ fail(
649
+ p.path,
650
+ "parent",
651
+ "a subtask's parent must be a task in the same plan",
652
+ );
452
653
  else if (parent.plan !== p.plan)
453
- fail(p.path, "parent", `parent task belongs to plan "${parent.plan}", not "${p.plan}"`);
654
+ fail(
655
+ p.path,
656
+ "parent",
657
+ `parent task belongs to plan "${parent.plan}", not "${p.plan}"`,
658
+ );
454
659
  }
455
660
  for (const b of p.blockedBy ?? []) {
456
661
  const target = byId.get(b);
457
662
  if (!target || target.plan !== p.plan)
458
- fail(p.path, "blocked_by", `"${b}" does not resolve to an item in plan "${p.plan}"`);
459
- if (b === p.id) fail(p.path, "blocked_by", "an item cannot block itself");
663
+ fail(
664
+ p.path,
665
+ "blocked_by",
666
+ `"${b}" does not resolve to an item in plan "${p.plan}"`,
667
+ );
668
+ if (b === p.id)
669
+ fail(p.path, "blocked_by", "an item cannot block itself");
460
670
  }
461
671
  }
462
672
  // blocked_by acyclicity (per plan; DFS with colors).
@@ -474,7 +684,11 @@ export function runCompile(argv = []) {
474
684
  };
475
685
  for (const p of plans)
476
686
  if (!color.has(p.id) && cyc(p))
477
- fail(p.path, "blocked_by", "dependency cycle — blocked_by must be acyclic");
687
+ fail(
688
+ p.path,
689
+ "blocked_by",
690
+ "dependency cycle — blocked_by must be acyclic",
691
+ );
478
692
  return plans;
479
693
  }
480
694
 
@@ -487,13 +701,21 @@ export function runCompile(argv = []) {
487
701
  const first = read(path).split("\n")[0].trim();
488
702
  const m = first.match(/^#\s+(.+)$/);
489
703
  if (!m) {
490
- fail(path, "pointer", 'first line must be "# <plan-id>" or "# No active plan"');
704
+ fail(
705
+ path,
706
+ "pointer",
707
+ 'first line must be "# <plan-id>" or "# No active plan"',
708
+ );
491
709
  return null;
492
710
  }
493
711
  const val = m[1].trim();
494
712
  if (/^no active plan$/i.test(val)) return null;
495
713
  if (!plans.some((p) => p.kind === "epic" && p.id === val)) {
496
- fail(path, "pointer", `active plan "${val}" is not a kind:epic plan in this compile`);
714
+ fail(
715
+ path,
716
+ "pointer",
717
+ `active plan "${val}" is not a kind:epic plan in this compile`,
718
+ );
497
719
  return null;
498
720
  }
499
721
  return val;
@@ -517,7 +739,8 @@ export function runCompile(argv = []) {
517
739
  }
518
740
  count++;
519
741
  const stem = name.replace(/\.md$/, "");
520
- if (data.name !== stem) fail(path, "name", `must equal filename stem "${stem}"`);
742
+ if (data.name !== stem)
743
+ fail(path, "name", `must equal filename stem "${stem}"`);
521
744
  if (!/^\d+\.\d+\.\d+$/.test(String(data.version ?? "")))
522
745
  fail(path, "version", "required · semver MAJOR.MINOR.PATCH");
523
746
  reqStr(data, "model", path);
@@ -537,12 +760,20 @@ export function runCompile(argv = []) {
537
760
  for (const d of duties) {
538
761
  const m = String(d).match(DUTY_RE);
539
762
  if (!m) {
540
- fail(path, "duties", `malformed duty (want "<duty> :: <sop> :: <bar>"): ${JSON.stringify(d)}`);
763
+ fail(
764
+ path,
765
+ "duties",
766
+ `malformed duty (want "<duty> :: <sop> :: <bar>"): ${JSON.stringify(d)}`,
767
+ );
541
768
  continue;
542
769
  }
543
770
  const sop = m[2];
544
771
  if (!existsSync(join(repoRoot, sop)) && !existsSync(join(ROOT, sop)))
545
- fail(path, "duties", `sop does not resolve: ${sop} (a duty without a procedure is a claim)`);
772
+ fail(
773
+ path,
774
+ "duties",
775
+ `sop does not resolve: ${sop} (a duty without a procedure is a claim)`,
776
+ );
546
777
  }
547
778
  }
548
779
  return count;
@@ -569,10 +800,13 @@ export function runCompile(argv = []) {
569
800
  ...compileNotes("rule", join(ROOT, "brain", "rules")),
570
801
  ...compileNotes("playbook", join(ROOT, "brain", "playbooks")),
571
802
  ];
572
- const improvements = compileImprovements(join(ROOT, "improve", "improvements"));
803
+ const improvements = compileImprovements(
804
+ join(ROOT, "improve", "improvements"),
805
+ );
573
806
  const health = compileHealth();
574
807
  const ledger = compileLedger();
575
808
  const coverage = compileCoverage();
809
+ const citations = compileCitations();
576
810
  const plans = compilePlans();
577
811
  const activePlanId = compileActivePlanId(plans);
578
812
  const agentCards = compileAgentCards();
@@ -583,16 +817,27 @@ export function runCompile(argv = []) {
583
817
  const bp = { P0: 0, P1: 0, P2: 0, P3: 0 };
584
818
  for (const i of open) bp[i.priority]++;
585
819
  if (ledger.open !== open.length)
586
- fail("improve/ledger.md", "open", `says ${ledger.open}, rows have ${open.length}`);
820
+ fail(
821
+ "improve/ledger.md",
822
+ "open",
823
+ `says ${ledger.open}, rows have ${open.length}`,
824
+ );
587
825
  for (const k of ["P0", "P1", "P2", "P3"])
588
826
  if (ledger.byPriority[k] !== bp[k])
589
- fail("improve/ledger.md", `by_priority.${k}`, `says ${ledger.byPriority[k]}, rows have ${bp[k]}`);
827
+ fail(
828
+ "improve/ledger.md",
829
+ `by_priority.${k}`,
830
+ `says ${ledger.byPriority[k]}, rows have ${bp[k]}`,
831
+ );
590
832
  }
591
833
 
592
834
  if (errors.length) {
593
835
  console.error(`✗ rafa compile: ${errors.length} contract violation(s)\n`);
594
- for (const e of errors) console.error(` ${e.path} · ${e.field} · ${e.rule}`);
595
- console.error(`\nFix these files to match the contract (.claude/rafa/contract.md), then re-run compile.`);
836
+ for (const e of errors)
837
+ console.error(` ${e.path} · ${e.field} · ${e.rule}`);
838
+ console.error(
839
+ `\nFix these files to match the contract (.claude/rafa/contract.md), then re-run compile.`,
840
+ );
596
841
  return 1;
597
842
  }
598
843
 
@@ -607,10 +852,14 @@ export function runCompile(argv = []) {
607
852
  health,
608
853
  ledger,
609
854
  coverage,
855
+ citations,
610
856
  notes,
611
857
  improvements,
612
858
  };
613
- writeFileSync(join(ROOT, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
859
+ writeFileSync(
860
+ join(ROOT, "manifest.json"),
861
+ JSON.stringify(manifest, null, 2) + "\n",
862
+ );
614
863
  console.log(
615
864
  `✓ rafa compile: ${notes.length} notes · ${improvements.length} improvements · ` +
616
865
  `${plans.length} plans validated${activePlanId ? ` (active: ${activePlanId})` : ""} (plans travel via the plans channel, not the manifest) · ` +