cohorte 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // Behavioural tests for scripts/loop.sh — the autonomous /review ⇄ /fix driver.
3
+ //
4
+ // The driver is pure shell around two JSON files it does not write, so it is
5
+ // testable end-to-end by putting a FAKE `claude` on PATH that produces those files
6
+ // per phase. What is pinned here cannot be seen by any structural check:
7
+ //
8
+ // · exit 4 — /build's readiness gate said NOT-READY, so no pass count helps
9
+ // · exit 0/3 leave the right TERMINAL status in the spec's front-matter, which is
10
+ // what makes an interrupted loop resumable (SCHEMA.md §Spec status)
11
+ // · the front-matter stamps are written with awk on every platform — a `sed -i`
12
+ // would pass on GNU and corrupt every spec on BSD/macOS
13
+ // · --resume continues at the recorded pass instead of re-paying passes 1..n-1
14
+ // · a spec with no front-matter still runs (the stamps are a silent no-op)
15
+ //
16
+ // node scripts/test-loop.mjs
17
+
18
+ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const root = fileURLToPath(new URL("..", import.meta.url));
25
+ const LOOP = join(root, "scripts/loop.sh");
26
+
27
+ let failures = 0;
28
+ const check = (name, cond, detail = "") => {
29
+ if (cond) console.log(` ✓ ${name}`);
30
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
31
+ };
32
+
33
+ const FM = `---
34
+ feature_id: feat-x
35
+ title: Feat X
36
+ status: frozen # draft → frozen → in-progress → in-review → shipped · blocked
37
+ branch: feature/feat-x
38
+ ---
39
+
40
+ # Feat X
41
+ `;
42
+
43
+ // A fake `claude`: reads the phase out of the `-p "/<cmd> <id>"` argument and
44
+ // writes whatever the scenario says that phase produces. `$PHASES` is a
45
+ // newline-separated script of `<cmd>:<what to write>` steps, consumed in order,
46
+ // so a scenario can make pass 1 and pass 2 differ.
47
+ const FAKE_CLAUDE = `#!/usr/bin/env bash
48
+ set -u
49
+ prompt=""
50
+ while [ $# -gt 0 ]; do
51
+ case "$1" in -p) prompt="$2"; shift 2 ;; *) shift ;; esac
52
+ done
53
+ cmd="\${prompt%% *}"; cmd="\${cmd#/}"
54
+ n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
55
+ n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
56
+ step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
57
+ echo "fake claude: phase=$cmd step=$step"
58
+ want="\${step%%:*}"; do_what="\${step#*:}"
59
+ [ "$want" = "$cmd" ] || { echo "fake claude: expected /$want, got /$cmd" >&2; exit 9; }
60
+ mkdir -p specs/reports
61
+ case "$do_what" in
62
+ notready)
63
+ printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"NOT-READY","gaps":["contract|POST /o|no success shape"],"surfaces":["backend"]}' \\
64
+ >specs/reports/feat-x.readiness.json ;;
65
+ ready)
66
+ printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
67
+ >specs/reports/feat-x.readiness.json ;;
68
+ clean)
69
+ printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"SHIP","findings":2,"blocking":0,"deferred":2,"unreviewed":[],"fingerprint":""}' \\
70
+ >specs/reports/feat-x.verdict.json ;;
71
+ deadreviewer)
72
+ printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":0,"blocking":0,"deferred":0,"unreviewed":["backend"],"fingerprint":""}' \\
73
+ >specs/reports/feat-x.verdict.json ;;
74
+ deadimplementer)
75
+ printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
76
+ >specs/reports/feat-x.readiness.json
77
+ printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"dead","frontend":"ok"},"dead":["backend"]}' \\
78
+ >specs/reports/feat-x.build.json ;;
79
+ blocking)
80
+ printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":3,"blocking":2,"deferred":1,"fingerprint":"aaaa1111bbbb2222"}' \\
81
+ >specs/reports/feat-x.verdict.json ;;
82
+ noop) : ;;
83
+ esac
84
+ exit 0
85
+ `;
86
+
87
+ // One scratch repo per scenario: a git checkout (loop.sh cds to its toplevel), a
88
+ // spec, the fake claude on PATH, and the phase script it plays out.
89
+ function scenario(phases, { frontmatter = FM } = {}) {
90
+ const dir = mkdtempSync(join(tmpdir(), "cohorte-loop-"));
91
+ const git = (...a) => execFileSync("git", ["-C", dir, ...a], { stdio: "ignore" });
92
+ git("init", "-q");
93
+ git("config", "user.email", "t@t.t");
94
+ git("config", "user.name", "t");
95
+ mkdirSync(join(dir, "specs/reports"), { recursive: true });
96
+ writeFileSync(join(dir, "specs/feat-x.md"), frontmatter);
97
+ git("add", "-A");
98
+ git("commit", "-qm", "init");
99
+
100
+ const bin = join(dir, "bin");
101
+ mkdirSync(bin);
102
+ writeFileSync(join(dir, "phases"), phases.join("\n") + "\n");
103
+ writeFileSync(join(bin, "claude"), FAKE_CLAUDE);
104
+ chmodSync(join(bin, "claude"), 0o755);
105
+ return { dir, bin };
106
+ }
107
+
108
+ function runLoop({ dir, bin }, args) {
109
+ const r = spawnSync("bash", [LOOP, ...args], {
110
+ cwd: dir,
111
+ encoding: "utf8",
112
+ env: {
113
+ ...process.env,
114
+ PATH: `${bin}:${process.env.PATH}`,
115
+ SCEN_DIR: dir,
116
+ CLAUDE_FLAGS: "--permission-mode acceptEdits",
117
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
118
+ GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
119
+ },
120
+ });
121
+ const spec = readFileSync(join(dir, "specs/feat-x.md"), "utf8");
122
+ const fm = k => {
123
+ const m = spec.match(new RegExp(`^${k}:\\s*([^#\\n]*)`, "m"));
124
+ return m ? m[1].trim() : null;
125
+ };
126
+ return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm };
127
+ }
128
+
129
+ console.log("loop.sh — readiness gate");
130
+ {
131
+ const s = scenario(["build:notready"]);
132
+ const r = runLoop(s, ["feat-x"]);
133
+ check("NOT-READY ⇒ exit 4, not 2", r.code === 4, `got ${r.code}: ${r.out.trim().split("\n").pop()}`);
134
+ check("NOT-READY ⇒ says the spec is not implementable",
135
+ /not implementable/i.test(r.out), r.out.trim().split("\n").pop());
136
+ check("NOT-READY ⇒ points at /spec", /\/spec feat-x/.test(r.out));
137
+ check("NOT-READY ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
138
+ check("NOT-READY ⇒ no review ran (the gate is the point)", !/phase=review/.test(r.out));
139
+ check("NOT-READY ⇒ the build stamp is NOT written",
140
+ !existsSync(join(s.dir, "specs/reports/feat-x.built")));
141
+ }
142
+
143
+ console.log("loop.sh — clean run");
144
+ {
145
+ const s = scenario(["build:ready", "review:clean"]);
146
+ const r = runLoop(s, ["feat-x"]);
147
+ check("clean ⇒ exit 0", r.code === 0, `got ${r.code}: ${r.out}`);
148
+ check("clean ⇒ status in-review (ready to /ship)", r.fm("status") === "in-review", r.fm("status"));
149
+ check("clean ⇒ loop state cleared", r.fm("loop_pass") === "0" && r.fm("loop_phase") === "done",
150
+ `${r.fm("loop_pass")}/${r.fm("loop_phase")}`);
151
+ check("clean ⇒ the deferred count is named, not dropped",
152
+ /2 deferred finding\(s\) parked/.test(r.out), r.out.trim().split("\n").pop());
153
+ check("clean ⇒ the status comment survives the awk rewrite",
154
+ /^status: in-review # draft/m.test(r.spec), r.spec.split("\n")[3]);
155
+ }
156
+
157
+ console.log("loop.sh — a dead subagent is never a clean result");
158
+ {
159
+ // A dead implementer: /build finishes fine having built one surface of two. Reviewing
160
+ // that would spend N reviewers auditing a half-built feature and report its holes as
161
+ // findings to fix — the wrong diagnosis at the wrong price.
162
+ const s = scenario(["build:deadimplementer"]);
163
+ const r = runLoop(s, ["feat-x"]);
164
+ check("dead implementer ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
165
+ check("dead implementer ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
166
+ check("dead implementer ⇒ names the cause", /implementer died/.test(r.out),
167
+ r.out.trim().split("\n").pop());
168
+ check("dead implementer ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
169
+ }
170
+ {
171
+ // THE dangerous one: blocking == 0 because the only reviewer that could have found
172
+ // something never answered. Exiting 0 here would report "clean" about unread code and
173
+ // send the human to /ship.
174
+ const s = scenario(["build:ready", "review:deadreviewer"]);
175
+ const r = runLoop(s, ["feat-x"]);
176
+ check("dead reviewer + blocking 0 ⇒ NOT exit 0", r.code !== 0, `got ${r.code}: ${r.out}`);
177
+ check("dead reviewer ⇒ exit 2 (no usable verdict)", r.code === 2, `got ${r.code}`);
178
+ check("dead reviewer ⇒ names the unreviewed surface", /reviewer died/.test(r.out),
179
+ r.out.trim().split("\n").pop());
180
+ check("dead reviewer ⇒ never says clean", !/✓ clean/.test(r.out));
181
+ check("dead reviewer ⇒ spec is NOT left in-review", r.fm("status") === "blocked", r.fm("status"));
182
+ }
183
+
184
+ console.log("loop.sh — non-convergent + resume");
185
+ {
186
+ const s = scenario(["build:ready", "review:blocking", "fix:noop", "review:blocking"]);
187
+ const r = runLoop(s, ["feat-x", "--max=4"]);
188
+ check("same fingerprint twice ⇒ exit 3", r.code === 3, `got ${r.code}: ${r.out}`);
189
+ check("non-convergent ⇒ status blocked", r.fm("status") === "blocked", r.fm("status"));
190
+ check("non-convergent ⇒ the pass it reached is recorded (resume anchor)",
191
+ r.fm("loop_pass") === "2", r.fm("loop_pass"));
192
+ check("non-convergent ⇒ the phase is recorded", r.fm("loop_phase") === "review", r.fm("loop_phase"));
193
+
194
+ // Resume: the recorded pass is where it picks up — passes 1..n-1 are not re-paid.
195
+ writeFileSync(join(s.dir, "count"), "0");
196
+ writeFileSync(join(s.dir, "phases"), "review:clean\n");
197
+ const r2 = runLoop(s, ["feat-x", "--max=4", "--resume"]);
198
+ check("--resume ⇒ announces the pass it continues from",
199
+ /resuming at review pass 2/.test(r2.out), r2.out.trim().split("\n")[0]);
200
+ check("--resume ⇒ skips the build (the stamp is there)", !/phase=build/.test(r2.out));
201
+ check("--resume ⇒ finishes clean from there", r2.code === 0, `got ${r2.code}: ${r2.out}`);
202
+ check("--resume ⇒ reports the resumed pass count, not 1",
203
+ /after 2 review pass\(es\)/.test(r2.out), r2.out.trim().split("\n").pop());
204
+ }
205
+ {
206
+ const s = scenario(["review:clean"]);
207
+ // A resume anchor past the ceiling is a usage error, not a silent restart at 1.
208
+ writeFileSync(join(s.dir, "specs/feat-x.md"), FM.replace("branch:", "loop_pass: 9\nbranch:"));
209
+ const r = runLoop(s, ["feat-x", "--max=3", "--resume"]);
210
+ check("--resume past --max ⇒ exit 64 with the reason", r.code === 64 && /raise --max/.test(r.out),
211
+ `${r.code}: ${r.out.trim()}`);
212
+ }
213
+
214
+ console.log("loop.sh — a spec with no front-matter still runs");
215
+ {
216
+ const s = scenario(["build:ready", "review:clean"], { frontmatter: "# Feat X\n\nno front-matter\n" });
217
+ const r = runLoop(s, ["feat-x"]);
218
+ check("no front-matter ⇒ still exits 0 (stamps are a silent no-op)", r.code === 0,
219
+ `got ${r.code}: ${r.out}`);
220
+ check("no front-matter ⇒ the spec is left untouched",
221
+ r.spec === "# Feat X\n\nno front-matter\n", JSON.stringify(r.spec));
222
+ check("no front-matter ⇒ no stray temp file",
223
+ !existsSync(join(s.dir, "specs/feat-x.md.loop.tmp")));
224
+ }
225
+
226
+ if (failures) { console.error(`\ntest-loop: ${failures} failure(s)`); process.exit(1); }
227
+ console.log("\ntest-loop: OK");
@@ -146,6 +146,34 @@ console.log("review.js");
146
146
  ]));
147
147
  check("SHIP + only LOW ⇒ next is /ship", String(result.next).startsWith("/ship"), result.next);
148
148
  }
149
+ {
150
+ // Deferred findings are real but out of the feature's scope: they must be
151
+ // counted and routed to the backlog, yet move NEITHER the verdict nor `clean`.
152
+ // Both halves matter — a deferred item that blocks costs a fix loop it was
153
+ // deferred out of, and one that is dropped is the leak the section exists to close.
154
+ const deferred = [{
155
+ severity: "HIGH", file: "apps/api/legacy.ts", line: 9, kind: "quality",
156
+ problem: "p", fix: "f", outOfScope: "predates this feature; diff never touched it",
157
+ }];
158
+ let stagePrompt = "";
159
+ const { result } = await run("review.js", (prompt, opts) => {
160
+ const label = opts.label || "";
161
+ if (label.startsWith("review:")) return { verdict: "SHIP", findings: [], deferred };
162
+ if (label === "stage-report") { stagePrompt = prompt; return "done"; }
163
+ return replier(BASE_REVIEW)(prompt, opts);
164
+ });
165
+ check("deferred-only ⇒ verdict still SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
166
+ check("deferred-only ⇒ next is /ship (not a fix loop)",
167
+ String(result.next).startsWith("/ship"), result.next);
168
+ check("deferred are counted (both surfaces)", result.deferred === 2, `got ${result.deferred}`);
169
+ check("deferred stay out of the severity counts",
170
+ Object.values(result.counts).every(n => n === 0), JSON.stringify(result.counts));
171
+ check("deferred are routed to the refactor backlog",
172
+ /refactor-backlog\.md/.test(stagePrompt) && /deferred:feat-x/.test(stagePrompt),
173
+ stagePrompt.slice(0, 200));
174
+ check("deferred are never cross-checked (no verify agent spawned)",
175
+ !/verify:/.test(String(result.criticals)) && result.refutedByCrossCheck === 0);
176
+ }
149
177
  {
150
178
  const { result } = await run("review.js", replier([
151
179
  ["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
@@ -23,11 +23,26 @@ const frontmatter = (text) => {
23
23
  // orchestration turn silently bills at the session model — Opus/Fable).
24
24
  // Interactive commands must stay unpinned (they inherit on purpose).
25
25
  const PINNED = ["build", "review", "fix", "ship", "audit",
26
- "refactor", "doctor", "align-ds", "update-pipeline"];
26
+ "refactor", "doctor", "align-ds", "update-pipeline", "drive"];
27
27
  const UNPINNED = ["brainstorm", "spec", "init-pipeline"];
28
28
 
29
+ // Names Claude Code itself claims. A core command that collides is not overridden —
30
+ // it is SHADOWED: the built-in answers the slash, our command file is never read, and
31
+ // the session confidently reports on a run that never happened. That is what `/loop`
32
+ // did (Claude Code's own `/loop` runs a prompt on an interval), invisible until a user
33
+ // noticed the driver had never started. `/loop` is here so the 1.6.0 rename to
34
+ // `/drive` can never be quietly reverted.
35
+ // Watchlist, not yet enforced because the collision is unproven: `doctor` (Claude Code
36
+ // has its own `/doctor`) — if a typed `/doctor` ever stops reaching the pipeline's, add
37
+ // it here and rename.
38
+ const RESERVED = ["loop", "clear", "compact", "cost", "help", "config",
39
+ "init", "run", "schedule", "simplify", "review-pr"];
40
+
29
41
  for (const f of readdirSync(join(root, "core/commands"))) {
30
42
  const path = `core/commands/${f}`;
43
+ if (RESERVED.includes(f.replace(/\.md$/, "")))
44
+ fail(path, `command name collides with a Claude Code built-in — it would be SHADOWED ` +
45
+ `(the built-in answers the slash and this file is never read); rename it`);
31
46
  const fm = frontmatter(read(path));
32
47
  if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
33
48
  if (!/^description:\s*\S/m.test(fm)) fail(path, "frontmatter lacks a description");