cohorte 2.7.0 → 2.9.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 (40) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +15 -10
  3. package/bin/cli.js +15 -2
  4. package/core/agents/review.md +4 -2
  5. package/core/commands/cohorte-brainstorm.md +5 -1
  6. package/core/commands/cohorte-build.md +3 -1
  7. package/core/commands/cohorte-doctor.md +5 -3
  8. package/core/commands/cohorte-fix.md +6 -5
  9. package/core/commands/cohorte-fleet.md +104 -0
  10. package/core/commands/cohorte-intake.md +92 -0
  11. package/core/commands/cohorte-patch.md +6 -1
  12. package/core/commands/cohorte-retro.md +85 -0
  13. package/core/commands/cohorte-review.md +85 -23
  14. package/core/commands/cohorte-ship.md +2 -1
  15. package/core/commands/cohorte-spec.md +1 -1
  16. package/core/hooks/gate.py +10 -4
  17. package/core/workflows/audit.js +14 -2
  18. package/core/workflows/loop.js +641 -0
  19. package/core/workflows/refactor.js +21 -8
  20. package/core/workflows/review.js +92 -12
  21. package/dashboard/dist/assets/{index-D1rsbLat.js → index-vtFc6Gyc.js} +12 -12
  22. package/dashboard/dist/index.html +1 -1
  23. package/dashboard/server/doctor.js +8 -2
  24. package/dashboard/server/index.js +6 -1
  25. package/dashboard/server/kanban.js +15 -4
  26. package/dashboard/server/metrics.js +8 -1
  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 +45 -14
  32. package/profile/cohorte.config.template.yaml +1 -1
  33. package/scripts/kanban-move.sh +15 -5
  34. package/scripts/new-feature.sh.template +8 -1
  35. package/scripts/preflight.sh +10 -2
  36. package/scripts/remove-feature.sh.template +3 -1
  37. package/scripts/test-dashboard.mjs +53 -5
  38. package/scripts/test-gate.mjs +6 -0
  39. package/scripts/test-workflows.mjs +415 -6
  40. package/scripts/validate-core.mjs +68 -34
@@ -184,7 +184,7 @@ console.log("doctor.js — the /cohorte-doctor port");
184
184
  writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
185
185
  writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
186
186
  mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
