@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.
- package/CHANGELOG.md +97 -0
- package/bin/rafa.mjs +37 -5
- package/blueprint/.claude/agents/atlas.md +2 -1
- package/blueprint/.claude/agents/sage.md +66 -0
- package/blueprint/.claude/commands/rafa.md +214 -269
- package/blueprint/.claude/rafa/contract.md +204 -115
- 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 +20 -5
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
- package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/benchmark.mjs +573 -0
- package/lib/blueprint.mjs +11 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +77 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +4 -0
- package/lib/gate/compile.mjs +293 -44
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +18 -0
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +35 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +13 -0
- package/package.json +1 -1
package/lib/gate/compile.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
[
|
|
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 (
|
|
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")
|
|
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: {
|
|
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 = [
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
!(
|
|
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 (
|
|
397
|
-
|
|
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)
|
|
416
|
-
|
|
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" &&
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
459
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
595
|
-
|
|
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(
|
|
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) · ` +
|