@rafinery/cli 0.4.1 → 0.6.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 (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/bin/rafa.mjs +30 -5
  3. package/blueprint/.claude/agents/atlas.md +10 -5
  4. package/blueprint/.claude/agents/compass.md +3 -1
  5. package/blueprint/.claude/commands/rafa.md +103 -12
  6. package/blueprint/.claude/rafa/contract.md +111 -47
  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 +17 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
  15. package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
  16. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  17. package/lib/blueprint.mjs +9 -1
  18. package/lib/brain-repo.mjs +10 -4
  19. package/lib/checkpoint.mjs +7 -0
  20. package/lib/ci-setup.mjs +2 -0
  21. package/lib/claude-config.mjs +113 -0
  22. package/lib/dirty.mjs +114 -0
  23. package/lib/distill.mjs +47 -2
  24. package/lib/gate/compile.mjs +145 -32
  25. package/lib/gate/verify-citations.mjs +214 -23
  26. package/lib/githook.mjs +54 -0
  27. package/lib/init.mjs +45 -0
  28. package/lib/leverage.mjs +13 -2
  29. package/lib/migrations/index.mjs +57 -2
  30. package/lib/pull.mjs +7 -0
  31. package/lib/push.mjs +21 -0
  32. package/lib/reflex.mjs +76 -0
  33. package/lib/releases.mjs +66 -0
  34. package/lib/status.mjs +152 -0
  35. package/lib/update.mjs +20 -0
  36. package/package.json +2 -2
package/lib/dirty.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // rafa dirty — the cite-graph invalidator's query surface (M5, agent-internal:
2
+ // the conductor runs this at boundaries; devs never need to remember it).
3
+ //
4
+ // rafa dirty human view: dirty code files + the brain notes citing them
5
+ // rafa dirty --json the same, machine-shaped (conductor bootstrap / spawn prompts)
6
+ // rafa dirty --consume clear the queue AFTER a completed scoped refresh — the
7
+ // refresh must have shipped (gates + checkpoint/push) first
8
+ //
9
+ // The queue (.rafa/dirty.jsonl) is written by the PostToolUse hook the moment an
10
+ // edit happens (monotonic, no session-end dependency). Citing notes resolve from
11
+ // the LOCAL manifest when one exists (full pull / prior compile); otherwise the
12
+ // mapping is honestly "unresolved here" — counts still serve, and the platform's
13
+ // get_code_context covers work-time recall. Never guesses.
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ export const DIRTY_FILES_ALARM = 15; // ≥ → recommend /rafa scan --brain-only
19
+ export const CITED_NOTES_ALARM = 10;
20
+
21
+ export function readDirty(root = process.cwd()) {
22
+ const file = join(root, ".rafa", "dirty.jsonl");
23
+ const files = new Map(); // path → last touch
24
+ if (!existsSync(file)) return { file, files: [] };
25
+ for (const line of readFileSync(file, "utf8").split("\n")) {
26
+ if (!line.trim()) continue;
27
+ try {
28
+ const e = JSON.parse(line);
29
+ if (e && typeof e.f === "string") files.set(e.f, e.t ?? "");
30
+ } catch {
31
+ /* torn line — skip, never fail the query */
32
+ }
33
+ }
34
+ return { file, files: [...files.entries()].map(([f, t]) => ({ f, t })) };
35
+ }
36
+
37
+ // file → citing notes from the local manifest; null = no manifest here (lazy .rafa).
38
+ export function resolveCiters(root, dirtyFiles) {
39
+ const path = join(root, ".rafa", "manifest.json");
40
+ if (!existsSync(path)) return null;
41
+ let m;
42
+ try {
43
+ m = JSON.parse(readFileSync(path, "utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ const want = new Set(dirtyFiles);
48
+ const notes = [];
49
+ for (const group of [m.notes ?? [], m.improvements ?? []]) {
50
+ for (const n of group) {
51
+ const files = [...new Set((n.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file))];
52
+ if (files.length) notes.push({ id: n.id, path: n.path, files });
53
+ }
54
+ }
55
+ return notes;
56
+ }
57
+
58
+ export default async function dirty(args = []) {
59
+ const ROOT = process.cwd();
60
+ const { file, files } = readDirty(ROOT);
61
+
62
+ if (args.includes("--consume")) {
63
+ if (files.length === 0) {
64
+ console.log("✓ dirty queue already clean");
65
+ return;
66
+ }
67
+ writeFileSync(file, "");
68
+ console.log(
69
+ `✓ consumed ${files.length} dirty file(s) — do this ONLY after the scoped refresh shipped (gates + checkpoint/push)`,
70
+ );
71
+ return;
72
+ }
73
+
74
+ const notes = resolveCiters(ROOT, files.map((e) => e.f));
75
+ const alarm =
76
+ files.length >= DIRTY_FILES_ALARM || (notes !== null && notes.length >= CITED_NOTES_ALARM);
77
+
78
+ if (args.includes("--json")) {
79
+ console.log(
80
+ JSON.stringify(
81
+ {
82
+ files: files.map((e) => e.f),
83
+ notes, // null = unresolved here (no local manifest) — an honest unknown, not zero
84
+ thresholds: { files: DIRTY_FILES_ALARM, notes: CITED_NOTES_ALARM },
85
+ alarm,
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ );
91
+ return;
92
+ }
93
+
94
+ if (files.length === 0) {
95
+ console.log("✓ dirty queue clean — no code edits recorded since the last brain reconcile");
96
+ return;
97
+ }
98
+ console.log(`${files.length} code file(s) edited since the last brain reconcile:`);
99
+ for (const e of files) console.log(` · ${e.f}`);
100
+ if (notes === null) {
101
+ console.log("citing notes: unresolved here (lazy .rafa, no local manifest) — recall still");
102
+ console.log("serves fresh knowledge via the platform; run a scoped refresh from a session.");
103
+ } else if (notes.length === 0) {
104
+ console.log("citing notes: none — nothing in the brain cites these files (nothing to refresh).");
105
+ } else {
106
+ console.log(`${notes.length} brain note(s) cite them:`);
107
+ for (const n of notes) console.log(` · ${n.id} ← ${n.files.join(", ")}`);
108
+ }
109
+ console.log(
110
+ alarm
111
+ ? `⚠ drift past threshold — recommend a brain refresh from main (/rafa scan --brain-only), then \`rafa dirty --consume\`.`
112
+ : `→ at the next boundary: scoped refresh of the citing notes (atlas re-derives ONLY those → gates → checkpoint), then \`rafa dirty --consume\`.`,
113
+ );
114
+ }
package/lib/distill.mjs CHANGED
@@ -61,6 +61,10 @@ async function loadAgentSdk(cwd) {
61
61
  const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
62
62
 
63
63
  export default async function distill(args = []) {
64
+ // U7 recursion guard, belt-and-braces: the Agent SDK worker must never fire
65
+ // the M5 session sensors (a worker's own tool calls re-triggering hooks would
66
+ // loop). The ci-setup workflow sets this too; this covers ad-hoc invocations.
67
+ process.env.RAFA_HOOKS_DISABLED = "1";
64
68
  const branch = args.filter((a) => !a.startsWith("-"))[0];
65
69
  if (!args.includes("--headless"))
66
70
  die(
@@ -76,6 +80,10 @@ export default async function distill(args = []) {
76
80
  );
77
81
 
78
82
  const ROOT = process.cwd();
83
+ // Telemetry (owner feedback #9): every phase is timed and printed, so a CI
84
+ // log answers "where is it, and where did the time go" at a glance.
85
+ const t0 = Date.now();
86
+ const secs = (t) => ((Date.now() - t) / 1000).toFixed(1);
79
87
 
80
88
  // 1 · collect the branch working set. Adjudication-flagged rows are LEFT for
81
89
  // a human session (CI can flag divergence, never resolve it).
@@ -101,7 +109,9 @@ export default async function distill(args = []) {
101
109
 
102
110
  // 2 · mirror the org brain locally — the authoring target + the base the
103
111
  // survivors fold into. (CI checkout is merged main = the validation target.)
112
+ const tPull = Date.now();
104
113
  await pull(["--full"]);
114
+ console.log(` ⏱ brain mirror: ${secs(tPull)}s`);
105
115
 
106
116
  // 3 · stage the incoming working set for the agent (gitignored inside .rafa).
107
117
  const stagingRoot = join(ROOT, ".rafa", "distill-incoming");
@@ -153,6 +163,7 @@ Staged files:
153
163
  ${fileList}`;
154
164
 
155
165
  console.log("• running the distillation agent (org's own ANTHROPIC_API_KEY) …");
166
+ const tAgent = Date.now();
156
167
  const run = sdk.query({
157
168
  prompt,
158
169
  options: {
@@ -163,14 +174,45 @@ ${fileList}`;
163
174
  maxTurns: 150,
164
175
  },
165
176
  });
177
+ // Stream the agent's narration lines — a silent multi-minute CI step reads
178
+ // as hung; a narrated one reads as working (and shows WHERE it hangs).
179
+ let lastLine = "";
180
+ let agentModel = "unreported";
166
181
  for await (const message of run) {
182
+ if (message.type === "assistant" && typeof message.message?.model === "string")
183
+ agentModel = message.message.model;
184
+ if (message.type === "assistant") {
185
+ const blocks = message.message?.content ?? [];
186
+ for (const b of blocks) {
187
+ if (b.type !== "text" || !b.text?.trim()) continue;
188
+ const line = b.text.trim().split("\n")[0].slice(0, 110);
189
+ if (line && line !== lastLine) {
190
+ console.log(` · ${line}`);
191
+ lastLine = line;
192
+ }
193
+ }
194
+ }
167
195
  if (message.type === "result") {
168
196
  if (message.subtype !== "success")
169
197
  die(`distillation agent did not complete (${message.subtype}) — nothing resolved, nothing pushed`);
170
198
  console.log(
171
- ` agent done (${message.num_turns ?? "?"} turns · ` +
199
+ ` agent: ${secs(tAgent)}s (${message.num_turns ?? "?"} turns · ` +
172
200
  `$${(message.total_cost_usd ?? 0).toFixed(4)} on the org's key)`,
173
201
  );
202
+ // Self-report the metered run (backlog #6) — best-effort, never fatal.
203
+ try {
204
+ await callTool(ROOT, "log_usage", {
205
+ provider: "anthropic",
206
+ model: agentModel,
207
+ inputTokens: message.usage?.input_tokens ?? 0,
208
+ outputTokens: message.usage?.output_tokens ?? 0,
209
+ ...(typeof message.total_cost_usd === "number" ? { costUsd: message.total_cost_usd } : {}),
210
+ purpose: "distill",
211
+ runner: "ci",
212
+ });
213
+ } catch (e) {
214
+ console.log(` ! usage not reported: ${e instanceof Error ? e.message : e}`);
215
+ }
174
216
  }
175
217
  }
176
218
 
@@ -195,10 +237,12 @@ ${fileList}`;
195
237
 
196
238
  // 6 · trust-but-verify: OUR gates re-run regardless of what the agent claims.
197
239
  console.log("• re-running the gates (trust-but-verify) …");
240
+ const tGates = Date.now();
198
241
  if (runVerifyCitations([]) !== 0)
199
242
  die("citation checker failed on the authored brain — nothing resolved, nothing pushed");
200
243
  if (runCompile([]) !== 0)
201
244
  die("compile gate failed on the authored brain — nothing resolved, nothing pushed");
245
+ console.log(` ⏱ gates: ${secs(tGates)}s`);
202
246
 
203
247
  // 7 · ship: brain repo push (webhook → ingest), staging cleaned first.
204
248
  rmSync(stagingRoot, { recursive: true, force: true });
@@ -221,6 +265,7 @@ ${fileList}`;
221
265
  branch,
222
266
  path: f.path,
223
267
  resolution: v.verdict,
268
+ actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
224
269
  ...(v.note ? { note: v.note } : {}),
225
270
  });
226
271
  console.log(` ${v.verdict === "distilled" ? "✓" : "!"} ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`);
@@ -232,6 +277,6 @@ ${fileList}`;
232
277
 
233
278
  console.log(
234
279
  `✓ distillation: ${distilledCount} into the org brain · ${refuted} refuted (reported, cited) · ` +
235
- `${adjudication} flagged needs-adjudication (next session's digest)`,
280
+ `${adjudication} flagged needs-adjudication (next session's digest) · total ${secs(t0)}s`,
236
281
  );
237
282
  }
@@ -191,6 +191,11 @@ export function runCompile(argv = []) {
191
191
  }
192
192
  checkVersion(data, path);
193
193
  const id = checkId(data, path, name);
194
+ // `anchor:` / `absent:` are checker-side declarations (verify-citations gates
195
+ // B2/B3 on them) — optional here, but if present they must be non-empty.
196
+ for (const key of ["anchor", "absent"])
197
+ if (key in data && (typeof data[key] !== "string" || data[key].trim() === ""))
198
+ fail(path, key, "optional · non-empty token (or `none`)");
194
199
  notes.push({
195
200
  id,
196
201
  kind,
@@ -332,23 +337,49 @@ export function runCompile(argv = []) {
332
337
  fail(path, `domains.${domain}`, `status ∈ ${STATUS.join("|")}`);
333
338
  domains.push({ domain, status: String(status) });
334
339
  }
340
+ // Optional `inventory:` block list — `<name> :: <glob> :: <count>` per entry.
341
+ // Checker-side (verify-citations recomputes each via git ls-files); validated
342
+ // here so a malformed declaration fails loudly at the gate, never silently.
343
+ if ("inventory" in data) {
344
+ if (!Array.isArray(data.inventory) || data.inventory.length === 0) {
345
+ fail(path, "inventory", "optional · block list of `<name> :: <glob> :: <count>`");
346
+ } else {
347
+ for (const entry of data.inventory)
348
+ if (!/^.+?\s*::\s*.+?\s*::\s*\d+$/.test(String(entry)))
349
+ fail(path, "inventory", `malformed entry: ${JSON.stringify(entry)} · must be \`<name> :: <glob> :: <count>\``);
350
+ }
351
+ }
335
352
  return { domains };
336
353
  }
337
354
 
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
- ];
355
+ // citation-check.json the checker's machine record of its last run (generated
356
+ // by `rafa verify-citations`). Folded into manifest.citations so the platform
357
+ // knows which gate level this brain passed. Absent file null (an honest
358
+ // "no recorded run"); a malformed one is a tool bug and fails loudly.
359
+ function compileCitations() {
360
+ const path = join(ROOT, "brain", "citation-check.json");
361
+ if (!existsSync(path)) return null;
362
+ let rec;
363
+ try {
364
+ rec = JSON.parse(read(path));
365
+ } catch (e) {
366
+ fail(path, "json", `unparseable: ${e instanceof Error ? e.message : e}`);
367
+ return null;
368
+ }
369
+ if (typeof rec.checkerVersion !== "number" || typeof rec.pass !== "boolean" || typeof rec.at !== "string") {
370
+ fail(path, "shape", "required · { checkerVersion: int, pass: bool, at: ISO string }");
371
+ return null;
372
+ }
373
+ return { checkerVersion: rec.checkerVersion, pass: rec.pass, at: rec.at };
374
+ }
375
+
376
+ // Plans (contract §7 v2 — the work-item tree, plans data version 2): one epic
377
+ // (root) → tasks → subtasks. Vendor-blended vocabulary; `blocked` is DERIVED
378
+ // (unresolved blocked_by / blocked_reason), never stored. VALIDATED here
379
+ // (files are the authoring surface — determinism holds) but plans do NOT ride
380
+ // the manifest: they travel through the push-on-approval plans channel.
381
+ const PLAN_STATUSES = ["todo", "in-progress", "done", "superseded", "abandoned"];
382
+ const PLAN_KINDS = ["epic", "task", "subtask"];
352
383
  function compilePlans() {
353
384
  const plans = [];
354
385
  const seen = new Map(); // id → path (global uniqueness across plans/**)
@@ -365,43 +396,123 @@ export function runCompile(argv = []) {
365
396
  fail(path, "id", `duplicate plan id "${id}" (also ${seen.get(id)}) · ids are globally unique across plans/**`);
366
397
  seen.set(id, path);
367
398
  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);
399
+ fail(path, "progress", "never stored · progress = done leaves / total leaves, derived");
400
+ if (data.kind === "parent" || data.kind === "child")
401
+ fail(path, "kind", `plans v1 shape ("${data.kind}") — run \`rafa migrate\` (v2: epic|task|subtask)`);
402
+ const kind = reqEnum(data, "kind", PLAN_KINDS, path);
370
403
  const plan = reqStr(data, "plan", path);
371
404
  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 {
405
+ if (status === "blocked")
406
+ fail(path, "status", "removed in v2 — blocked is DERIVED from blocked_by/blocked_reason (run `rafa migrate`)");
407
+ if (kind === "epic") {
378
408
  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`);
409
+ fail(path, "parent", "must be null for kind: epic (the root)");
410
+ if (plan !== id) fail(path, "plan", `must equal id "${id}" for kind: epic`);
381
411
  if (status !== undefined && !isEnum(status, PLAN_STATUSES))
382
- fail(path, "status", `optional (parent) · ${PLAN_STATUSES.join("|")}`);
412
+ fail(path, "status", `optional (epic) · ${PLAN_STATUSES.join("|")}`);
413
+ } else {
414
+ if (!isEnum(status, PLAN_STATUSES))
415
+ fail(path, "status", `required (${kind}) · ${PLAN_STATUSES.join("|")}`);
416
+ if (typeof data.parent !== "string" || data.parent === "")
417
+ fail(path, "parent", `required (${kind}) · the parent item id`);
418
+ if (data.domains !== undefined)
419
+ fail(path, "domains", "epic only · the blast radius belongs to the plan, not an item");
420
+ }
421
+ if (data.domains !== undefined && !Array.isArray(data.domains))
422
+ fail(path, "domains", "optional · flow list of domain strings");
423
+ if (data.blocked_by !== undefined && !Array.isArray(data.blocked_by))
424
+ fail(path, "blocked_by", "optional · flow list of item ids this item waits on");
425
+ if (
426
+ data.priority !== undefined &&
427
+ !(Number.isInteger(data.priority) && data.priority >= 0 && data.priority <= 4)
428
+ )
429
+ fail(path, "priority", "optional · int 0–4 (0 none · 1 urgent · 2 high · 3 medium · 4 low)");
430
+ if (data.estimate !== undefined && !(Number.isInteger(data.estimate) && data.estimate >= 0))
431
+ fail(path, "estimate", "optional · non-negative int (points)");
432
+ if (data.external !== undefined) {
433
+ const ex = data.external;
434
+ if (typeof ex !== "object" || Array.isArray(ex) || typeof ex.provider !== "string" || typeof ex.key !== "string")
435
+ fail(path, "external", "optional · { provider, key, url? } flow map — tracker identity (read-only to sessions)");
383
436
  }
384
437
  plans.push({
385
438
  id,
386
439
  plan,
387
- parent: kind === "parent" ? null : plan,
440
+ parent: kind === "epic" ? null : String(data.parent ?? ""),
388
441
  kind,
389
442
  title: reqStr(data, "title", path),
390
443
  ...(isEnum(status, PLAN_STATUSES) ? { status } : {}),
444
+ ...(typeof data.description === "string" && data.description !== ""
445
+ ? { description: data.description }
446
+ : {}),
447
+ ...(typeof data.approach === "string" && data.approach !== ""
448
+ ? { approach: data.approach }
449
+ : {}),
450
+ ...(typeof data.assignee === "string" && data.assignee !== ""
451
+ ? { assignee: data.assignee }
452
+ : {}),
453
+ ...(Array.isArray(data.blocked_by) ? { blockedBy: data.blocked_by.map(String) } : {}),
454
+ ...(typeof data.blocked_reason === "string" && data.blocked_reason !== ""
455
+ ? { blockedReason: data.blocked_reason }
456
+ : {}),
457
+ ...(Number.isInteger(data.priority) ? { priority: data.priority } : {}),
458
+ ...(Number.isInteger(data.estimate) ? { estimate: data.estimate } : {}),
391
459
  ...(typeof data.branch === "string" && data.branch !== ""
392
460
  ? { branch: data.branch }
393
461
  : {}),
394
462
  ...(typeof data.baseSha === "string" && data.baseSha !== ""
395
463
  ? { baseSha: String(data.baseSha) }
396
464
  : {}),
465
+ ...(Array.isArray(data.domains) && kind === "epic"
466
+ ? { domains: data.domains.map(String) }
467
+ : {}),
468
+ ...(typeof data.external === "object" && !Array.isArray(data.external) && data.external !== null
469
+ ? { external: data.external }
470
+ : {}),
397
471
  path: rel(path),
398
472
  });
399
473
  }
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));
474
+ // Cross-checks: tree shape + blocked_by resolution, per plan.
475
+ const byId = new Map(plans.map((p) => [p.id, p]));
476
+ const roots = new Map(); // plan id → epic item
477
+ for (const p of plans) if (p.kind === "epic") roots.set(p.id, p);
478
+ for (const p of plans) {
479
+ if (!roots.has(p.plan))
480
+ fail(p.path, "plan", `no kind:epic "${p.plan}" in this compile (dangling item)`);
481
+ if (p.kind === "task") {
482
+ const parent = byId.get(p.parent);
483
+ if (!parent || parent.kind !== "epic" || parent.id !== p.plan)
484
+ fail(p.path, "parent", `a task's parent must be its epic ("${p.plan}")`);
485
+ }
486
+ if (p.kind === "subtask") {
487
+ const parent = byId.get(p.parent);
488
+ if (!parent || parent.kind !== "task")
489
+ fail(p.path, "parent", "a subtask's parent must be a task in the same plan");
490
+ else if (parent.plan !== p.plan)
491
+ fail(p.path, "parent", `parent task belongs to plan "${parent.plan}", not "${p.plan}"`);
492
+ }
493
+ for (const b of p.blockedBy ?? []) {
494
+ const target = byId.get(b);
495
+ if (!target || target.plan !== p.plan)
496
+ fail(p.path, "blocked_by", `"${b}" does not resolve to an item in plan "${p.plan}"`);
497
+ if (b === p.id) fail(p.path, "blocked_by", "an item cannot block itself");
498
+ }
499
+ }
500
+ // blocked_by acyclicity (per plan; DFS with colors).
501
+ const color = new Map();
502
+ const cyc = (p) => {
503
+ color.set(p.id, 1);
504
+ for (const b of p.blockedBy ?? []) {
505
+ const t = byId.get(b);
506
+ if (!t) continue;
507
+ if (color.get(t.id) === 1) return true;
508
+ if (!color.has(t.id) && cyc(t)) return true;
509
+ }
510
+ color.set(p.id, 2);
511
+ return false;
512
+ };
402
513
  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)`);
514
+ if (!color.has(p.id) && cyc(p))
515
+ fail(p.path, "blocked_by", "dependency cycle blocked_by must be acyclic");
405
516
  return plans;
406
517
  }
407
518
 
@@ -419,8 +530,8 @@ export function runCompile(argv = []) {
419
530
  }
420
531
  const val = m[1].trim();
421
532
  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`);
533
+ if (!plans.some((p) => p.kind === "epic" && p.id === val)) {
534
+ fail(path, "pointer", `active plan "${val}" is not a kind:epic plan in this compile`);
424
535
  return null;
425
536
  }
426
537
  return val;
@@ -500,6 +611,7 @@ export function runCompile(argv = []) {
500
611
  const health = compileHealth();
501
612
  const ledger = compileLedger();
502
613
  const coverage = compileCoverage();
614
+ const citations = compileCitations();
503
615
  const plans = compilePlans();
504
616
  const activePlanId = compileActivePlanId(plans);
505
617
  const agentCards = compileAgentCards();
@@ -534,6 +646,7 @@ export function runCompile(argv = []) {
534
646
  health,
535
647
  ledger,
536
648
  coverage,
649
+ citations,
537
650
  notes,
538
651
  improvements,
539
652
  };