cohorte 2.6.0 → 2.8.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 +110 -0
- package/README.md +11 -9
- package/bin/cli.js +15 -2
- package/core/agents/review.md +4 -2
- package/core/commands/cohorte-build.md +3 -1
- package/core/commands/cohorte-doctor.md +27 -4
- package/core/commands/cohorte-fix.md +6 -5
- package/core/commands/cohorte-review.md +32 -22
- package/core/commands/cohorte-ship.md +2 -1
- package/core/commands/cohorte-spec.md +1 -1
- package/core/hooks/gate.py +10 -4
- package/core/runtimes/claude.json +1 -0
- package/core/runtimes/codex.json +1 -0
- package/core/runtimes/cursor.json +1 -0
- package/core/runtimes/gemini.json +1 -0
- package/core/runtimes/opencode.json +1 -0
- package/core/templates/design-brief.md +12 -3
- package/core/workflows/audit.js +20 -5
- package/core/workflows/loop.js +617 -0
- package/core/workflows/refactor.js +21 -8
- package/core/workflows/review.js +81 -12
- package/dashboard/dist/assets/{index-D1rsbLat.js → index-DO3_nq2Q.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +11 -3
- package/dashboard/server/index.js +6 -1
- package/dashboard/server/kanban.js +15 -4
- package/dashboard/server/runtime.js +20 -1
- package/install.ps1 +16 -333
- package/install.sh +27 -297
- package/package.json +1 -1
- package/profile/SCHEMA.md +34 -10
- package/profile/cohorte.config.template.yaml +1 -1
- package/scripts/kanban-move.sh +15 -5
- package/scripts/metrics/prices.json +6 -3
- package/scripts/new-feature.sh.template +8 -1
- package/scripts/preflight.sh +10 -2
- package/scripts/remove-feature.sh.template +3 -1
- package/scripts/test-dashboard.mjs +42 -1
- package/scripts/test-gate.mjs +6 -0
- package/scripts/test-metrics.mjs +2 -2
- package/scripts/test-workflows.mjs +386 -6
- package/scripts/validate-core.mjs +64 -31
package/scripts/test-gate.mjs
CHANGED
|
@@ -112,6 +112,12 @@ console.log("gate.py — Bash command gating");
|
|
|
112
112
|
run(bash("node ace migration:run"), at()).decision === "ask");
|
|
113
113
|
check("deny wins over ask on the same segment",
|
|
114
114
|
run(bash("node ace migration:fresh"), at()).decision === "deny");
|
|
115
|
+
// Deny must win ACROSS segments too: with segment-order scanning, the benign ask
|
|
116
|
+
// surfaced first and the human's one confirm ran the hard-denied command behind it.
|
|
117
|
+
check("deny in a LATER segment wins over an earlier ask segment",
|
|
118
|
+
run(bash("node ace migration:run && node ace migration:fresh"), at()).decision === "deny");
|
|
119
|
+
check("deny in a later segment wins over an earlier branch-gated ask",
|
|
120
|
+
run(bash("git commit -m x && node ace db:wipe"), at()).decision === "deny");
|
|
115
121
|
// Matching is substring-on-the-whole-pattern, so a partial overlap is NOT a
|
|
116
122
|
// match — `migration:run` alone does not trigger `node ace migration:run`.
|
|
117
123
|
check("a partial overlap of a pattern does not gate",
|
package/scripts/test-metrics.mjs
CHANGED
|
@@ -144,8 +144,8 @@ check('…and keeps its own spend', retired && retired.tokens.output, 90);
|
|
|
144
144
|
// opus-5 $5 in / $25 out per MTok; 5m cache write 1.25x input, cache read 0.1x input.
|
|
145
145
|
// m1 100*5 + 1000*25 + 1000*6.25 + 10000*0.5 = 36750
|
|
146
146
|
// m3 500*25 = 12500
|
|
147
|
-
// s1 sonnet-5 2000*
|
|
148
|
-
check('cost sums the cache tiers at their own rates', Number(build.cost.total.toFixed(6)), 0.
|
|
147
|
+
// s1 sonnet-5 2000*10 = 20000 (subagent, $2/$10 since 2026-08)
|
|
148
|
+
check('cost sums the cache tiers at their own rates', Number(build.cost.total.toFixed(6)), 0.06925);
|
|
149
149
|
check('the unpriced list stays empty for known models', build.unpriced, []);
|
|
150
150
|
|
|
151
151
|
const detail = out.runs.find((r) => r.command === '/cohorte-build');
|
|
@@ -49,13 +49,17 @@ const finding = (over = {}) => ({
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
// Run one workflow script with a `reply(prompt, opts) => value` stub in place of
|
|
52
|
-
// every agent call
|
|
53
|
-
|
|
52
|
+
// every agent call, and an optional `wf(name, args)` stub in place of nested
|
|
53
|
+
// workflow() calls (loop.js runs the review workflow as a child). Returns
|
|
54
|
+
// { result, calls, prompts } — prompts keyed by label, for byte-identity asserts.
|
|
55
|
+
async function run(script, reply, args = { feature: "feat-x" }, wf) {
|
|
54
56
|
const text = readFileSync(join(root, "core/workflows", script), "utf8")
|
|
55
57
|
.replace(/^export const meta/m, "const meta");
|
|
56
58
|
const calls = [];
|
|
59
|
+
const prompts = {};
|
|
57
60
|
const agent = async (prompt, opts = {}) => {
|
|
58
61
|
calls.push(opts.label || "(unlabelled)");
|
|
62
|
+
prompts[opts.label || "(unlabelled)"] = prompt;
|
|
59
63
|
return reply(prompt, opts, calls);
|
|
60
64
|
};
|
|
61
65
|
// Mirrors the runtime's contract: a thunk that throws resolves to null, the
|
|
@@ -76,8 +80,9 @@ async function run(script, reply, args = { feature: "feat-x" }) {
|
|
|
76
80
|
"agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow", text);
|
|
77
81
|
const result = await fn(
|
|
78
82
|
agent, parallel, pipeline, () => {}, () => {}, args,
|
|
79
|
-
{ total: null, spent: () => 0, remaining: () => Infinity },
|
|
80
|
-
|
|
83
|
+
{ total: null, spent: () => 0, remaining: () => Infinity },
|
|
84
|
+
wf || (async () => {}));
|
|
85
|
+
return { result, calls, prompts };
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
// A reply table keyed by label prefix; the first matching prefix wins.
|
|
@@ -156,7 +161,7 @@ console.log("review.js");
|
|
|
156
161
|
problem: "p", fix: "f", outOfScope: "predates this feature; diff never touched it",
|
|
157
162
|
}];
|
|
158
163
|
let stagePrompt = "";
|
|
159
|
-
const { result } = await run("review.js", (prompt, opts) => {
|
|
164
|
+
const { result, calls } = await run("review.js", (prompt, opts) => {
|
|
160
165
|
const label = opts.label || "";
|
|
161
166
|
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [], deferred };
|
|
162
167
|
if (label === "stage-report") { stagePrompt = prompt; return "done"; }
|
|
@@ -172,7 +177,8 @@ console.log("review.js");
|
|
|
172
177
|
/refactor-backlog\.md/.test(stagePrompt) && /deferred:feat-x/.test(stagePrompt),
|
|
173
178
|
stagePrompt.slice(0, 200));
|
|
174
179
|
check("deferred are never cross-checked (no verify agent spawned)",
|
|
175
|
-
|
|
180
|
+
!calls.some(c => c.startsWith("verify:")) && result.refutedByCrossCheck === 0,
|
|
181
|
+
calls.join(","));
|
|
176
182
|
}
|
|
177
183
|
{
|
|
178
184
|
const { result } = await run("review.js", replier([
|
|
@@ -344,6 +350,380 @@ console.log("audit.js / refactor.js");
|
|
|
344
350
|
result.skipped && result.skipped.backend === 2, JSON.stringify(result));
|
|
345
351
|
}
|
|
346
352
|
|
|
353
|
+
// ── review.js — the machine verdict contract the loop reduces on ─────────────
|
|
354
|
+
console.log("review.js verdict contract");
|
|
355
|
+
{
|
|
356
|
+
const { result } = await run("review.js", replier([
|
|
357
|
+
["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
|
|
358
|
+
]));
|
|
359
|
+
check("red preflight ⇒ aborted: 'preflight' (what a driver branches on)",
|
|
360
|
+
result.aborted === "preflight", JSON.stringify(result.aborted));
|
|
361
|
+
}
|
|
362
|
+
{
|
|
363
|
+
// The empty-diff SHIP certified nothing (no review, no stamp) — its `next` must not
|
|
364
|
+
// read as "run /cohorte-ship", which would point a driver at a gate that refuses.
|
|
365
|
+
const { result } = await run("review.js", replier([
|
|
366
|
+
["stage-diff", { surfaces: [] }], ...BASE_REVIEW,
|
|
367
|
+
]));
|
|
368
|
+
check("empty-diff SHIP ⇒ next warns nothing was reviewed, never '/cohorte-ship'",
|
|
369
|
+
result.verdict === "SHIP" && !String(result.next).startsWith("/cohorte-ship") && /no review|nothing to ship/i.test(result.next),
|
|
370
|
+
result.next);
|
|
371
|
+
}
|
|
372
|
+
{
|
|
373
|
+
// blocking = CRITICAL + security counted once; blocking_items = identity, not wording:
|
|
374
|
+
// surface | file WITHOUT :line | first 8 words of the problem, lowercased, collapsed.
|
|
375
|
+
const crit = finding({ severity: "CRITICAL", file: "apps/api/a.ts:41",
|
|
376
|
+
problem: "Missing auth-check on POST /orders endpoint here now" });
|
|
377
|
+
let stagePrompt = "";
|
|
378
|
+
const { result } = await run("review.js", (prompt, opts) => {
|
|
379
|
+
const label = opts.label || "";
|
|
380
|
+
if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
|
|
381
|
+
if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
|
|
382
|
+
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
|
|
383
|
+
if (label === "stage-report") { stagePrompt = prompt; return "done"; }
|
|
384
|
+
return replier(BASE_REVIEW)(prompt, opts);
|
|
385
|
+
});
|
|
386
|
+
check("blocking counts CRITICAL+security, each once", result.blocking === 1, `got ${result.blocking}`);
|
|
387
|
+
check("blockingItems: surface|file-no-line|8-word normalized problem",
|
|
388
|
+
(result.blockingItems || [])[0] === "backend|apps/api/a.ts|missing auth check on post orders endpoint here",
|
|
389
|
+
JSON.stringify(result.blockingItems));
|
|
390
|
+
check("verdict.json is staged, fingerprint computed in Bash (sha256), never by hand",
|
|
391
|
+
/verdict\.json/.test(stagePrompt) && /sha256sum/.test(stagePrompt), stagePrompt.slice(0, 200));
|
|
392
|
+
}
|
|
393
|
+
{
|
|
394
|
+
// The cross-check exists so a refuted CRITICAL cannot force a fix loop — and so an
|
|
395
|
+
// unrefuted one still does. Both directions, plus security ⇒ BLOCK.
|
|
396
|
+
const crit = finding({ severity: "CRITICAL" });
|
|
397
|
+
const withVerify = refuted => (prompt, opts) => {
|
|
398
|
+
const label = opts.label || "";
|
|
399
|
+
if (label.startsWith("verify:")) return { refuted, reason: refuted ? "a guard covers it" : "holds" };
|
|
400
|
+
if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
|
|
401
|
+
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
|
|
402
|
+
return replier(BASE_REVIEW)(prompt, opts);
|
|
403
|
+
};
|
|
404
|
+
const kept = await run("review.js", withVerify(false));
|
|
405
|
+
check("unrefuted CRITICAL ⇒ REVISE, cross-check ran",
|
|
406
|
+
kept.result.verdict === "REVISE" && kept.calls.some(c => c.startsWith("verify:")),
|
|
407
|
+
JSON.stringify([kept.result.verdict, kept.result.blocking]));
|
|
408
|
+
const refutedRun = await run("review.js", withVerify(true));
|
|
409
|
+
check("refuted CRITICAL ⇒ SHIP, not a fix loop",
|
|
410
|
+
refutedRun.result.verdict === "SHIP" && refutedRun.result.refutedByCrossCheck === 1 && refutedRun.result.blocking === 0,
|
|
411
|
+
JSON.stringify([refutedRun.result.verdict, refutedRun.result.refutedByCrossCheck]));
|
|
412
|
+
const sec = await run("review.js", (prompt, opts) => {
|
|
413
|
+
const label = opts.label || "";
|
|
414
|
+
if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
|
|
415
|
+
if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [finding({ severity: "HIGH", kind: "security" })] };
|
|
416
|
+
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
|
|
417
|
+
return replier(BASE_REVIEW)(prompt, opts);
|
|
418
|
+
});
|
|
419
|
+
check("surviving security finding ⇒ BLOCK, counted blocking",
|
|
420
|
+
sec.result.verdict === "BLOCK" && sec.result.blocking === 1,
|
|
421
|
+
JSON.stringify([sec.result.verdict, sec.result.blocking]));
|
|
422
|
+
// A dead cross-check verifier must KEEP the finding (a real CRITICAL must not die
|
|
423
|
+
// on a transport error), never silently drop it.
|
|
424
|
+
const deadVerify = await run("review.js", (prompt, opts) => {
|
|
425
|
+
const label = opts.label || "";
|
|
426
|
+
if (label.startsWith("verify:")) return null;
|
|
427
|
+
if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
|
|
428
|
+
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
|
|
429
|
+
return replier(BASE_REVIEW)(prompt, opts);
|
|
430
|
+
});
|
|
431
|
+
check("dead verifier ⇒ the CRITICAL is kept, verdict REVISE",
|
|
432
|
+
deadVerify.result.verdict === "REVISE" && deadVerify.result.blocking === 1,
|
|
433
|
+
JSON.stringify([deadVerify.result.verdict, deadVerify.result.blocking]));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── refactor.js — args scoping + the retry round's cleared accumulation ──────
|
|
437
|
+
console.log("refactor.js retry & args");
|
|
438
|
+
{
|
|
439
|
+
// Bare-string shorthand names a DOMAIN — it must never widen to 'all' (that
|
|
440
|
+
// dispatched code-editing implementers on every big domain).
|
|
441
|
+
let backlogPrompt = "";
|
|
442
|
+
await run("refactor.js", (prompt, opts) => {
|
|
443
|
+
const label = opts.label || "";
|
|
444
|
+
if (label === "profile") return PROFILE;
|
|
445
|
+
if (label === "read-backlog") { backlogPrompt = prompt; return { domains: [] }; }
|
|
446
|
+
return "ok";
|
|
447
|
+
}, "backend");
|
|
448
|
+
check("bare-string args scope to that domain, not 'all'",
|
|
449
|
+
/Requested domains: backend/.test(backlogPrompt) && !/Requested domains: all/.test(backlogPrompt),
|
|
450
|
+
backlogPrompt.slice(-140));
|
|
451
|
+
}
|
|
452
|
+
{
|
|
453
|
+
// The re-verify covers only the retried items; round 1's verified clears must
|
|
454
|
+
// survive the merge or the backlog un-ticks finished work.
|
|
455
|
+
const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
|
|
456
|
+
let tickPrompt = "";
|
|
457
|
+
const { result } = await run("refactor.js", (prompt, opts) => {
|
|
458
|
+
const label = opts.label || "";
|
|
459
|
+
if (label === "profile") return PROFILE;
|
|
460
|
+
if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
|
|
461
|
+
if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
|
|
462
|
+
if (label === "reverify:backend") return { cleared: items.slice(3), remaining: [], gatesGreen: true };
|
|
463
|
+
if (label === "tick-backlog") { tickPrompt = prompt; return "done"; }
|
|
464
|
+
return "handoff";
|
|
465
|
+
}, { domains: "all" });
|
|
466
|
+
check("retry round keeps round-1 clears (5/5, not 2/5)",
|
|
467
|
+
result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
|
|
468
|
+
JSON.stringify(result.domains));
|
|
469
|
+
check("all five cleared items reach the ticker", items.every(i => tickPrompt.includes(i)),
|
|
470
|
+
tickPrompt.slice(0, 160));
|
|
471
|
+
}
|
|
472
|
+
{
|
|
473
|
+
// A dead re-verifier loses only the retry round's claim: round 1's clears stay
|
|
474
|
+
// cleared, the retried items stay open — never reset to all-five-open.
|
|
475
|
+
const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
|
|
476
|
+
const { result } = await run("refactor.js", (prompt, opts) => {
|
|
477
|
+
const label = opts.label || "";
|
|
478
|
+
if (label === "profile") return PROFILE;
|
|
479
|
+
if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
|
|
480
|
+
if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
|
|
481
|
+
if (label === "reverify:backend") return null;
|
|
482
|
+
if (label === "tick-backlog") return "done";
|
|
483
|
+
return "handoff";
|
|
484
|
+
}, { domains: "all" });
|
|
485
|
+
check("dead re-verifier ⇒ round-1 clears kept, retried items open",
|
|
486
|
+
result.domains.backend.cleared === 3 && result.domains.backend.remaining === 2,
|
|
487
|
+
JSON.stringify(result.domains));
|
|
488
|
+
}
|
|
489
|
+
{
|
|
490
|
+
// Gates red with everything cleared: the retry must NOT re-open verified items —
|
|
491
|
+
// with a dead re-verifier the same lines once sat in `cleared` AND `remaining`
|
|
492
|
+
// (ticked off the backlog while reported open).
|
|
493
|
+
const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
|
|
494
|
+
const { result } = await run("refactor.js", (prompt, opts) => {
|
|
495
|
+
const label = opts.label || "";
|
|
496
|
+
if (label === "profile") return PROFILE;
|
|
497
|
+
if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
|
|
498
|
+
if (label === "verify:backend") return { cleared: items, remaining: [], gatesGreen: false, failures: "lint red" };
|
|
499
|
+
if (label === "reverify:backend") return null;
|
|
500
|
+
if (label === "tick-backlog") return "done";
|
|
501
|
+
return "handoff";
|
|
502
|
+
}, { domains: "all" });
|
|
503
|
+
check("gates-red + all cleared + dead re-verifier ⇒ no cleared/remaining overlap",
|
|
504
|
+
result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
|
|
505
|
+
JSON.stringify(result.domains));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── loop.js — build → review → [fix → review]*, unattended ──────────────────
|
|
509
|
+
// The reducer's facts come from stubs, but the DECISIONS under test (freshness,
|
|
510
|
+
// precondition gates, exit ordering, treading water) all live in script code —
|
|
511
|
+
// which is exactly why they live there and not in an agent prompt.
|
|
512
|
+
console.log("loop.js");
|
|
513
|
+
|
|
514
|
+
const loopFacts = (over = {}) => ({
|
|
515
|
+
now: { epoch: 1000000, iso: "2026-08-22T00:00:00Z" },
|
|
516
|
+
spec: { exists: true, status: "frozen", kind: "", mtimeEpoch: 500, designFiles: [] },
|
|
517
|
+
readiness: { exists: true, mtimeEpoch: 600, verdict: "READY", gaps: [], surfaces: ["backend", "frontend"] },
|
|
518
|
+
contractFile: { exists: true },
|
|
519
|
+
build: { exists: false },
|
|
520
|
+
loop: { exists: false },
|
|
521
|
+
...over,
|
|
522
|
+
});
|
|
523
|
+
const FRESH_BUILD = { exists: true, mtimeEpoch: 600, dead: [] };
|
|
524
|
+
const loopReply = (facts, over = {}) => (prompt, opts) => {
|
|
525
|
+
const label = opts.label || "";
|
|
526
|
+
if (label === "profile") return over.profile || PROFILE;
|
|
527
|
+
if (label === "preconditions") return facts;
|
|
528
|
+
if (label.startsWith("state:") || label === "close") return "done 1000001";
|
|
529
|
+
if (label.startsWith("ingest:")) return "ingest" in over ? over.ingest
|
|
530
|
+
: { items: [{ line: "- [ ] CRITICAL · apps/api/a.ts:3 · quality · f", file: "apps/api/a.ts" }] };
|
|
531
|
+
if (label.startsWith("tick:")) return "done";
|
|
532
|
+
if (label.startsWith("build") || label.startsWith("fix")) {
|
|
533
|
+
return "impl" in over ? over.impl : "handoff\n## Remediation addressed\n- apps/api/a.ts:3 — fixed";
|
|
534
|
+
}
|
|
535
|
+
return "ok";
|
|
536
|
+
};
|
|
537
|
+
const reviewOf = over => ({
|
|
538
|
+
verdict: "REVISE", blocking: 1, blockingItems: ["backend|apps/api/a.ts|p"],
|
|
539
|
+
unreviewedSurfaces: [], deferred: 0, ...over,
|
|
540
|
+
});
|
|
541
|
+
const SHIP_CLEAN = { verdict: "SHIP", blocking: 0, blockingItems: [], unreviewedSurfaces: [], deferred: 0, next: "/cohorte-ship feat-x (DoD ticked + freshness stamped)" };
|
|
542
|
+
|
|
543
|
+
{
|
|
544
|
+
// THE ordering regression: a dead reviewer's zero findings must not read as ship.
|
|
545
|
+
// unreviewed is checked BEFORE blocking — the other way round ships unread code.
|
|
546
|
+
const wf = async () => reviewOf({ blocking: 0, unreviewedSurfaces: ["backend"] });
|
|
547
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
548
|
+
check("unreviewed + blocking 0 ⇒ abort/unreviewed, NOT ship",
|
|
549
|
+
result.outcome === "abort" && result.reason === "unreviewed", JSON.stringify([result.outcome, result.reason]));
|
|
550
|
+
}
|
|
551
|
+
{
|
|
552
|
+
// Same blocking identity two consecutive rounds ⇒ treading water at round 2,
|
|
553
|
+
// not burned down to maxRounds.
|
|
554
|
+
const wf = async () => reviewOf();
|
|
555
|
+
const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
556
|
+
check("identical fingerprint twice ⇒ abort/treading-water", result.reason === "treading-water", result.reason);
|
|
557
|
+
check("…at round 2, not maxRounds", result.rounds === 2, `rounds ${result.rounds}`);
|
|
558
|
+
check("…after exactly one fix round", calls.filter(c => c.startsWith("fix:")).length === 1,
|
|
559
|
+
calls.filter(c => c.startsWith("fix")).join(","));
|
|
560
|
+
}
|
|
561
|
+
{
|
|
562
|
+
// NOT-READY is the one outcome more passes cannot fix — and a precondition that
|
|
563
|
+
// aborts AFTER spawning has not aborted: only profile + preconditions may run.
|
|
564
|
+
const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 600, verdict: "NOT-READY", gaps: ["contract|POST /x|no shape"], surfaces: ["backend"] } });
|
|
565
|
+
const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
566
|
+
check("NOT-READY ⇒ abort/precondition with the gaps verbatim",
|
|
567
|
+
result.reason === "precondition" && (result.gaps || []).length === 1, JSON.stringify(result));
|
|
568
|
+
check("NOT-READY ⇒ zero dispatches (profile + facts only)",
|
|
569
|
+
calls.join(",") === "profile,preconditions", calls.join(","));
|
|
570
|
+
}
|
|
571
|
+
{
|
|
572
|
+
// readiness older than the spec describes a spec that no longer exists ⇒ absent.
|
|
573
|
+
const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 400, verdict: "READY", gaps: [], surfaces: ["backend"] } });
|
|
574
|
+
const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
575
|
+
check("stale readiness.json ⇒ treated as absent ⇒ abort/precondition",
|
|
576
|
+
result.reason === "precondition" && /older than the spec/.test(result.detail), JSON.stringify(result.detail));
|
|
577
|
+
check("stale readiness ⇒ zero dispatches", calls.join(",") === "profile,preconditions", calls.join(","));
|
|
578
|
+
}
|
|
579
|
+
{
|
|
580
|
+
// A blocking finding on the contract file is /cohorte-fix §1's lead-only step.
|
|
581
|
+
const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
|
|
582
|
+
const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/feat-x.ts|response shape wrong"] });
|
|
583
|
+
const { result, calls } = await run("loop.js",
|
|
584
|
+
loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
|
|
585
|
+
check("blocking finding on the contract file ⇒ abort/contract-change",
|
|
586
|
+
result.reason === "contract-change", result.reason);
|
|
587
|
+
check("contract-change ⇒ no fix round dispatched", !calls.some(c => c.startsWith("fix")), calls.join(","));
|
|
588
|
+
}
|
|
589
|
+
{
|
|
590
|
+
// Dead build implementers: retried ONCE, byte-identical, then abort — never "ok".
|
|
591
|
+
const { result, calls, prompts } = await run("loop.js",
|
|
592
|
+
loopReply(loopFacts(), { impl: null }), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
593
|
+
check("dead implementers ⇒ abort/dead-implementers", result.reason === "dead-implementers", result.reason);
|
|
594
|
+
check("each dead surface retried exactly once",
|
|
595
|
+
calls.filter(c => c === "build:backend").length === 1 && calls.filter(c => c === "build-retry:backend").length === 1,
|
|
596
|
+
calls.join(","));
|
|
597
|
+
check("the retry is byte-identical to the dispatch",
|
|
598
|
+
prompts["build:backend"] === prompts["build-retry:backend"]);
|
|
599
|
+
}
|
|
600
|
+
{
|
|
601
|
+
// Deferred findings never cost a round: 9 deferred + 0 blocking ships in one.
|
|
602
|
+
const wf = async () => ({ ...SHIP_CLEAN, deferred: 9 });
|
|
603
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
604
|
+
check("deferred 9 + blocking 0 ⇒ ship in one round",
|
|
605
|
+
result.outcome === "ship" && result.rounds === 1 && result.deferred === 9, JSON.stringify(result));
|
|
606
|
+
}
|
|
607
|
+
{
|
|
608
|
+
// The degraded preflight verdict has no unreviewed/blocking keys — the reducer
|
|
609
|
+
// must branch on `aborted`, not crash on a missing field.
|
|
610
|
+
const wf = async () => ({ verdict: "ABORTED", aborted: "preflight", reason: "preflight red" });
|
|
611
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
612
|
+
check("child preflight abort ⇒ abort/preflight (no crash on the degraded shape)",
|
|
613
|
+
result.outcome === "abort" && result.reason === "preflight", JSON.stringify([result.outcome, result.reason]));
|
|
614
|
+
}
|
|
615
|
+
{
|
|
616
|
+
// A fresh build.json with no dead surfaces means the work is on disk — entering
|
|
617
|
+
// after a conversational /cohorte-build must not rebuild it.
|
|
618
|
+
const { result, calls } = await run("loop.js",
|
|
619
|
+
loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
620
|
+
check("fresh build.json ⇒ build phase skipped", !calls.some(c => c.startsWith("build")), calls.join(","));
|
|
621
|
+
check("…and the run still ships", result.outcome === "ship", result.outcome);
|
|
622
|
+
// …and a STALE build.json builds: the work on disk predates the spec.
|
|
623
|
+
const stale = await run("loop.js",
|
|
624
|
+
loopReply(loopFacts({ build: { exists: true, mtimeEpoch: 400, dead: [] } })), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
625
|
+
check("stale build.json ⇒ build phase runs", stale.calls.some(c => c.startsWith("build:")), stale.calls.join(","));
|
|
626
|
+
}
|
|
627
|
+
{
|
|
628
|
+
// Resume: an unfinished, fresh loop.json restores round + the treading-water key,
|
|
629
|
+
// so a run killed mid-round costs a re-review, not a restart.
|
|
630
|
+
const prev = JSON.stringify({ id: "feat-x", round: 3, lastItems: ["backend|apps/api/a.ts|p"], history: [{ round: 1, blocking: 3 }, { round: 2, blocking: 1 }] });
|
|
631
|
+
const facts = loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: prev } });
|
|
632
|
+
const wf = async () => reviewOf();
|
|
633
|
+
const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, wf);
|
|
634
|
+
check("resume: same items as the resumed round ⇒ treading-water immediately",
|
|
635
|
+
result.reason === "treading-water" && !calls.some(c => c.startsWith("fix")), JSON.stringify([result.reason, result.rounds]));
|
|
636
|
+
check("resume: history carries the prior rounds", result.rounds === 3, `rounds ${result.rounds}`);
|
|
637
|
+
// A FINISHED loop.json (outcome set) must not resume — fresh run from round 1.
|
|
638
|
+
const done = JSON.stringify({ id: "feat-x", round: 4, outcome: "abort", lastItems: ["backend|apps/api/a.ts|p"], history: [] });
|
|
639
|
+
const r2 = await run("loop.js",
|
|
640
|
+
loopReply(loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: done } })),
|
|
641
|
+
{ feature: "feat-x" }, wf);
|
|
642
|
+
check("a finished loop.json does not resume (round 1, fix dispatched)",
|
|
643
|
+
r2.calls.some(c => c.startsWith("fix:")), r2.calls.join(","));
|
|
644
|
+
}
|
|
645
|
+
{
|
|
646
|
+
// The loop EDITS the spec as it runs (status stamps, Remediation appends), so on
|
|
647
|
+
// resume the spec's mtime is NEWER than readiness.json — freshness must be measured
|
|
648
|
+
// against the baseline stored in loop.json, or the loop's own footprint aborts its
|
|
649
|
+
// own resume with "readiness is older than the spec".
|
|
650
|
+
const prev = JSON.stringify({ id: "feat-x", round: 2, specMtime: 500, lastItems: ["backend|apps/api/old.ts|p"], history: [{ round: 1, blocking: 3 }] });
|
|
651
|
+
const facts = loopFacts({
|
|
652
|
+
spec: { exists: true, status: "in-progress", kind: "", mtimeEpoch: 900, designFiles: [] },
|
|
653
|
+
build: FRESH_BUILD,
|
|
654
|
+
loop: { exists: true, mtimeEpoch: 700, raw: prev },
|
|
655
|
+
});
|
|
656
|
+
const { result } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
|
|
657
|
+
check("resume survives the loop's own spec writes (baseline mtime, not current)",
|
|
658
|
+
result.outcome === "ship", JSON.stringify([result.outcome, result.reason, result.detail]));
|
|
659
|
+
check("resume keeps only rounds before the resumed one (no double count)",
|
|
660
|
+
result.rounds === 2, `rounds ${result.rounds}`);
|
|
661
|
+
}
|
|
662
|
+
{
|
|
663
|
+
// A real verdict whose report never landed on disk: the fix round would ingest the
|
|
664
|
+
// PREVIOUS round's report. Resumable give-up, never a fix round on stale findings.
|
|
665
|
+
const wf = async () => reviewOf({ reportStaged: false });
|
|
666
|
+
const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
667
|
+
check("blocking verdict + unstaged report ⇒ abort/report-not-staged, no fix round",
|
|
668
|
+
result.reason === "report-not-staged" && !calls.some(c => c.startsWith("fix")),
|
|
669
|
+
JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
|
|
670
|
+
}
|
|
671
|
+
{
|
|
672
|
+
// Ordinary surface code can live UNDER contract.path (a `shared` surface at the
|
|
673
|
+
// contract package) — only the feature's contract FILE is the lead-only abort.
|
|
674
|
+
const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
|
|
675
|
+
const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/utils.ts|helper broken"] });
|
|
676
|
+
const { result, calls } = await run("loop.js",
|
|
677
|
+
loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
|
|
678
|
+
check("a finding elsewhere under contract.path is NOT contract-change",
|
|
679
|
+
result.reason !== "contract-change" && calls.some(c => c.startsWith("fix:")),
|
|
680
|
+
JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
|
|
681
|
+
}
|
|
682
|
+
{
|
|
683
|
+
// A dead fix implementer still gets its metrics line — an incomplete batch is the
|
|
684
|
+
// batch worth recording — written BEFORE the abort.
|
|
685
|
+
const wf = async () => reviewOf();
|
|
686
|
+
const { result, prompts } = await run("loop.js",
|
|
687
|
+
loopReply(loopFacts({ build: FRESH_BUILD }), { impl: null }), { feature: "feat-x" }, wf);
|
|
688
|
+
check("dead fix implementer ⇒ abort, with the fix metrics written first (\"dead\")",
|
|
689
|
+
result.reason === "dead-implementers" && /"backend":"dead"/.test(prompts["state:fixed-1"] || ""),
|
|
690
|
+
JSON.stringify([result.reason, (prompts["state:fixed-1"] || "").slice(-120)]));
|
|
691
|
+
}
|
|
692
|
+
{
|
|
693
|
+
// maxRounds is the last net: distinct findings each round burn down to it.
|
|
694
|
+
let n = 0;
|
|
695
|
+
const wf = async () => reviewOf({ blockingItems: [`backend|apps/api/f${++n}.ts|p`] });
|
|
696
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })),
|
|
697
|
+
{ feature: "feat-x", maxRounds: 2 }, wf);
|
|
698
|
+
check("maxRounds reached with distinct findings ⇒ abort/max-rounds at that round",
|
|
699
|
+
result.reason === "max-rounds" && result.rounds === 2, JSON.stringify([result.reason, result.rounds]));
|
|
700
|
+
}
|
|
701
|
+
{
|
|
702
|
+
// A ship with surviving HIGH/MEDIUM has NO freshness stamp — the loop must relay
|
|
703
|
+
// review's routing (which says /cohorte-fix), not print "/cohorte-ship".
|
|
704
|
+
const wf = async () => ({ ...SHIP_CLEAN, next: "/cohorte-fix feat-x — SHIP verdict, but 2 finding(s) above LOW survived; park them in specs/refactor-backlog.md instead if you deliberately defer them" });
|
|
705
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
706
|
+
check("ship with HIGH leftovers ⇒ next relays review's /cohorte-fix routing",
|
|
707
|
+
String(result.next).startsWith("/cohorte-fix"), result.next);
|
|
708
|
+
}
|
|
709
|
+
{
|
|
710
|
+
// Dead ingest = no Remediation items were appended; dispatching blind would
|
|
711
|
+
// re-build surfaces with no instructions. Abort, resumable at this round.
|
|
712
|
+
const wf = async () => reviewOf();
|
|
713
|
+
const { result } = await run("loop.js",
|
|
714
|
+
loopReply(loopFacts({ build: FRESH_BUILD }), { ingest: null }), { feature: "feat-x" }, wf);
|
|
715
|
+
check("dead ingest agent ⇒ abort/ingest-died, items never invented",
|
|
716
|
+
result.reason === "ingest-died", result.reason);
|
|
717
|
+
}
|
|
718
|
+
{
|
|
719
|
+
// workflow() unavailable (no runtime / review.js not installed) ⇒ explicit refusal,
|
|
720
|
+
// never a conversational fallback.
|
|
721
|
+
const wf = async () => { throw new Error("unknown workflow: cohorte-review"); };
|
|
722
|
+
const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
|
|
723
|
+
check("review workflow unavailable ⇒ abort/review-workflow-unavailable",
|
|
724
|
+
result.reason === "review-workflow-unavailable", result.reason);
|
|
725
|
+
}
|
|
726
|
+
|
|
347
727
|
console.log("");
|
|
348
728
|
if (failures) { console.error(`test-workflows: ${failures} failure(s)`); process.exit(1); }
|
|
349
729
|
console.log("test-workflows: OK");
|
|
@@ -56,13 +56,17 @@ for (const f of readdirSync(join(root, "core/commands"))) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// ── fixed agents ────────────────────────────────────────────────────────────
|
|
59
|
-
// Every non-template agent needs name/tools/model, and must be
|
|
60
|
-
//
|
|
61
|
-
//
|
|
59
|
+
// Every non-template agent needs name/tools/model, and every one must be
|
|
60
|
+
// ASSERTED by the ci.yml install dry-run (a new agent the CLI's copy rules miss
|
|
61
|
+
// would otherwise never reach an install and nothing would notice — the exact
|
|
62
|
+
// bug that motivated this check). The old form grepped install.sh/install.ps1
|
|
63
|
+
// for copy commands, but those scripts have been thin delegators to bin/cli.js
|
|
64
|
+
// since 2.2.0 — the text being matched was unreachable dead code, so the check
|
|
65
|
+
// passed vacuously. CI postconditions test the copy that actually runs.
|
|
62
66
|
const AGENT_MODEL = { review: "sonnet", release: "haiku",
|
|
63
67
|
"profile-reader": "haiku" };
|
|
64
|
-
const
|
|
65
|
-
|
|
68
|
+
const ciYmlText = existsSync(join(root, ".github/workflows/ci.yml"))
|
|
69
|
+
? read(".github/workflows/ci.yml") : "";
|
|
66
70
|
|
|
67
71
|
for (const f of readdirSync(join(root, "core/agents"))) {
|
|
68
72
|
const path = `core/agents/${f}`;
|
|
@@ -84,10 +88,8 @@ for (const f of readdirSync(join(root, "core/agents"))) {
|
|
|
84
88
|
fail(path, `frontmatter must pin \`model: ${want}\``);
|
|
85
89
|
if (text.includes("<SURFACE_"))
|
|
86
90
|
fail(path, "unrendered <SURFACE_*> placeholder in a non-template agent");
|
|
87
|
-
if (!
|
|
88
|
-
fail("
|
|
89
|
-
if (!installPs1.includes(`core\\agents\\${f}`))
|
|
90
|
-
fail("install.ps1", `does not copy core\\agents\\${f} (Copy-FixedAgents)`);
|
|
91
|
+
if (ciYmlText && !ciYmlText.includes(`agents/${f}`))
|
|
92
|
+
fail(".github/workflows/ci.yml", `install dry-run never asserts .claude/agents/${f} — a fixed agent the CLI's copy rules miss would ship silently`);
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
// ── cross-references ────────────────────────────────────────────────────────
|
|
@@ -145,7 +147,9 @@ const walk = (dir) => readdirSync(join(root, dir), { withFileTypes: true }).flat
|
|
|
145
147
|
for (const dir of ["core/commands", "core/agents", "core/templates", "core/workflows"])
|
|
146
148
|
for (const rel of walk(dir)) {
|
|
147
149
|
if (!/\.(md|js)$/.test(rel) || rel === TELEMETRY_SCRUBBER) continue;
|
|
148
|
-
|
|
150
|
+
// Collapse whitespace first: prose wraps mid-phrase, and "usage\n ping" sat in a
|
|
151
|
+
// command file for two releases because this regex only saw one line at a time.
|
|
152
|
+
if (NO_TELEMETRY.test(read(rel).replace(/\s+/g, " ")))
|
|
149
153
|
fail(rel, "mentions telemetry — it was removed in 2.3.0; nothing may ping or ask for consent");
|
|
150
154
|
}
|
|
151
155
|
// …and the exempt file may only REMOVE it: naming a send/ping/consent path there is still a bug.
|
|
@@ -173,22 +177,28 @@ for (const c of KANBAN_STAGES) {
|
|
|
173
177
|
fail(path, "no instruction to run the resolver before concluding there is no board");
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
// ── shipped scripts
|
|
177
|
-
// Every scripts/*.sh must be
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// than by name, so grepping for filenames can't see it — ci.yml dry-runs it into a
|
|
182
|
-
// scratch HOME and asserts the same postconditions instead. Both are needed: this
|
|
183
|
-
// check catches a forgotten name, that one catches a drifted rule.
|
|
180
|
+
// ── shipped scripts + thin installers ───────────────────────────────────────
|
|
181
|
+
// Every shipped scripts/*.sh must be asserted by the ci.yml install dry-run —
|
|
182
|
+
// bin/cli.js copies by rule rather than by name, so only a postcondition on the
|
|
183
|
+
// copy that actually runs can catch a rule that misses a new script. Callers
|
|
184
|
+
// chain these with `|| true`, so a missing one is a silent no-op forever.
|
|
184
185
|
// A `<name>.sh` with a `<name>.sh.template` sibling is a locally-rendered artifact
|
|
185
186
|
// (this repo dogfoods its own /cohorte-init-pipeline), not a core asset — skip those.
|
|
186
|
-
const installers = { "install.sh": read("install.sh"), "install.ps1": read("install.ps1") };
|
|
187
187
|
const shipped = readdirSync(join(root, "scripts"));
|
|
188
188
|
for (const f of shipped.filter((f) => f.endsWith(".sh") && !shipped.includes(`${f}.template`)))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
if (ciYmlText && !ciYmlText.includes(`pipeline/scripts/${f}`))
|
|
190
|
+
fail(".github/workflows/ci.yml", `install dry-run never asserts pipeline/scripts/${f} (a copy rule that misses it is a silent no-op at runtime)`);
|
|
191
|
+
// The shell installers are THIN DELEGATORS to bin/cli.js — their legacy copy-verbatim
|
|
192
|
+
// path was unreachable dead code from 2.2.0 (removed in 2.7.0), and its text was what
|
|
193
|
+
// this file's copy checks used to vacuously match. Pin the shape: both must hand off
|
|
194
|
+
// to the CLI and neither may grow its own copy logic back.
|
|
195
|
+
for (const [name, marker] of [["install.sh", "cp -R \"$src/core"], ["install.ps1", "Copy-Tree"]]) {
|
|
196
|
+
const text = read(name);
|
|
197
|
+
if (!text.includes("bin/cli.js") && !text.includes("bin\\cli.js"))
|
|
198
|
+
fail(name, "no longer delegates to bin/cli.js — the only renderer of runtime-neutral sources");
|
|
199
|
+
if (text.includes(marker))
|
|
200
|
+
fail(name, "carries its own core-copy logic again — installs must go through bin/cli.js (no shell renderer exists)");
|
|
201
|
+
}
|
|
192
202
|
|
|
193
203
|
// ── workflow scripts ────────────────────────────────────────────────────────
|
|
194
204
|
// core/workflows/*.js run inside the Claude Code Workflow runtime: an async
|
|
@@ -223,12 +233,36 @@ else for (const f of readdirSync(workflowsDir)) {
|
|
|
223
233
|
fail(path, `does not parse as a workflow body: ${e.message}`);
|
|
224
234
|
}
|
|
225
235
|
}
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
236
|
+
// Workflow meta.names and command names share one mental namespace — both are the
|
|
237
|
+
// "/cohorte-…" way a human asks for a phase. A meta.name that MATCHES a
|
|
238
|
+
// core/commands/<name>.md is a declared variant pair (same phase, two execution paths —
|
|
239
|
+
// review/audit/refactor, on purpose). A meta.name matching neither a command nor the
|
|
240
|
+
// deliberate command-less list below is a typo'd variant: the human asks for "the
|
|
241
|
+
// review workflow", the lead resolves the name, and a mismatched name runs nothing.
|
|
242
|
+
// cohorte-loop is command-less BY DECISION (SCHEMA.md §Workflows): when the Workflow
|
|
243
|
+
// runtime is unavailable it must refuse explicitly, never degrade to a conversational
|
|
244
|
+
// loop — so a core/commands/cohorte-loop.md appearing later is a regression, not an
|
|
245
|
+
// addition, and the check below this one pins that too.
|
|
246
|
+
const COMMANDLESS = ["cohorte-loop"];
|
|
247
|
+
if (existsSync(workflowsDir)) for (const f of readdirSync(workflowsDir)) {
|
|
248
|
+
if (!f.endsWith(".js")) continue;
|
|
249
|
+
const path = `core/workflows/${f}`;
|
|
250
|
+
const m = read(path).match(/name:\s*'([^']+)'/);
|
|
251
|
+
if (!m) { fail(path, "meta has no parseable `name: '…'`"); continue; }
|
|
252
|
+
const name = m[1];
|
|
253
|
+
if (!name.startsWith(PREFIX))
|
|
254
|
+
fail(path, `meta.name '${name}' lacks the \`${PREFIX}\` prefix`);
|
|
255
|
+
else if (!existsSync(join(root, "core/commands", `${name}.md`)) && !COMMANDLESS.includes(name))
|
|
256
|
+
fail(path, `meta.name '${name}' matches no core/commands/${name}.md and is not in the ` +
|
|
257
|
+
`deliberate command-less list — a variant pair must share the name exactly, or the ` +
|
|
258
|
+
`workflow-only decision must be recorded in COMMANDLESS`);
|
|
259
|
+
}
|
|
260
|
+
if (existsSync(join(root, "core/commands/cohorte-loop.md")))
|
|
261
|
+
fail("core/commands/cohorte-loop.md", "must not exist — /cohorte-loop is workflow-only " +
|
|
262
|
+
"(no conversational fallback, by decision; see core/workflows/loop.js header)");
|
|
263
|
+
|
|
264
|
+
// The workflows dir reaching installs is covered per-file by the ci.yml dry-run
|
|
265
|
+
// assertions checked just below (the shell installers delegate to bin/cli.js).
|
|
232
266
|
|
|
233
267
|
// A new workflow script must also be KNOWN to the things that check for it, or it
|
|
234
268
|
// ships and nothing notices when an installer stops copying it. This check exists
|
|
@@ -286,11 +320,10 @@ const pkg = JSON.parse(read("package.json"));
|
|
|
286
320
|
for (const negation of ["!core/hooks/__pycache__", "!**/*.pyc"])
|
|
287
321
|
if (!(pkg.files || []).includes(negation))
|
|
288
322
|
fail("package.json", `\`files\` must carry the ${negation} negation (.npmignore cannot do this)`);
|
|
289
|
-
for (const [name, src] of Object.entries(installers))
|
|
290
|
-
if (!src.includes("__pycache__"))
|
|
291
|
-
fail(name, "never scrubs hooks/__pycache__ — cp -R would carry it into the user's .claude");
|
|
292
323
|
if (!read("bin/cli.js").includes("__pycache__"))
|
|
293
324
|
fail("bin/cli.js", "copyCore() never excludes __pycache__ from the hooks copy");
|
|
325
|
+
if (ciYmlText && !ciYmlText.includes("hooks/__pycache__"))
|
|
326
|
+
fail(".github/workflows/ci.yml", "install dry-run never asserts hooks/__pycache__ is absent — a compiled cache would ride into the user's .claude unnoticed");
|
|
294
327
|
|
|
295
328
|
// ── report ──────────────────────────────────────────────────────────────────
|
|
296
329
|
if (errors.length) {
|