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.
Files changed (42) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +11 -9
  3. package/bin/cli.js +15 -2
  4. package/core/agents/review.md +4 -2
  5. package/core/commands/cohorte-build.md +3 -1
  6. package/core/commands/cohorte-doctor.md +27 -4
  7. package/core/commands/cohorte-fix.md +6 -5
  8. package/core/commands/cohorte-review.md +32 -22
  9. package/core/commands/cohorte-ship.md +2 -1
  10. package/core/commands/cohorte-spec.md +1 -1
  11. package/core/hooks/gate.py +10 -4
  12. package/core/runtimes/claude.json +1 -0
  13. package/core/runtimes/codex.json +1 -0
  14. package/core/runtimes/cursor.json +1 -0
  15. package/core/runtimes/gemini.json +1 -0
  16. package/core/runtimes/opencode.json +1 -0
  17. package/core/templates/design-brief.md +12 -3
  18. package/core/workflows/audit.js +20 -5
  19. package/core/workflows/loop.js +617 -0
  20. package/core/workflows/refactor.js +21 -8
  21. package/core/workflows/review.js +81 -12
  22. package/dashboard/dist/assets/{index-D1rsbLat.js → index-DO3_nq2Q.js} +1 -1
  23. package/dashboard/dist/index.html +1 -1
  24. package/dashboard/server/doctor.js +11 -3
  25. package/dashboard/server/index.js +6 -1
  26. package/dashboard/server/kanban.js +15 -4
  27. package/dashboard/server/runtime.js +20 -1
  28. package/install.ps1 +16 -333
  29. package/install.sh +27 -297
  30. package/package.json +1 -1
  31. package/profile/SCHEMA.md +34 -10
  32. package/profile/cohorte.config.template.yaml +1 -1
  33. package/scripts/kanban-move.sh +15 -5
  34. package/scripts/metrics/prices.json +6 -3
  35. package/scripts/new-feature.sh.template +8 -1
  36. package/scripts/preflight.sh +10 -2
  37. package/scripts/remove-feature.sh.template +3 -1
  38. package/scripts/test-dashboard.mjs +42 -1
  39. package/scripts/test-gate.mjs +6 -0
  40. package/scripts/test-metrics.mjs +2 -2
  41. package/scripts/test-workflows.mjs +386 -6
  42. package/scripts/validate-core.mjs +64 -31
@@ -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",
@@ -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*15 = 30000 (subagent)
148
- check('cost sums the cache tiers at their own rates', Number(build.cost.total.toFixed(6)), 0.07925);
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. Returns { result, calls }.
53
- async function run(script, reply, args = { feature: "feat-x" }) {
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 }, async () => {});
80
- return { result, calls };
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
- !/verify:/.test(String(result.criticals)) && result.refutedByCrossCheck === 0);
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 shipped by
60
- // both installers (a new agent that install.sh doesn't copy never reaches
61
- // a global install the exact bug that motivated this check).
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 installSh = read("install.sh");
65
- const installPs1 = read("install.ps1");
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 (!installSh.includes(`core/agents/${f}`))
88
- fail("install.sh", `does not copy core/agents/${f} (copy_fixed_agents)`);
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
- if (NO_TELEMETRY.test(read(rel)))
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 copied by BOTH shell installers. Callers chain these
178
- // with `|| true`, so one an installer forgets is a silent no-op forever — no kanban
179
- // card moves, no error. CI is the only place this is loud.
180
- // The third installer, bin/cli.js (what the `cohorte` CLI runs), copies by rule rather
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
- for (const [name, src] of Object.entries(installers))
190
- if (!src.includes(`scripts/${f}`) && !src.includes(`scripts\\${f}`))
191
- fail(name, `never copies scripts/${f} into pipeline/scripts/ (silent no-op at runtime)`);
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
- // Both shell installers must copy the workflows dir (bin/cli.js copies by rule,
227
- // covered by the ci.yml dry-run).
228
- if (!installSh.includes("core/workflows"))
229
- fail("install.sh", "does not copy core/workflows (copy_core)");
230
- if (!installPs1.includes("core\\workflows"))
231
- fail("install.ps1", "does not copy core\\workflows (Copy-Core)");
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) {