@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.
- package/CHANGELOG.md +144 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +10 -5
- package/blueprint/.claude/agents/compass.md +3 -1
- package/blueprint/.claude/commands/rafa.md +103 -12
- package/blueprint/.claude/rafa/contract.md +111 -47
- package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
- package/blueprint/.claude/rafa/hooks/pre-push +24 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
- package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
- package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +17 -5
- package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/blueprint.mjs +9 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/checkpoint.mjs +7 -0
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +113 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +47 -2
- package/lib/gate/compile.mjs +145 -32
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +45 -0
- package/lib/leverage.mjs +13 -2
- package/lib/migrations/index.mjs +57 -2
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +66 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +20 -0
- 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
|
|
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
|
}
|
package/lib/gate/compile.mjs
CHANGED
|
@@ -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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 ·
|
|
369
|
-
|
|
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 (
|
|
373
|
-
|
|
374
|
-
|
|
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:
|
|
380
|
-
if (plan !== id) fail(path, "plan", `must equal id "${id}" for kind:
|
|
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 (
|
|
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 === "
|
|
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-
|
|
401
|
-
const
|
|
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.
|
|
404
|
-
fail(p.path, "
|
|
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 === "
|
|
423
|
-
fail(path, "pointer", `active plan "${val}" is not a kind:
|
|
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
|
};
|