cohorte 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ short, user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` secti
7
7
  > They are history and are deliberately not rewritten — every command gained a `cohorte-` prefix
8
8
  > in 2.0.0.
9
9
 
10
+ ## 2.0.1 — 2026-08-03
11
+
12
+ Three fixes, one failure: an autonomous `/cohorte-loop` run that built 1 surface of 3, stamped
13
+ itself green, and then hung asking a human to approve its own pre-flight. All in `loop.sh` — no
14
+ repo files change, so `npx cohorte@latest update --global` (or `update`) is the whole migration.
15
+
16
+ - **Child sessions now run in `bypassPermissions`, not `acceptEdits`.** `acceptEdits` auto-approves
17
+ Write/Edit and *nothing else*, so the first `Bash` call no `allow` prefix in `settings.json`
18
+ covers raises a permission prompt — and a `claude -p` child has nobody to answer it. It stalls,
19
+ prints prose asking you to approve, and **exits 0**, which the driver scores as a clean phase.
20
+ That is the whole content of one observed run: the review child blocked on invoking
21
+ `preflight.sh` and the loop logged `▶ /cohorte-review … ok`. It is also backwards from what the
22
+ gate is built for — `hooks/gate.py` escalates every `ask` match to a hard **deny** under
23
+ `bypassPermissions` precisely because an unattended run cannot confirm. So the dangerous commands
24
+ from PIPELINE.md `gate` stay blocked deterministically, while typecheck/lint/tests/`git diff` stop
25
+ needing a human. `CLAUDE_FLAGS` still overrides it for a watched run.
26
+
27
+ - **A build phase that reported nothing is no longer treated as a build that found nothing.** The
28
+ driver checked `dead[]` in `specs/reports/<id>.build.json` but accepted the file being **absent** —
29
+ and a phase cut short never reaches the step that writes it, so there was no file to grep and no
30
+ surface to name while the child still exited 0. A 3-surface build that lost 2 of them mid-write
31
+ stamped `<id>.built` and sent reviewers at the result. A missing `build.json` after a build now
32
+ aborts as **exit 2**, naming the cause, and leaves no stamp so a re-run rebuilds.
33
+
34
+ - **`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` is exported for every child.** Print mode otherwise
35
+ *terminates* still-running background tasks at its ceiling ("Background tasks still running after
36
+ 600s; terminating") — which is 25–40 min short of a real implementer batch, and killed the two
37
+ surfaces above mid-write. The phase is bounded by its own completion (and by the `caffeinate`
38
+ assertion the driver already holds), not by a stopwatch that fires inside the longest phase.
39
+
40
+ `test-loop.mjs` pins all three, including the fixture bug that hid the second one: its `ready`
41
+ build wrote no `build.json` either.
42
+
10
43
  ## 2.0.0 — 2026-08-03
11
44
 
12
45
  > **Breaking: every command is renamed.** `/build` → `/cohorte-build`, `/review` →
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /cohorte-init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
package/scripts/loop.sh CHANGED
@@ -99,7 +99,7 @@ usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
99
99
  (loop_pass), instead of starting over at pass 1
100
100
 
101
101
  env CLAUDE_FLAGS flags for every child session
102
- (default: --permission-mode acceptEdits)
102
+ (default: --permission-mode bypassPermissions)
103
103
  EOF
104
104
  exit 64
105
105
  }
@@ -194,7 +194,32 @@ fm_set() { # fm_set <key> <value> (replace, else append)
194
194
  mv "$spec.loop.tmp" "$spec" 2>/dev/null || rm -f "$spec.loop.tmp"
195
195
  }
196
196
 
197
- : "${CLAUDE_FLAGS:=--permission-mode acceptEdits}"
197
+ # --- child session flags -----------------------------------------------------
198
+ # bypassPermissions, NOT acceptEdits. acceptEdits auto-approves Write/Edit and
199
+ # NOTHING else, so every Bash call in a child falls back to the settings.json
200
+ # rules — and the first one no `allow` prefix covers raises a permission prompt.
201
+ # In `claude -p` there is nobody to answer it: the child stalls, eventually
202
+ # prints prose asking the human to approve, and EXITS 0. The driver then reads
203
+ # that as a clean phase. Observed on a real run: the review child hung on its own
204
+ # preflight.sh call ("could you approve the pending tool-call prompt") and the
205
+ # loop scored the phase `ok`.
206
+ #
207
+ # This is also what the gate hook is built for: hooks/gate.py escalates every
208
+ # `ask` match to a hard DENY under bypassPermissions, precisely because an
209
+ # unattended run has nobody to confirm. The dangerous commands stay blocked — by
210
+ # the gate, deterministically, from PIPELINE.md `gate` — while the mechanical
211
+ # ones (typecheck, lint, tests, git diff) stop needing a human. Driving the loop
212
+ # in acceptEdits gets this backwards: nothing is auto-denied and everything is
213
+ # auto-hung. Override with CLAUDE_FLAGS to run in a stricter mode interactively.
214
+ : "${CLAUDE_FLAGS:=--permission-mode bypassPermissions}"
215
+
216
+ # A `/cohorte-build` implementer batch runs 25–40 min as background tasks. In print
217
+ # mode the harness waits a bounded time for background work and then TERMINATES it
218
+ # ("Background tasks still running after 600s; terminating"), which cuts implementers
219
+ # off mid-write and still exits the child 0. 0 = wait indefinitely; the caffeinate
220
+ # assertion above and the phase's own completion are what bound a phase, not a
221
+ # stopwatch that fires in the middle of the longest one.
222
+ export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0
198
223
 
199
224
  : >"$log"
200
225
  {
@@ -309,6 +334,14 @@ spawned no agent; see $readiness, then /cohorte-spec $id"
309
334
  finish 2 "✗ an implementer died — the surface(s) in \"dead\" were never built; see $buildjson and $log"
310
335
  fi
311
336
  [ "$build_ok" -eq 1 ] || finish 2 "✗ /cohorte-build failed — see $log"
337
+ # An ABSENT build.json is the same class of lie as a dead implementer, and the `dead`
338
+ # check above cannot see it: a phase cut short (harness background-task ceiling, a
339
+ # Claude Code teardown, a crash) never reaches §3's report, so there is no file to
340
+ # grep and no surface to name — while the child still exits 0. Treating "no report"
341
+ # as "nothing to report" is what let a build of 3 surfaces stamp itself green with 2
342
+ # of them never written, and sent reviewers at the result.
343
+ [ -f "$buildjson" ] || finish 2 "✗ /cohorte-build wrote no $buildjson — the phase was cut short \
344
+ (background-task ceiling, teardown or crash) and the surfaces it never reported are unbuilt; see $log"
312
345
  date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
313
346
  fi
314
347
 
@@ -59,6 +59,9 @@ case "$cmd" in
59
59
  *) echo "fake claude: expected a /cohorte-* command, got '$cmd'" >&2; exit 9 ;;
60
60
  esac
61
61
  cmd="\${cmd#/cohorte-}" # scenarios are keyed on the PHASE, which stays unprefixed
62
+ # Echoed so the tests can assert what the driver hands its children: an unattended child
63
+ # that cannot answer a permission prompt, and a background ceiling that must not fire.
64
+ echo "fake claude: bgceil=\${CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS:-unset}"
62
65
  n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
63
66
  n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
64
67
  step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
@@ -72,6 +75,13 @@ case "$do_what" in
72
75
  >specs/reports/feat-x.readiness.json ;;
73
76
  ready)
74
77
  printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
78
+ >specs/reports/feat-x.readiness.json
79
+ printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"ok"},"dead":[]}' \\
80
+ >specs/reports/feat-x.build.json ;;
81
+ # READY, dispatched, and then cut short before §3's report — the harness terminating
82
+ # background implementers, a teardown, a crash. No build.json, and exit 0 anyway.
83
+ cutshort)
84
+ printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend","frontend"]}' \\
75
85
  >specs/reports/feat-x.readiness.json ;;
76
86
  clean)
77
87
  printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"SHIP","findings":2,"blocking":0,"deferred":2,"unreviewed":[],"fingerprint":""}' \\
@@ -114,24 +124,26 @@ function scenario(phases, { frontmatter = FM } = {}) {
114
124
  }
115
125
 
116
126
  function runLoop({ dir, bin }, args) {
117
- const r = spawnSync("bash", [LOOP, ...args], {
118
- cwd: dir,
119
- encoding: "utf8",
120
- env: {
121
- ...process.env,
122
- PATH: `${bin}:${process.env.PATH}`,
123
- SCEN_DIR: dir,
124
- CLAUDE_FLAGS: "--permission-mode acceptEdits",
125
- GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
126
- GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
127
- },
128
- });
127
+ const env = {
128
+ ...process.env,
129
+ PATH: `${bin}:${process.env.PATH}`,
130
+ SCEN_DIR: dir,
131
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
132
+ GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
133
+ };
134
+ // Never inherit these from whoever runs the suite: the default child flags and the
135
+ // background ceiling are exactly what the assertions below are about.
136
+ delete env.CLAUDE_FLAGS;
137
+ delete env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS;
138
+ const r = spawnSync("bash", [LOOP, ...args], { cwd: dir, encoding: "utf8", env });
129
139
  const spec = readFileSync(join(dir, "specs/feat-x.md"), "utf8");
130
140
  const fm = k => {
131
141
  const m = spec.match(new RegExp(`^${k}:\\s*([^#\\n]*)`, "m"));
132
142
  return m ? m[1].trim() : null;
133
143
  };
134
- return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm };
144
+ const logPath = join(dir, "specs/reports/feat-x.loop.log");
145
+ const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
146
+ return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm, log };
135
147
  }
