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 +33 -0
- package/package.json +1 -1
- package/scripts/loop.sh +35 -2
- package/scripts/test-loop.mjs +74 -13
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
package/scripts/test-loop.mjs
CHANGED
|
@@ -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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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"]);
|