187
- for (const w of ["review.js", "audit.js", "refactor.js"]) {
187
+ for (const w of ["review.js", "audit.js", "refactor.js", "loop.js"]) {
188
188
  writeFileSync(join(d, ".claude", "workflows", w), "x");
189
189
  }
190
190
  writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
@@ -464,12 +464,60 @@ console.log("doctor.js — a non-Claude runtime layout");
464
464
  check("nothing is reported broken on a healthy non-Claude install",
465
465
  s.summary.bad === 0 && s.summary.warn === 0, JSON.stringify(s.summary));
466
466
 
467
- // The metrics sink follows `<state>` too.
467
+ // The metrics sink follows `<state>` too — and workflow-stamped `tokens` aggregate
468
+ // per feature/phase while token-less conversational lines read as 0, not NaN.
468
469
  writeFileSync(join(d, ".cohorte", "pipeline-metrics.jsonl"),
469
470
  JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f", phase: "build", seconds: 10,
470
- surfaces: { api: "ok" } }) + "\n");
471
- check("metrics are read from the runtime's state dir",
472
- metrics({ projectRoot: d, globalDir: g }).batches === 1);
471
+ tokens: 12000, surfaces: { api: "ok" } }) + "\n" +
472
+ JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f", phase: "review", seconds: 5,
473
+ surfaces: { api: "SHIP:0" } }) + "\n");
474
+ const m = metrics({ projectRoot: d, globalDir: g });
475
+ check("metrics are read from the runtime's state dir", m.batches === 2);
476
+ check("workflow tokens aggregate; token-less lines count as 0",
477
+ m.features[0].totalTokens === 12000 && m.features[0].phases.build.tokens === 12000
478
+ && m.features[0].phases.review.tokens === 0,
479
+ JSON.stringify(m.features[0] && { t: m.features[0].totalTokens, p: m.features[0].phases }));
480
+ }
481
+
482
+ // ── runtime.js — stale absolute registry paths (a cloned/moved bundled core) ─
483
+ // runtimes.json records install-time ABSOLUTE paths. A committed core cloned to
484
+ // another machine (or a checkout simply moved) still carries the original paths;
485
+ // taken verbatim, every check went red on a healthy install.
486
+ console.log("runtime.js — registry paths survive a clone/move");
487
+ {
488
+ const { layouts } = require(join(root, "dashboard/server/runtime.js"));
489
+ const d = scratch();
490
+ const core = join(d, ".claude");
491
+ mkdirSync(join(core, "pipeline"), { recursive: true });
492
+ const theirRoot = join("/Users", "somebody-else", "their-checkout");
493
+ writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
494
+ claude: {
495
+ label: "Claude Code", scope: "project", core_version: "9.9.9",
496
+ paths: {
497
+ core: join(theirRoot, ".claude"),
498
+ commands: join(theirRoot, ".claude", "commands"),
499
+ agents: join(theirRoot, ".claude", "agents"),
500
+ hooks_config: join(theirRoot, ".claude", "settings.json"),
501
+ state: ".claude",
502
+ },
503
+ },
504
+ }));
505
+ const [l] = layouts({ projectRoot: d, globalDir: join(d, "no-global") });
506
+ check("agents re-rooted onto the probed checkout",
507
+ l.agents === join(d, ".claude", "agents"), l.agents);
508
+ check("hooks config re-rooted too",
509
+ l.hooksConfig === join(d, ".claude", "settings.json"), l.hooksConfig);
510
+ // A path OUTSIDE the recorded project root (a genuine machine-local absolute,
511
+ // e.g. a global agents dir) must pass through untouched.
512
+ writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
513
+ claude: {
514
+ label: "Claude Code", scope: "project", core_version: "9.9.9",
515
+ paths: { core: join(theirRoot, ".claude"), agents: "/opt/shared-agents", state: ".claude" },
516
+ },
517
+ }));
518
+ const [l2] = layouts({ projectRoot: d, globalDir: join(d, "no-global") });
519
+ check("an absolute path outside the recorded root is untouched",
520
+ l2.agents === "/opt/shared-agents", l2.agents);
473
521
  }
474
522
 
475
523
  for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
@@ -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",
@@ -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, budgetStub) {
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,10 +80,18 @@ 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
+ budgetStub || { total: null, spent: () => 0, remaining: () => Infinity },
84
+ wf || (async () => {}));
85
+ return { result, calls, prompts };
81
86
  }
82
87
 
88
+ // A budget stub whose spent() grows with every agent/workflow call — what the runtime's
89
+ // counter does — so token deltas in the scripts come out non-zero and orderable.
90
+ const tickingBudget = () => {
91
+ let n = 0;
92
+ return { total: null, spent: () => (n += 1000), remaining: () => Infinity };
93
+ };
94
+
83
95
  // A reply table keyed by label prefix; the first matching prefix wins.
84
96
  const replier = table => (prompt, opts) => {
85
97
  const label = opts.label || "";
@@ -156,7 +168,7 @@ console.log("review.js");
156
168
  problem: "p", fix: "f", outOfScope: "predates this feature; diff never touched it",
157
169
  }];
158
170
  let stagePrompt = "";