136
148
 
137
149
  console.log("loop.sh — readiness gate");
@@ -189,6 +201,55 @@ console.log("loop.sh — a dead subagent is never a clean result");
189
201
  check("dead reviewer ⇒ spec is NOT left in-review", r.fm("status") === "blocked", r.fm("status"));
190
202
  }
191
203
 
204
+ console.log("loop.sh — a build that never reported is never a built build");
205
+ {
206
+ // The absent-file twin of the dead implementer, and the one the `dead[]` grep cannot
207
+ // see: a phase cut short never reaches the step that writes build.json, so there is no
208
+ // file to read and no surface to name — while the child still exits 0. Scoring that as
209
+ // a clean build stamps `.built` over a half-written tree and sends reviewers at it.
210
+ const s = scenario(["build:cutshort"]);
211
+ const r = runLoop(s, ["feat-x"]);
212
+ check("no build.json ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
213
+ check("no build.json ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
214
+ check("no build.json ⇒ names the cut-short phase", /wrote no .*build\.json/.test(r.out),
215
+ r.out.trim().split("\n").pop());
216
+ check("no build.json ⇒ the build stamp is NOT written (a re-run must rebuild)",
217
+ !existsSync(join(s.dir, "specs/reports/feat-x.built")));
218
+ check("no build.json ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
219
+ }
220
+
221
+ console.log("loop.sh — what the children are handed");
222
+ {
223
+ // acceptEdits auto-approves Write/Edit and NOTHING else, so the first child Bash call no
224
+ // `allow` rule covers raises a prompt no `claude -p` can answer: the child stalls, asks
225
+ // the human in prose, and exits 0 — which the driver scores `ok`. Seen on a real run,
226
+ // where the review child hung on its own preflight.sh call. gate.py is built for the
227
+ // other mode: it escalates `ask` to a hard deny under bypassPermissions.
228
+ const s = scenario(["build:ready", "review:clean"]);
229
+ const r = runLoop(s, ["feat-x"]);
230
+ check("default child flags are bypassPermissions, not acceptEdits",
231
+ /# flags: --permission-mode bypassPermissions/.test(r.log) && !/acceptEdits/.test(r.log),
232
+ r.log.split("\n")[1]);
233
+ // Print mode TERMINATES still-running background tasks at its ceiling ("Background tasks
234
+ // still running after 600s"), which cuts a 25–40 min implementer batch off mid-write.
235
+ check("children inherit an unbounded background-task ceiling",
236
+ /fake claude: bgceil=0/.test(r.log), (r.log.match(/bgceil=\S*/) || ["absent"])[0]);
237
+ }
238
+ {
239
+ // The override is the escape hatch for a watched run — it must not have been hard-coded away.
240
+ const s = scenario(["review:clean"]);
241
+ const r = spawnSync("bash", [LOOP, "feat-x", "--no-build"], {
242
+ cwd: s.dir, encoding: "utf8",
243
+ env: { ...process.env, PATH: `${s.bin}:${process.env.PATH}`, SCEN_DIR: s.dir,
244
+ CLAUDE_FLAGS: "--permission-mode acceptEdits",
245
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
246
+ GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t" },
247
+ });
248
+ const log = readFileSync(join(s.dir, "specs/reports/feat-x.loop.log"), "utf8");
249
+ check("CLAUDE_FLAGS overrides the default", /# flags: --permission-mode acceptEdits/.test(log),
250
+ `${r.status}: ${log.split("\n")[1]}`);
251
+ }
252
+
192
253
  console.log("loop.sh — non-convergent + resume");
193
254
  {
194
255
  const s = scenario(["build:ready", "review:blocking", "fix:noop", "review:blocking"]);