159
- const { result } = await run("review.js", (prompt, opts) => {
171
+ const { result, calls } = await run("review.js", (prompt, opts) => {
160
172
  const label = opts.label || "";
161
173
  if (label.startsWith("review:")) return { verdict: "SHIP", findings: [], deferred };
162
174
  if (label === "stage-report") { stagePrompt = prompt; return "done"; }
@@ -172,7 +184,8 @@ console.log("review.js");
172
184
  /refactor-backlog\.md/.test(stagePrompt) && /deferred:feat-x/.test(stagePrompt),
173
185
  stagePrompt.slice(0, 200));
174
186
  check("deferred are never cross-checked (no verify agent spawned)",
175
- !/verify:/.test(String(result.criticals)) && result.refutedByCrossCheck === 0);
187
+ !calls.some(c => c.startsWith("verify:")) && result.refutedByCrossCheck === 0,
188
+ calls.join(","));
176
189
  }
177
190
  {
178
191
  const { result } = await run("review.js", replier([
@@ -344,6 +357,402 @@ console.log("audit.js / refactor.js");
344
357
  result.skipped && result.skipped.backend === 2, JSON.stringify(result));
345
358
  }
346
359
 
360
+ // ── review.js — the machine verdict contract the loop reduces on ─────────────
361
+ console.log("review.js verdict contract");
362
+ {
363
+ const { result } = await run("review.js", replier([
364
+ ["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
365
+ ]));
366
+ check("red preflight ⇒ aborted: 'preflight' (what a driver branches on)",
367
+ result.aborted === "preflight", JSON.stringify(result.aborted));
368
+ }
369
+ {
370
+ // The empty-diff SHIP certified nothing (no review, no stamp) — its `next` must not
371
+ // read as "run /cohorte-ship", which would point a driver at a gate that refuses.
372
+ const { result } = await run("review.js", replier([
373
+ ["stage-diff", { surfaces: [] }], ...BASE_REVIEW,
374
+ ]));
375
+ check("empty-diff SHIP ⇒ next warns nothing was reviewed, never '/cohorte-ship'",
376
+ result.verdict === "SHIP" && !String(result.next).startsWith("/cohorte-ship") && /no review|nothing to ship/i.test(result.next),
377
+ result.next);
378
+ }
379
+ {
380
+ // blocking = CRITICAL + security counted once; blocking_items = identity, not wording:
381
+ // surface | file WITHOUT :line | first 8 words of the problem, lowercased, collapsed.
382
+ const crit = finding({ severity: "CRITICAL", file: "apps/api/a.ts:41",
383
+ problem: "Missing auth-check on POST /orders endpoint here now" });
384
+ let stagePrompt = "";
385
+ const { result } = await run("review.js", (prompt, opts) => {
386
+ const label = opts.label || "";
387
+ if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
388
+ if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
389
+ if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
390
+ if (label === "stage-report") { stagePrompt = prompt; return "done"; }
391
+ return replier(BASE_REVIEW)(prompt, opts);
392
+ });
393
+ check("blocking counts CRITICAL+security, each once", result.blocking === 1, `got ${result.blocking}`);
394
+ check("blockingItems: surface|file-no-line|8-word normalized problem",
395
+ (result.blockingItems || [])[0] === "backend|apps/api/a.ts|missing auth check on post orders endpoint here",
396
+ JSON.stringify(result.blockingItems));
397
+ check("verdict.json is staged, fingerprint computed in Bash (sha256), never by hand",
398
+ /verdict\.json/.test(stagePrompt) && /sha256sum/.test(stagePrompt), stagePrompt.slice(0, 200));
399
+ }
400
+ {
401
+ // The cross-check exists so a refuted CRITICAL cannot force a fix loop — and so an
402
+ // unrefuted one still does. Both directions, plus security ⇒ BLOCK.
403
+ const crit = finding({ severity: "CRITICAL" });
404
+ const withVerify = refuted => (prompt, opts) => {
405
+ const label = opts.label || "";
406
+ if (label.startsWith("verify:")) return { refuted, reason: refuted ? "a guard covers it" : "holds" };
407
+ if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
408
+ if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
409
+ return replier(BASE_REVIEW)(prompt, opts);
410
+ };
411
+ const kept = await run("review.js", withVerify(false));
412
+ check("unrefuted CRITICAL ⇒ REVISE, cross-check ran",
413
+ kept.result.verdict === "REVISE" && kept.calls.some(c => c.startsWith("verify:")),
414
+ JSON.stringify([kept.result.verdict, kept.result.blocking]));
415
+ const refutedRun = await run("review.js", withVerify(true));
416
+ check("refuted CRITICAL ⇒ SHIP, not a fix loop",
417
+ refutedRun.result.verdict === "SHIP" && refutedRun.result.refutedByCrossCheck === 1 && refutedRun.result.blocking === 0,
418
+ JSON.stringify([refutedRun.result.verdict, refutedRun.result.refutedByCrossCheck]));
419
+ const sec = await run("review.js", (prompt, opts) => {
420
+ const label = opts.label || "";
421
+ if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
422
+ if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [finding({ severity: "HIGH", kind: "security" })] };
423
+ if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
424
+ return replier(BASE_REVIEW)(prompt, opts);
425
+ });
426
+ check("surviving security finding ⇒ BLOCK, counted blocking",
427
+ sec.result.verdict === "BLOCK" && sec.result.blocking === 1,
428
+ JSON.stringify([sec.result.verdict, sec.result.blocking]));
429
+ // A dead cross-check verifier must KEEP the finding (a real CRITICAL must not die
430
+ // on a transport error), never silently drop it.
431
+ const deadVerify = await run("review.js", (prompt, opts) => {
432
+ const label = opts.label || "";
433
+ if (label.startsWith("verify:")) return null;
434
+ if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
435
+ if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
436
+ return replier(BASE_REVIEW)(prompt, opts);
437
+ });
438
+ check("dead verifier ⇒ the CRITICAL is kept, verdict REVISE",
439
+ deadVerify.result.verdict === "REVISE" && deadVerify.result.blocking === 1,
440
+ JSON.stringify([deadVerify.result.verdict, deadVerify.result.blocking]));
441
+ }
442
+
443
+ // ── refactor.js — args scoping + the retry round's cleared accumulation ──────
444
+ console.log("refactor.js retry & args");
445
+ {
446
+ // Bare-string shorthand names a DOMAIN — it must never widen to 'all' (that
447
+ // dispatched code-editing implementers on every big domain).
448
+ let backlogPrompt = "";
449
+ await run("refactor.js", (prompt, opts) => {
450
+ const label = opts.label || "";
451
+ if (label === "profile") return PROFILE;
452
+ if (label === "read-backlog") { backlogPrompt = prompt; return { domains: [] }; }
453
+ return "ok";
454
+ }, "backend");
455
+ check("bare-string args scope to that domain, not 'all'",
456
+ /Requested domains: backend/.test(backlogPrompt) && !/Requested domains: all/.test(backlogPrompt),
457
+ backlogPrompt.slice(-140));
458
+ }
459
+ {
460
+ // The re-verify covers only the retried items; round 1's verified clears must
461
+ // survive the merge or the backlog un-ticks finished work.
462
+ const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
463
+ let tickPrompt = "";
464
+ const { result } = await run("refactor.js", (prompt, opts) => {
465
+ const label = opts.label || "";
466
+ if (label === "profile") return PROFILE;
467
+ if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
468
+ if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
469
+ if (label === "reverify:backend") return { cleared: items.slice(3), remaining: [], gatesGreen: true };
470
+ if (label === "tick-backlog") { tickPrompt = prompt; return "done"; }
471
+ return "handoff";
472
+ }, { domains: "all" });
473
+ check("retry round keeps round-1 clears (5/5, not 2/5)",
474
+ result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
475
+ JSON.stringify(result.domains));
476
+ check("all five cleared items reach the ticker", items.every(i => tickPrompt.includes(i)),
477
+ tickPrompt.slice(0, 160));
478
+ }
479
+ {
480
+ // A dead re-verifier loses only the retry round's claim: round 1's clears stay
481
+ // cleared, the retried items stay open — never reset to all-five-open.
482
+ const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
483
+ const { result } = await run("refactor.js", (prompt, opts) => {
484
+ const label = opts.label || "";
485
+ if (label === "profile") return PROFILE;
486
+ if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
487
+ if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
488
+ if (label === "reverify:backend") return null;
489
+ if (label === "tick-backlog") return "done";
490
+ return "handoff";
491
+ }, { domains: "all" });
492
+ check("dead re-verifier ⇒ round-1 clears kept, retried items open",
493
+ result.domains.backend.cleared === 3 && result.domains.backend.remaining === 2,
494
+ JSON.stringify(result.domains));
495
+ }
496
+ {
497
+ // Gates red with everything cleared: the retry must NOT re-open verified items —
498
+ // with a dead re-verifier the same lines once sat in `cleared` AND `remaining`
499
+ // (ticked off the backlog while reported open).
500
+ const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
501
+ const { result } = await run("refactor.js", (prompt, opts) => {
502
+ const label = opts.label || "";
503
+ if (label === "profile") return PROFILE;
504
+ if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
505
+ if (label === "verify:backend") return { cleared: items, remaining: [], gatesGreen: false, failures: "lint red" };
506
+ if (label === "reverify:backend") return null;
507
+ if (label === "tick-backlog") return "done";
508
+ return "handoff";
509
+ }, { domains: "all" });
510
+ check("gates-red + all cleared + dead re-verifier ⇒ no cleared/remaining overlap",
511
+ result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
512
+ JSON.stringify(result.domains));
513
+ }
514
+
515
+ // ── loop.js — build → review → [fix → review]*, unattended ──────────────────
516
+ // The reducer's facts come from stubs, but the DECISIONS under test (freshness,
517
+ // precondition gates, exit ordering, treading water) all live in script code —
518
+ // which is exactly why they live there and not in an agent prompt.
519
+ console.log("loop.js");
520
+
521
+ const loopFacts = (over = {}) => ({
522
+ now: { epoch: 1000000, iso: "2026-08-22T00:00:00Z" },
523
+ spec: { exists: true, status: "frozen", kind: "", mtimeEpoch: 500, designFiles: [] },
524
+ readiness: { exists: true, mtimeEpoch: 600, verdict: "READY", gaps: [], surfaces: ["backend", "frontend"] },
525
+ contractFile: { exists: true },
526
+ build: { exists: false },
527
+ loop: { exists: false },
528
+ ...over,
529
+ });
530
+ const FRESH_BUILD = { exists: true, mtimeEpoch: 600, dead: [] };
531
+ const loopReply = (facts, over = {}) => (prompt, opts) => {
532
+ const label = opts.label || "";
533
+ if (label === "profile") return over.profile || PROFILE;
534
+ if (label === "preconditions") return facts;
535
+ if (label.startsWith("state:") || label === "close") return "done 1000001";
536
+ if (label.startsWith("ingest:")) return "ingest" in over ? over.ingest
537
+ : { items: [{ line: "- [ ] CRITICAL · apps/api/a.ts:3 · quality · f", file: "apps/api/a.ts" }] };
538
+ if (label.startsWith("tick:")) return "done";
539
+ if (label.startsWith("build") || label.startsWith("fix")) {
540
+ return "impl" in over ? over.impl : "handoff\n## Remediation addressed\n- apps/api/a.ts:3 — fixed";
541
+ }
542
+ return "ok";
543
+ };
544
+ const reviewOf = over => ({
545
+ verdict: "REVISE", blocking: 1, blockingItems: ["backend|apps/api/a.ts|p"],
546
+ unreviewedSurfaces: [], deferred: 0, ...over,
547
+ });
548
+ const SHIP_CLEAN = { verdict: "SHIP", blocking: 0, blockingItems: [], unreviewedSurfaces: [], deferred: 0, next: "/cohorte-ship feat-x (DoD ticked + freshness stamped)" };
549
+
550
+ {
551
+ // THE ordering regression: a dead reviewer's zero findings must not read as ship.
552
+ // unreviewed is checked BEFORE blocking — the other way round ships unread code.
553
+ const wf = async () => reviewOf({ blocking: 0, unreviewedSurfaces: ["backend"] });
554
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
555
+ check("unreviewed + blocking 0 ⇒ abort/unreviewed, NOT ship",
556
+ result.outcome === "abort" && result.reason === "unreviewed", JSON.stringify([result.outcome, result.reason]));
557
+ }
558
+ {
559
+ // Same blocking identity two consecutive rounds ⇒ treading water at round 2,
560
+ // not burned down to maxRounds.
561
+ const wf = async () => reviewOf();
562
+ const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
563
+ check("identical fingerprint twice ⇒ abort/treading-water", result.reason === "treading-water", result.reason);
564
+ check("…at round 2, not maxRounds", result.rounds === 2, `rounds ${result.rounds}`);
565
+ check("…after exactly one fix round", calls.filter(c => c.startsWith("fix:")).length === 1,
566
+ calls.filter(c => c.startsWith("fix")).join(","));
567
+ }
568
+ {
569
+ // NOT-READY is the one outcome more passes cannot fix — and a precondition that
570
+ // aborts AFTER spawning has not aborted: only profile + preconditions may run.
571
+ const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 600, verdict: "NOT-READY", gaps: ["contract|POST /x|no shape"], surfaces: ["backend"] } });
572
+ const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
573
+ check("NOT-READY ⇒ abort/precondition with the gaps verbatim",
574
+ result.reason === "precondition" && (result.gaps || []).length === 1, JSON.stringify(result));
575
+ check("NOT-READY ⇒ zero dispatches (profile + facts only)",
576
+ calls.join(",") === "profile,preconditions", calls.join(","));
577
+ }
578
+ {
579
+ // readiness older than the spec describes a spec that no longer exists ⇒ absent.
580
+ const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 400, verdict: "READY", gaps: [], surfaces: ["backend"] } });
581
+ const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
582
+ check("stale readiness.json ⇒ treated as absent ⇒ abort/precondition",
583
+ result.reason === "precondition" && /older than the spec/.test(result.detail), JSON.stringify(result.detail));
584
+ check("stale readiness ⇒ zero dispatches", calls.join(",") === "profile,preconditions", calls.join(","));
585
+ }
586
+ {
587
+ // A blocking finding on the contract file is /cohorte-fix §1's lead-only step.
588
+ const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
589
+ const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/feat-x.ts|response shape wrong"] });
590
+ const { result, calls } = await run("loop.js",
591
+ loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
592
+ check("blocking finding on the contract file ⇒ abort/contract-change",
593
+ result.reason === "contract-change", result.reason);
594
+ check("contract-change ⇒ no fix round dispatched", !calls.some(c => c.startsWith("fix")), calls.join(","));
595
+ }
596
+ {
597
+ // Dead build implementers: retried ONCE, byte-identical, then abort — never "ok".
598
+ const { result, calls, prompts } = await run("loop.js",
599
+ loopReply(loopFacts(), { impl: null }), { feature: "feat-x" }, async () => SHIP_CLEAN);
600
+ check("dead implementers ⇒ abort/dead-implementers", result.reason === "dead-implementers", result.reason);
601
+ check("each dead surface retried exactly once",
602
+ calls.filter(c => c === "build:backend").length === 1 && calls.filter(c => c === "build-retry:backend").length === 1,
603
+ calls.join(","));
604
+ check("the retry is byte-identical to the dispatch",
605
+ prompts["build:backend"] === prompts["build-retry:backend"]);
606
+ }
607
+ {
608
+ // Deferred findings never cost a round: 9 deferred + 0 blocking ships in one.
609
+ const wf = async () => ({ ...SHIP_CLEAN, deferred: 9 });
610
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
611
+ check("deferred 9 + blocking 0 ⇒ ship in one round",
612
+ result.outcome === "ship" && result.rounds === 1 && result.deferred === 9, JSON.stringify(result));
613
+ }
614
+ {
615
+ // The degraded preflight verdict has no unreviewed/blocking keys — the reducer
616
+ // must branch on `aborted`, not crash on a missing field.
617
+ const wf = async () => ({ verdict: "ABORTED", aborted: "preflight", reason: "preflight red" });
618
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
619
+ check("child preflight abort ⇒ abort/preflight (no crash on the degraded shape)",
620
+ result.outcome === "abort" && result.reason === "preflight", JSON.stringify([result.outcome, result.reason]));
621
+ }
622
+ {
623
+ // A fresh build.json with no dead surfaces means the work is on disk — entering
624
+ // after a conversational /cohorte-build must not rebuild it.
625
+ const { result, calls } = await run("loop.js",
626
+ loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, async () => SHIP_CLEAN);
627
+ check("fresh build.json ⇒ build phase skipped", !calls.some(c => c.startsWith("build")), calls.join(","));
628
+ check("…and the run still ships", result.outcome === "ship", result.outcome);
629
+ // …and a STALE build.json builds: the work on disk predates the spec.
630
+ const stale = await run("loop.js",
631
+ loopReply(loopFacts({ build: { exists: true, mtimeEpoch: 400, dead: [] } })), { feature: "feat-x" }, async () => SHIP_CLEAN);
632
+ check("stale build.json ⇒ build phase runs", stale.calls.some(c => c.startsWith("build:")), stale.calls.join(","));
633
+ }
634
+ {
635
+ // Resume: an unfinished, fresh loop.json restores round + the treading-water key,
636
+ // so a run killed mid-round costs a re-review, not a restart.
637
+ 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 }] });
638
+ const facts = loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: prev } });
639
+ const wf = async () => reviewOf();
640
+ const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, wf);
641
+ check("resume: same items as the resumed round ⇒ treading-water immediately",
642
+ result.reason === "treading-water" && !calls.some(c => c.startsWith("fix")), JSON.stringify([result.reason, result.rounds]));
643
+ check("resume: history carries the prior rounds", result.rounds === 3, `rounds ${result.rounds}`);
644
+ // A FINISHED loop.json (outcome set) must not resume — fresh run from round 1.
645
+ const done = JSON.stringify({ id: "feat-x", round: 4, outcome: "abort", lastItems: ["backend|apps/api/a.ts|p"], history: [] });
646
+ const r2 = await run("loop.js",
647
+ loopReply(loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: done } })),
648
+ { feature: "feat-x" }, wf);
649
+ check("a finished loop.json does not resume (round 1, fix dispatched)",
650
+ r2.calls.some(c => c.startsWith("fix:")), r2.calls.join(","));
651
+ }
652
+ {
653
+ // The loop EDITS the spec as it runs (status stamps, Remediation appends), so on
654
+ // resume the spec's mtime is NEWER than readiness.json — freshness must be measured
655
+ // against the baseline stored in loop.json, or the loop's own footprint aborts its
656
+ // own resume with "readiness is older than the spec".
657
+ const prev = JSON.stringify({ id: "feat-x", round: 2, specMtime: 500, lastItems: ["backend|apps/api/old.ts|p"], history: [{ round: 1, blocking: 3 }] });
658
+ const facts = loopFacts({
659
+ spec: { exists: true, status: "in-progress", kind: "", mtimeEpoch: 900, designFiles: [] },
660
+ build: FRESH_BUILD,
661
+ loop: { exists: true, mtimeEpoch: 700, raw: prev },
662
+ });
663
+ const { result } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
664
+ check("resume survives the loop's own spec writes (baseline mtime, not current)",
665
+ result.outcome === "ship", JSON.stringify([result.outcome, result.reason, result.detail]));
666
+ check("resume keeps only rounds before the resumed one (no double count)",
667
+ result.rounds === 2, `rounds ${result.rounds}`);
668
+ }
669
+ {
670
+ // A real verdict whose report never landed on disk: the fix round would ingest the
671
+ // PREVIOUS round's report. Resumable give-up, never a fix round on stale findings.
672
+ const wf = async () => reviewOf({ reportStaged: false });
673
+ const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
674
+ check("blocking verdict + unstaged report ⇒ abort/report-not-staged, no fix round",
675
+ result.reason === "report-not-staged" && !calls.some(c => c.startsWith("fix")),
676
+ JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
677
+ }
678
+ {
679
+ // Ordinary surface code can live UNDER contract.path (a `shared` surface at the
680
+ // contract package) — only the feature's contract FILE is the lead-only abort.
681
+ const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
682
+ const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/utils.ts|helper broken"] });
683
+ const { result, calls } = await run("loop.js",
684
+ loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
685
+ check("a finding elsewhere under contract.path is NOT contract-change",
686
+ result.reason !== "contract-change" && calls.some(c => c.startsWith("fix:")),
687
+ JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
688
+ }
689
+ {
690
+ // A dead fix implementer still gets its metrics line — an incomplete batch is the
691
+ // batch worth recording — written BEFORE the abort.
692
+ const wf = async () => reviewOf();
693
+ const { result, prompts } = await run("loop.js",
694
+ loopReply(loopFacts({ build: FRESH_BUILD }), { impl: null }), { feature: "feat-x" }, wf);
695
+ check("dead fix implementer ⇒ abort, with the fix metrics written first (\"dead\")",
696
+ result.reason === "dead-implementers" && /"backend":"dead"/.test(prompts["state:fixed-1"] || ""),
697
+ JSON.stringify([result.reason, (prompts["state:fixed-1"] || "").slice(-120)]));
698
+ }
699
+ {
700
+ // maxRounds is the last net: distinct findings each round burn down to it.
701
+ let n = 0;
702
+ const wf = async () => reviewOf({ blockingItems: [`backend|apps/api/f${++n}.ts|p`] });
703
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })),
704
+ { feature: "feat-x", maxRounds: 2 }, wf);
705
+ check("maxRounds reached with distinct findings ⇒ abort/max-rounds at that round",
706
+ result.reason === "max-rounds" && result.rounds === 2, JSON.stringify([result.reason, result.rounds]));
707
+ }
708
+ {
709
+ // A ship with surviving HIGH/MEDIUM has NO freshness stamp — the loop must relay
710
+ // review's routing (which says /cohorte-fix), not print "/cohorte-ship".
711
+ 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" });
712
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
713
+ check("ship with HIGH leftovers ⇒ next relays review's /cohorte-fix routing",
714
+ String(result.next).startsWith("/cohorte-fix"), result.next);
715
+ }
716
+ {
717
+ // Dead ingest = no Remediation items were appended; dispatching blind would
718
+ // re-build surfaces with no instructions. Abort, resumable at this round.
719
+ const wf = async () => reviewOf();
720
+ const { result } = await run("loop.js",
721
+ loopReply(loopFacts({ build: FRESH_BUILD }), { ingest: null }), { feature: "feat-x" }, wf);
722
+ check("dead ingest agent ⇒ abort/ingest-died, items never invented",
723
+ result.reason === "ingest-died", result.reason);
724
+ }
725
+ {
726
+ // Token accounting: the run's history and metrics lines carry approximate output
727
+ // tokens from budget.spent() — the figure the conversational path cannot record.
728
+ const wf = async () => reviewOf();
729
+ const { result, prompts } = await run("loop.js", loopReply(loopFacts()),
730
+ { feature: "feat-x" }, wf, tickingBudget());
731
+ check("loop: history rounds carry a tokens figure",
732
+ result.history.length > 0 && result.history.every(h => typeof h.tokens === "number" && h.tokens > 0),
733
+ JSON.stringify(result.history));
734
+ check("loop: build metrics line carries tokens",
735
+ /"phase":"build".*"tokens":[1-9]/.test(prompts["state:built"] || ""),
736
+ (prompts["state:built"] || "").slice(-200));
737
+ check("loop: the run total is returned", typeof result.tokens === "number" && result.tokens > 0,
738
+ String(result.tokens));
739
+ let stagePrompt = "";
740
+ await run("review.js", (prompt, opts) => {
741
+ if (opts.label === "stage-report") { stagePrompt = prompt; return "done"; }
742
+ return replier([["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW])(prompt, opts);
743
+ }, { feature: "feat-x" }, undefined, tickingBudget());
744
+ check("review: metrics line carries tokens",
745
+ /"phase":"review".*"tokens":[1-9]/.test(stagePrompt), stagePrompt.slice(-200));
746
+ }
747
+ {
748
+ // workflow() unavailable (no runtime / review.js not installed) ⇒ explicit refusal,
749
+ // never a conversational fallback.
750
+ const wf = async () => { throw new Error("unknown workflow: cohorte-review"); };
751
+ const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
752
+ check("review workflow unavailable ⇒ abort/review-workflow-unavailable",
753
+ result.reason === "review-workflow-unavailable", result.reason);
754
+ }
755
+
347
756
  console.log("");
348
757
  if (failures) { console.error(`test-workflows: ${failures} failure(s)`); process.exit(1); }
349
758
  console.log("test-workflows: OK");