cohorte 2.1.0 → 2.3.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.
- package/CHANGELOG.md +173 -0
- package/README.md +49 -41
- package/bin/cli.js +324 -28
- package/core/adapter/render.js +389 -0
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +7 -6
- package/core/agents/review.md +17 -2
- package/core/commands/cohorte-audit.md +2 -0
- package/core/commands/cohorte-brainstorm.md +3 -11
- package/core/commands/cohorte-build.md +37 -23
- package/core/commands/cohorte-doctor.md +61 -36
- package/core/commands/cohorte-fix.md +3 -5
- package/core/commands/cohorte-init-pipeline.md +7 -8
- package/core/commands/cohorte-patch.md +113 -0
- package/core/commands/cohorte-refactor.md +5 -2
- package/core/commands/cohorte-review.md +20 -18
- package/core/commands/cohorte-ship.md +13 -14
- package/core/commands/cohorte-spec.md +4 -13
- package/core/commands/cohorte-update-pipeline.md +13 -12
- package/core/hooks/gate.py +203 -16
- package/core/runtimes/claude.json +73 -0
- package/core/runtimes/codex.json +82 -0
- package/core/runtimes/cursor.json +75 -0
- package/core/runtimes/gemini.json +75 -0
- package/core/runtimes/opencode.json +72 -0
- package/core/templates/patch.template.md +86 -0
- package/core/templates/spec.template.md +1 -3
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -11
- package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
- package/core/templates/steps/init-pipeline/05-report.md +1 -1
- package/core/workflows/review.js +1 -3
- package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +156 -69
- package/dashboard/server/index.js +12 -2
- package/dashboard/server/metrics.js +13 -6
- package/dashboard/server/runtime.js +115 -0
- package/dashboard/server/versions.js +12 -1
- package/install.ps1 +26 -3
- package/install.sh +28 -6
- package/package.json +6 -2
- package/profile/PIPELINE.template.md +8 -6
- package/profile/SCHEMA.md +70 -108
- package/profile/cohorte.config.template.yaml +0 -16
- package/scripts/kanban-move.sh +11 -1
- package/scripts/metrics/collect.mjs +5 -3
- package/scripts/preflight.sh +27 -8
- package/scripts/test-adapter.mjs +368 -0
- package/scripts/test-dashboard.mjs +70 -0
- package/scripts/test-gate.mjs +62 -0
- package/scripts/validate-core.mjs +26 -24
- package/core/commands/cohorte-loop.md +0 -110
- package/scripts/loop-detach.sh +0 -153
- package/scripts/loop.sh +0 -399
- package/scripts/telemetry-send.sh +0 -77
- package/scripts/test-loop.mjs +0 -330
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
model: sonnet
|
|
3
|
-
description: Autonomous /cohorte-build → /cohorte-review → /cohorte-fix → /cohorte-review loop for one feature, until no blocking finding remains.
|
|
4
|
-
argument-hint: <feature_id> [--max=N] [--no-build] [--rebuild] [--resume]
|
|
5
|
-
allowed-tools: Bash(bash ~/.claude/pipeline/scripts/loop-detach.sh:*), Bash(bash .claude/pipeline/scripts/loop-detach.sh:*), Bash(test:*), Read(specs/reports/**)
|
|
6
|
-
disable-model-invocation: true
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
You are the **launcher**, not the loop. Run the driver for **$ARGUMENTS** and relay three lines.
|
|
10
|
-
|
|
11
|
-
> **The driver's name has moved twice.** It was `/loop`, which Claude Code's own built-in `/loop`
|
|
12
|
-
> (run a prompt on a recurring interval) silently **shadowed** — typing `/loop <id>` started the
|
|
13
|
-
> interval runner with the feature id as its prompt, so the driver never ran and the session
|
|
14
|
-
> reported a loop that did not exist. 1.6.0 renamed it `/drive` to escape that. 2.0.0 prefixed
|
|
15
|
-
> every command with `cohorte-`, which makes shadowing impossible by construction, so the accurate
|
|
16
|
-
> name is back: **`/cohorte-loop`**. The shipped script keeps its `loop.sh` name throughout.
|
|
17
|
-
>
|
|
18
|
-
> This command exists because a slash command cannot `/clear` itself. Every phase of the loop runs
|
|
19
|
-
> as a **separate `claude -p` child session** with its own fresh context, driven by a bash script —
|
|
20
|
-
> so the diff, the N review reports and the N contracts never accumulate in YOUR history, which is
|
|
21
|
-
> re-sent at input price on every turn. Running the loop conversationally here would cost more than
|
|
22
|
-
> the loop saves.
|
|
23
|
-
|
|
24
|
-
## 1. Launch — detached, then poll
|
|
25
|
-
|
|
26
|
-
The driver runs for **hours**, which rules out running it as one foreground Bash call: a single
|
|
27
|
-
call is capped at 600 s, and a backgrounded one is not detached — the child stays in this session's
|
|
28
|
-
process group, so a Claude Code restart, crash or laptop sleep kills `loop.sh` and every `claude -p`
|
|
29
|
-
child with it, mid-write. `loop-detach.sh` puts the driver in its own `screen` session so it
|
|
30
|
-
survives all of that, and `loop.sh` re-execs itself under `caffeinate` so idle sleep cannot abort
|
|
31
|
-
its in-flight requests either.
|
|
32
|
-
|
|
33
|
-
Launch — returns immediately:
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
test -f .claude/pipeline/scripts/loop-detach.sh \
|
|
37
|
-
&& bash .claude/pipeline/scripts/loop-detach.sh start $ARGUMENTS \
|
|
38
|
-
|| bash ~/.claude/pipeline/scripts/loop-detach.sh start $ARGUMENTS
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Then poll. Each call blocks up to ~9 min (inside the tool ceiling) and prints the **status file** —
|
|
42
|
-
one line per phase, plus `__EXIT__ <code>` when the run is over:
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
bash ~/.claude/pipeline/scripts/loop-detach.sh wait <feature_id>
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
**Repeat `wait` until you see `__EXIT__ <code>`.** A `__RUNNING__` line means the driver is still
|
|
49
|
-
working and nothing is wrong — call `wait` again. Never conclude anything from a `__RUNNING__`;
|
|
50
|
-
the run has not finished and you have no verdict to report. If the human says to stop waiting,
|
|
51
|
-
tell them the run continues detached and how to follow it (`screen -r cohorte-<id>`) — do not
|
|
52
|
-
kill it unless they ask.
|
|
53
|
-
|
|
54
|
-
Pass `$ARGUMENTS` through untouched — `loop.sh` owns its own flag parsing (`--max=N`,
|
|
55
|
-
`--no-build`, `--rebuild`, `--resume`) and exits 64 on anything it doesn't know. Don't validate flags
|
|
56
|
-
yourself, don't rewrite them, don't add any.
|
|
57
|
-
|
|
58
|
-
**One lid-close caveat to pass on** if the human is walking away from a laptop: `caffeinate` holds
|
|
59
|
-
off *idle* sleep, but no userspace assertion can prevent lid-close sleep. Lid open, or clamshell
|
|
60
|
-
mode (AC + external display + external input).
|
|
61
|
-
|
|
62
|
-
**Resume is the human's call, not yours.** The loop records its position in the spec's front-matter
|
|
63
|
-
(`status: in-progress` · `loop_pass` · `loop_phase` — SCHEMA.md §Spec status), so a run killed by a
|
|
64
|
-
dead session, a ceiling or a `blocked` exit can continue with `--resume` instead of re-paying the
|
|
65
|
-
passes it already made. If the human types `/cohorte-loop <id>` on a spec whose front-matter says
|
|
66
|
-
`status: in-progress` or `blocked` with `loop_pass` > 1, say so in one line and ask whether to resume
|
|
67
|
-
or restart — never silently add the flag, and never silently restart from pass 1.
|
|
68
|
-
|
|
69
|
-
**Never read `specs/reports/<id>.loop.log`.** It holds the full transcript of every child session —
|
|
70
|
-
the entire diff, every review report, every fix handoff. Pulling it into this session re-imports
|
|
71
|
-
exactly the context the loop was built to keep out, and it is the one mistake that turns this
|
|
72
|
-
command into the most expensive one in the pipeline. Point the human at the path instead; they can
|
|
73
|
-
open it in an editor for free. The same goes for the per-surface `.diff` and `.preflight.txt` files.
|
|
74
|
-
|
|
75
|
-
`<id>.loop.status` is the **other** file and is safe: it is the driver's stdout, one line per phase.
|
|
76
|
-
`wait` already prints it, so you never need to Read it yourself. Two files, one letter apart —
|
|
77
|
-
`.log` is the expensive one.
|
|
78
|
-
|
|
79
|
-
## 2. Report — three lines, from the exit code
|
|
80
|
-
|
|
81
|
-
`wait` prints one line per phase, one closing line, and `__EXIT__ <code>`; that is your raw
|
|
82
|
-
material, and that code is the exit code the table below is keyed on. For exit
|
|
83
|
-
**1** or **3** only, also Read `specs/reports/<id>.verdict.json` (small, structured, safe) to name
|
|
84
|
-
the remaining findings — never the markdown report, which is the findings body in full. For exit
|
|
85
|
-
**4**, Read `specs/reports/<id>.readiness.json` instead (also small) and relay its `gaps`. On any
|
|
86
|
-
other exit the closing line already carries the deferred count, so read nothing.
|
|
87
|
-
|
|
88
|
-
| exit | meaning | what to say |
|
|
89
|
-
| ---- | ------- | ----------- |
|
|
90
|
-
| `0` | clean | no blocking findings left; the human can `/cohorte-ship <id>` |
|
|
91
|
-
| `1` | ceiling hit | the fix was progressing but ran out of passes ⇒ re-run with a higher `--max` |
|
|
92
|
-
| `2` | no usable verdict | `/cohorte-review` produced nothing, or aborted on a red preflight — the closing line says which; point at `specs/reports/<id>.preflight.txt` |
|
|
93
|
-
| `3` | non-convergent | the same blocking findings survived a fix pass; a higher `--max` will NOT help — the human needs to look at them (list them from the verdict) |
|
|
94
|
-
| `4` | not implementable | `/cohorte-build`'s readiness gate returned `NOT-READY` — the frozen spec cannot be built and **no agent ran**; Read `specs/reports/<id>.readiness.json` (small, structured) and relay its `gaps`, then point at `/cohorte-spec <id>`. More passes cannot fix this |
|
|
95
|
-
| `64` | usage | relay the script's own message verbatim |
|
|
96
|
-
|
|
97
|
-
Then print exactly three lines and nothing else — plus a fourth **only when the verdict carries
|
|
98
|
-
`deferred` > 0** (findings that were real but out of this feature's scope, parked in the backlog by
|
|
99
|
-
`/cohorte-review` §3.5; they are not blocking and never cost a pass, but they are not nothing either):
|
|
100
|
-
|
|
101
|
-
```
|
|
102
|
-
outcome: <one clause — clean / ceiling / no verdict / non-convergent / not implementable / usage>
|
|
103
|
-
iterations: <n> review pass(es)<, m fix pass(es) committed>
|
|
104
|
-
remaining: <blocking count + one short phrase per blocking item, or "none">
|
|
105
|
-
deferred: <n> parked in specs/refactor-backlog.md — /cohorte-refactor <domain> when you want them
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Add at most one follow-up sentence: the next command to run. Never restate a finding's fix, never
|
|
109
|
-
summarize the log, never open the diff. Each fix pass is already committed
|
|
110
|
-
(`loop(<id>): fix pass <i>`) — say so on a non-zero exit, since those commits are the way back.
|
package/scripts/loop-detach.sh
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# loop-detach.sh — run loop.sh so it outlives the session that launched it.
|
|
4
|
-
#
|
|
5
|
-
# loop-detach.sh start <feature-id> [loop.sh flags…]
|
|
6
|
-
# loop-detach.sh wait <feature-id>
|
|
7
|
-
#
|
|
8
|
-
# WHY THIS EXISTS. `/cohorte-loop` used to run the driver as one foreground Bash
|
|
9
|
-
# call ("let it run to completion"). Two things make that impossible for a real
|
|
10
|
-
# feature:
|
|
11
|
-
#
|
|
12
|
-
# 1. The Bash tool caps a single call at 600 s. A build is 25–40 min, so the
|
|
13
|
-
# call was killed mid-`/cohorte-build` and the loop reported nothing.
|
|
14
|
-
# 2. A backgrounded Bash call is NOT detached — the child stays in the calling
|
|
15
|
-
# session's process group, so when Claude Code goes down (a restart, a
|
|
16
|
-
# crash, a laptop sleep) `loop.sh` and every `claude -p` child die with it.
|
|
17
|
-
# Diagnosed on a real run: four teardowns in 45 min, each one aborting both
|
|
18
|
-
# surface implementers mid-write and leaving a half-built tree.
|
|
19
|
-
#
|
|
20
|
-
# `screen -dmS` is the fix: its server double-forks and reparents to init, so the
|
|
21
|
-
# driver is in its own session and survives the launching process entirely. The
|
|
22
|
-
# exit code — which `/cohorte-loop` keys its whole report table on — would be lost
|
|
23
|
-
# that way, so the wrapper appends `__EXIT__ <code>` to the status file.
|
|
24
|
-
#
|
|
25
|
-
# STATUS FILE vs LOG — these are different files and the distinction is the whole
|
|
26
|
-
# token economy of this command. loop.sh writes ONE LINE PER PHASE to stdout; that
|
|
27
|
-
# is what lands in <id>.loop.status and it is safe to read. The full transcript of
|
|
28
|
-
# every child session — the diff, every review report, every handoff — goes to
|
|
29
|
-
# <id>.loop.log, which must never be read into a session. Do not merge them.
|
|
30
|
-
|
|
31
|
-
set -uo pipefail
|
|
32
|
-
|
|
33
|
-
usage() {
|
|
34
|
-
cat >&2 <<'EOF'
|
|
35
|
-
usage: loop-detach.sh start <feature-id> [loop.sh flags…]
|
|
36
|
-
loop-detach.sh wait <feature-id>
|
|
37
|
-
|
|
38
|
-
start launch the driver detached; returns immediately
|
|
39
|
-
wait block up to ~9 min waiting for it to finish (safely under the Bash
|
|
40
|
-
tool's 600 s ceiling), then print the small status file. Call again
|
|
41
|
-
while it prints __RUNNING__.
|
|
42
|
-
EOF
|
|
43
|
-
exit 64
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
mode="${1:-}"; id="${2:-}"
|
|
47
|
-
[ -n "$mode" ] && [ -n "$id" ] || usage
|
|
48
|
-
shift 2 2>/dev/null || usage
|
|
49
|
-
case "$mode" in start|wait) ;; *) echo "loop-detach: unknown mode: $mode" >&2; usage ;; esac
|
|
50
|
-
|
|
51
|
-
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
52
|
-
echo "loop-detach: not inside a git checkout" >&2; exit 64; }
|
|
53
|
-
cd "$root" || exit 64
|
|
54
|
-
|
|
55
|
-
status="$root/specs/reports/$id.loop.status"
|
|
56
|
-
sess="cohorte-$id"
|
|
57
|
-
|
|
58
|
-
# ── wait ─────────────────────────────────────────────────────────────────────
|
|
59
|
-
# 36 × 15 s = 540 s, comfortably inside the 600 s tool ceiling. The driver is
|
|
60
|
-
# detached, so this timing out means nothing to the run — call wait again.
|
|
61
|
-
if [ "$mode" = "wait" ]; then
|
|
62
|
-
[ -f "$status" ] || { echo "loop-detach: no run to wait on ($status absent)" >&2; exit 64; }
|
|
63
|
-
i=0
|
|
64
|
-
while [ "$i" -lt 36 ]; do
|
|
65
|
-
grep -q '__EXIT__' "$status" 2>/dev/null && break
|
|
66
|
-
sleep 15
|
|
67
|
-
i=$((i + 1))
|
|
68
|
-
done
|
|
69
|
-
cat "$status"
|
|
70
|
-
grep -q '__EXIT__' "$status" 2>/dev/null || echo "__RUNNING__"
|
|
71
|
-
exit 0
|
|
72
|
-
fi
|
|
73
|
-
|
|
74
|
-
# ── start ────────────────────────────────────────────────────────────────────
|
|
75
|
-
# Refuse to double-launch: two drivers on one feature would interleave commits and
|
|
76
|
-
# fight over the same verdict files, and the second would silently win the report.
|
|
77
|
-
# `screen -ls` exits 1 when it DOES find sessions, so under `pipefail` a piped
|
|
78
|
-
# `| grep -q` reports failure on the very case we are testing for — the guard
|
|
79
|
-
# silently never fired. Capture first, match second.
|
|
80
|
-
sessions="$(screen -ls 2>/dev/null || true)"
|
|
81
|
-
if [ -n "$sessions" ] && printf '%s\n' "$sessions" | grep -q "[.]$sess[[:space:]]"; then
|
|
82
|
-
echo "loop-detach: '$sess' is already running — 'wait $id' to follow it, or"
|
|
83
|
-
echo " 'screen -S $sess -X quit' to stop it first" >&2
|
|
84
|
-
exit 64
|
|
85
|
-
fi
|
|
86
|
-
# The screen check above only sees the screen tier. On the setsid/nohup tiers there is
|
|
87
|
-
# no session to list, so match the process itself — otherwise the guard silently covers
|
|
88
|
-
# macOS and misses every platform that lacks screen. `[l]oop.sh` keeps this pgrep from
|
|
89
|
-
# matching itself. Skipped where pgrep is absent (Git Bash): the guard degrades to
|
|
90
|
-
# nothing there rather than blocking a legitimate launch.
|
|
91
|
-
if command -v pgrep >/dev/null 2>&1 && pgrep -f "[l]oop\.sh .*$id" >/dev/null 2>&1; then
|
|
92
|
-
echo "loop-detach: a driver is already running for '$id' — 'wait $id' to follow it," >&2
|
|
93
|
-
echo " or stop that process before launching another" >&2
|
|
94
|
-
exit 64
|
|
95
|
-
fi
|
|
96
|
-
|
|
97
|
-
loop=""
|
|
98
|
-
for cand in "$root/.claude/pipeline/scripts/loop.sh" "$HOME/.claude/pipeline/scripts/loop.sh"; do
|
|
99
|
-
[ -f "$cand" ] && { loop="$cand"; break; }
|
|
100
|
-
done
|
|
101
|
-
[ -n "$loop" ] || { echo "loop-detach: no loop.sh in .claude/ or ~/.claude/ — run /cohorte-doctor" >&2; exit 64; }
|
|
102
|
-
|
|
103
|
-
mkdir -p "$root/specs/reports"
|
|
104
|
-
: >"$status"
|
|
105
|
-
|
|
106
|
-
# A self-deleting wrapper, rather than interpolating "$@" into a `sh -c` string:
|
|
107
|
-
# feature ids and flags would otherwise need shell-correct quoting at two nesting
|
|
108
|
-
# levels, and getting that subtly wrong silently drops a flag (`--max=3` becoming
|
|
109
|
-
# `--max`). `printf %q` is a bash builtin — present even in macOS's bash 3.2.
|
|
110
|
-
wrapper="$(mktemp "${TMPDIR:-/tmp}/cohorte-detach-XXXXXX")" || exit 1
|
|
111
|
-
{
|
|
112
|
-
echo '#!/usr/bin/env bash'
|
|
113
|
-
printf 'rm -f -- %q\n' "$wrapper" # self-delete: no litter in TMPDIR
|
|
114
|
-
printf 'cd %q || exit 1\n' "$root"
|
|
115
|
-
printf 'bash %q %q' "$loop" "$id"
|
|
116
|
-
for a in "$@"; do printf ' %q' "$a"; done
|
|
117
|
-
printf ' >>%q 2>&1\n' "$status"
|
|
118
|
-
printf 'printf "__EXIT__ %%s\\n" "$?" >>%q\n' "$status"
|
|
119
|
-
} >"$wrapper"
|
|
120
|
-
chmod +x "$wrapper"
|
|
121
|
-
|
|
122
|
-
# Three tiers, because "detached" means different things per platform and only the
|
|
123
|
-
# first two are actually detached. What matters is escaping the caller's process
|
|
124
|
-
# GROUP — not just ignoring SIGHUP — since that is what a Claude Code teardown kills.
|
|
125
|
-
#
|
|
126
|
-
# screen — macOS + Linux (macOS ships it at /usr/bin/screen). Its server
|
|
127
|
-
# double-forks and reparents to init: fully out of our session.
|
|
128
|
-
# setsid — Linux (util-linux, effectively always present; NOT on macOS, and not
|
|
129
|
-
# in Git Bash). Puts the child in a brand-new session directly.
|
|
130
|
-
# nohup — last resort, and NOT equivalent: it survives SIGHUP but stays in this
|
|
131
|
-
# process group, so a teardown still takes it. This is the Windows/Git
|
|
132
|
-
# Bash path today.
|
|
133
|
-
if command -v screen >/dev/null 2>&1; then
|
|
134
|
-
screen -dmS "$sess" "$wrapper"
|
|
135
|
-
echo "▶ detached as screen session '$sess' — survives this session ending"
|
|
136
|
-
echo " follow it live with: screen -r $sess"
|
|
137
|
-
elif command -v setsid >/dev/null 2>&1; then
|
|
138
|
-
setsid "$wrapper" >/dev/null 2>&1 &
|
|
139
|
-
echo "▶ detached with setsid (no 'screen' on PATH) — survives this session ending"
|
|
140
|
-
else
|
|
141
|
-
# Say so plainly. A silent downgrade here reads as "safe to walk away" when it is
|
|
142
|
-
# not, which is the exact failure that made a half-built tree look like a cohorte
|
|
143
|
-
# bug for three hours.
|
|
144
|
-
nohup "$wrapper" >/dev/null 2>&1 &
|
|
145
|
-
echo "▶ launched with nohup — no 'screen' or 'setsid' on PATH."
|
|
146
|
-
echo " WARNING: this does NOT survive the calling session being torn down. It"
|
|
147
|
-
echo " ignores SIGHUP but stays in this process group. For a truly unattended"
|
|
148
|
-
echo " run, install screen, or start the driver from your own terminal:"
|
|
149
|
-
echo " bash <core>/pipeline/scripts/loop.sh $id"
|
|
150
|
-
fi
|
|
151
|
-
|
|
152
|
-
echo " status: specs/reports/$id.loop.status (small — one line per phase)"
|
|
153
|
-
echo " log: specs/reports/$id.loop.log (full transcript — never read this)"
|
package/scripts/loop.sh
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# loop.sh — autonomous /cohorte-build → /cohorte-review → /cohorte-fix → /cohorte-review …
|
|
4
|
-
# loop for ONE feature.
|
|
5
|
-
#
|
|
6
|
-
# loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
|
|
7
|
-
#
|
|
8
|
-
# THE POINT: every phase runs as a SEPARATE `claude -p` child with its own fresh
|
|
9
|
-
# context. The session that typed /cohorte-loop never sees the diff, the N review reports
|
|
10
|
-
# or the N contracts — it reads only this script's one-line-per-phase stdout and,
|
|
11
|
-
# at the end, the verdict JSON. Running the loop inside the calling session would
|
|
12
|
-
# accumulate all of it in a history that is re-sent at input price on every turn,
|
|
13
|
-
# which is the exact cost the pipeline's /clear discipline exists to avoid.
|
|
14
|
-
#
|
|
15
|
-
# Contract with the pipeline: /cohorte-review writes specs/reports/<id>.verdict.json on
|
|
16
|
-
# every run, and /cohorte-build writes <id>.readiness.json + <id>.build.json. Those three
|
|
17
|
-
# files — `blocking`, `fingerprint`, `unreviewed`, `verdict`, `dead` — are the ONLY
|
|
18
|
-
# channel between cohorte and this driver. No prose is parsed.
|
|
19
|
-
#
|
|
20
|
-
# Two of those fields exist for the same reason: a subagent that DIES returns
|
|
21
|
-
# nothing, and nothing is byte-identical to "clean". A dead implementer means a
|
|
22
|
-
# surface was never built; a dead reviewer means a surface was never audited, and
|
|
23
|
-
# `blocking == 0` would then certify code no one read. Both abort as exit 2.
|
|
24
|
-
#
|
|
25
|
-
# Exit codes (distinct diagnostics, do not collapse them):
|
|
26
|
-
# 0 clean — a review returned blocking == 0
|
|
27
|
-
# 1 ceiling — --max passes used, still blocking (the fix was progressing;
|
|
28
|
-
# re-run with a higher --max)
|
|
29
|
-
# 2 no usable verdict — /cohorte-review produced nothing, or aborted on a red
|
|
30
|
-
# preflight (typecheck/lint/tests broken; the message says which)
|
|
31
|
-
# 3 non-convergent — two consecutive reviews returned the SAME blocking
|
|
32
|
-
# fingerprint: the fix is treading water, a higher --max will not help
|
|
33
|
-
# 4 not implementable — /cohorte-build's readiness gate returned NOT-READY and spawned
|
|
34
|
-
# no agent: the frozen spec cannot be built (missing contract shape, unowned
|
|
35
|
-
# area, absent dependency). Needs /cohorte-spec, not more passes.
|
|
36
|
-
# 64 usage — bad flag, bad id, missing spec, no `claude` on PATH
|
|
37
|
-
#
|
|
38
|
-
# No /cohorte-fix runs on the last pass: fixing without a review behind it ships
|
|
39
|
-
# unaudited code. Each fix pass is committed — that commit is the only way back
|
|
40
|
-
# after N autonomous passes.
|
|
41
|
-
#
|
|
42
|
-
# RESUME: the spec's front-matter IS the loop's state (SCHEMA.md §Spec status).
|
|
43
|
-
# Before every phase this script stamps `status: in-progress` + `loop_phase` +
|
|
44
|
-
# `loop_pass` into specs/<id>.md — deterministically, with awk, costing no tokens
|
|
45
|
-
# — and on exit stamps a terminal status (`in-review` clean, `blocked` otherwise).
|
|
46
|
-
# `--resume` reads `loop_pass` back and continues from that pass instead of 1, so
|
|
47
|
-
# a session that died at pass 3 of 5 does not re-pay passes 1 and 2. The build is
|
|
48
|
-
# skipped or redone by the same stamp logic as always (the stamp is only written
|
|
49
|
-
# on a build that finished), so an interrupted build still rebuilds.
|
|
50
|
-
|
|
51
|
-
set -uo pipefail
|
|
52
|
-
|
|
53
|
-
# --- hold the machine awake for the whole run --------------------------------
|
|
54
|
-
# System sleep aborts every in-flight `claude -p` request, so a loop that spans
|
|
55
|
-
# hours must own a power assertion for its entire life — a driver killed at hour
|
|
56
|
-
# two has spent hour one for nothing, and the abort is indistinguishable from a
|
|
57
|
-
# clean "agent returned nothing" (which is the `dead` family this script exists
|
|
58
|
-
# to catch). Re-exec ourselves under caffeinate once; the guard keeps it to one
|
|
59
|
-
# level, and `exec` leaves no extra process to reap.
|
|
60
|
-
#
|
|
61
|
-
# macOS `caffeinate -ims`: `-i` idle system sleep · `-m` disk sleep · `-s` system
|
|
62
|
-
# sleep (AC only). NOT `-d`/`-u` — an unattended build has no reason to hold the
|
|
63
|
-
# display on. Linux gets the systemd equivalent. Windows has no scriptable
|
|
64
|
-
# equivalent, and neither does a systemd-less Linux, so both fall through to a
|
|
65
|
-
# no-op rather than pretending: the run still works, it is just as sleep-proof as
|
|
66
|
-
# the machine's own settings make it. A *refused* inhibitor falls through the same
|
|
67
|
-
# way — an unheld power assertion is a degraded run, not a failed one.
|
|
68
|
-
#
|
|
69
|
-
# THIS CANNOT PREVENT LID-CLOSE SLEEP on any platform. No userspace assertion can
|
|
70
|
-
# override it — keep the lid open, or use clamshell mode (AC + external display +
|
|
71
|
-
# external input).
|
|
72
|
-
#
|
|
73
|
-
# PROBE before exec'ing. `exec` replaces this shell, so an inhibitor that *exists* but is
|
|
74
|
-
# refused — `systemd-inhibit` in a container, in CI, or in any session without a logind
|
|
75
|
-
# seat answers `Failed to inhibit: Access denied` and exits 1 — would become the driver's
|
|
76
|
-
# own exit code, and the run would never start at all. "Present" and "usable" are not the
|
|
77
|
-
# same test; only the second one is safe to build an `exec` on. One fast subprocess on a
|
|
78
|
-
# run measured in hours.
|
|
79
|
-
if [ -z "${COHORTE_CAFFEINATED:-}" ]; then
|
|
80
|
-
if command -v caffeinate > /dev/null 2>&1 && caffeinate -ims true > /dev/null 2>&1; then
|
|
81
|
-
COHORTE_CAFFEINATED=1 exec caffeinate -ims "$0" "$@"
|
|
82
|
-
elif command -v systemd-inhibit > /dev/null 2>&1 \
|
|
83
|
-
&& systemd-inhibit --what=sleep:idle --who=cohorte --why="probe" true > /dev/null 2>&1; then
|
|
84
|
-
COHORTE_CAFFEINATED=1 exec systemd-inhibit \
|
|
85
|
-
--what=sleep:idle --who=cohorte --why="autonomous $0 run" "$0" "$@"
|
|
86
|
-
fi
|
|
87
|
-
fi
|
|
88
|
-
|
|
89
|
-
usage() {
|
|
90
|
-
cat >&2 <<'EOF'
|
|
91
|
-
usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
|
|
92
|
-
|
|
93
|
-
--max=N stop after N review passes (default 5) — a ceiling on the TOTAL
|
|
94
|
-
pass count, so it still means "5 passes" when resuming at pass 3
|
|
95
|
-
--no-build never build — re-run the /cohorte-review ⇄ /cohorte-fix loop on a feature that
|
|
96
|
-
is already built (the common case; the build stamp is ignored)
|
|
97
|
-
--rebuild force a /cohorte-build even if the stamp says it was already built
|
|
98
|
-
--resume continue from the pass recorded in the spec's front-matter
|
|
99
|
-
(loop_pass), instead of starting over at pass 1
|
|
100
|
-
|
|
101
|
-
env CLAUDE_FLAGS flags for every child session
|
|
102
|
-
(default: --permission-mode bypassPermissions)
|
|
103
|
-
EOF
|
|
104
|
-
exit 64
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
id=""
|
|
108
|
-
max=5
|
|
109
|
-
build_mode="auto" # auto | never | force
|
|
110
|
-
resume=0
|
|
111
|
-
|
|
112
|
-
for arg in "$@"; do
|
|
113
|
-
case "$arg" in
|
|
114
|
-
--max=*)
|
|
115
|
-
max="${arg#--max=}"
|
|
116
|
-
case "$max" in
|
|
117
|
-
''|*[!0-9]*) echo "loop: --max must be a positive integer (got '${arg#--max=}')" >&2; exit 64 ;;
|
|
118
|
-
esac
|
|
119
|
-
[ "$max" -ge 1 ] || { echo "loop: --max must be >= 1" >&2; exit 64; }
|
|
120
|
-
;;
|
|
121
|
-
--no-build) build_mode="never" ;;
|
|
122
|
-
--rebuild) build_mode="force" ;;
|
|
123
|
-
--resume) resume=1 ;;
|
|
124
|
-
-h|--help) usage ;;
|
|
125
|
-
-*) echo "loop: unknown flag: $arg" >&2; usage ;;
|
|
126
|
-
*)
|
|
127
|
-
[ -z "$id" ] || { echo "loop: unexpected argument: $arg" >&2; usage; }
|
|
128
|
-
id="$arg"
|
|
129
|
-
;;
|
|
130
|
-
esac
|
|
131
|
-
done
|
|
132
|
-
|
|
133
|
-
[ -n "$id" ] || usage
|
|
134
|
-
# --no-build --rebuild together is a contradiction, not a precedence puzzle.
|
|
135
|
-
case " $* " in
|
|
136
|
-
*" --no-build "*) case " $* " in *" --rebuild "*)
|
|
137
|
-
echo "loop: --no-build and --rebuild are mutually exclusive" >&2; exit 64 ;; esac ;;
|
|
138
|
-
esac
|
|
139
|
-
|
|
140
|
-
command -v claude >/dev/null 2>&1 || {
|
|
141
|
-
echo "loop: no 'claude' on PATH — the loop drives child claude -p sessions" >&2
|
|
142
|
-
exit 64
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
146
|
-
echo "loop: not inside a git checkout" >&2; exit 64; }
|
|
147
|
-
cd "$root" || exit 64
|
|
148
|
-
|
|
149
|
-
spec="specs/$id.md"
|
|
150
|
-
[ -f "$spec" ] || {
|
|
151
|
-
echo "loop: no spec at $spec — run /cohorte-spec $id first" >&2; exit 64; }
|
|
152
|
-
|
|
153
|
-
reports="specs/reports"
|
|
154
|
-
mkdir -p "$reports"
|
|
155
|
-
verdict="$reports/$id.verdict.json"
|
|
156
|
-
readiness="$reports/$id.readiness.json"
|
|
157
|
-
buildjson="$reports/$id.build.json"
|
|
158
|
-
stamp="$reports/$id.built"
|
|
159
|
-
log="$reports/$id.loop.log"
|
|
160
|
-
|
|
161
|
-
# --- the spec front-matter as loop state -------------------------------------
|
|
162
|
-
# Best-effort by design: a spec with no front-matter (or an unwritable one) makes
|
|
163
|
-
# every fm_* call a silent no-op. This is bookkeeping for resume + the dashboard,
|
|
164
|
-
# never a precondition — the loop must not die over a status line.
|
|
165
|
-
fm_get() { # fm_get <key> → value, or empty
|
|
166
|
-
[ -f "$spec" ] || return 0
|
|
167
|
-
awk -v k="$1" '
|
|
168
|
-
NR==1 && $0=="---" { fm=1; next }
|
|
169
|
-
fm==1 && $0=="---" { exit }
|
|
170
|
-
fm==1 && $0 ~ "^"k":" {
|
|
171
|
-
sub("^"k":[[:space:]]*", ""); sub("#.*", "")
|
|
172
|
-
gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit
|
|
173
|
-
}
|
|
174
|
-
' "$spec"
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
fm_set() { # fm_set <key> <value> (replace, else append)
|
|
178
|
-
[ -f "$spec" ] || return 0
|
|
179
|
-
awk -v k="$1" -v v="$2" '
|
|
180
|
-
NR==1 && $0!="---" { nofm=1 }
|
|
181
|
-
nofm { print; next }
|
|
182
|
-
NR==1 { fm=1; print; next }
|
|
183
|
-
fm==1 && $0=="---" {
|
|
184
|
-
if (!done) print k ": " v # key absent: add it before the closing ---
|
|
185
|
-
fm=2; print; next
|
|
186
|
-
}
|
|
187
|
-
fm==1 && $0 ~ "^"k":" {
|
|
188
|
-
if (done) next # a duplicate key: drop it
|
|
189
|
-
c=""; i=index($0, "#"); if (i>0) c=" " substr($0, i) # keep a trailing comment
|
|
190
|
-
print k ": " v c; done=1; next
|
|
191
|
-
}
|
|
192
|
-
{ print }
|
|
193
|
-
' "$spec" >"$spec.loop.tmp" 2>/dev/null &&
|
|
194
|
-
mv "$spec.loop.tmp" "$spec" 2>/dev/null || rm -f "$spec.loop.tmp"
|
|
195
|
-
}
|
|
196
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
: >"$log"
|
|
225
|
-
{
|
|
226
|
-
printf '# loop %s — max=%s build=%s\n' "$id" "$max" "$build_mode"
|
|
227
|
-
printf '# flags: %s\n' "$CLAUDE_FLAGS"
|
|
228
|
-
} >>"$log"
|
|
229
|
-
|
|
230
|
-
# --- one phase = one throwaway child session ---------------------------------
|
|
231
|
-
# ALL child output is redirected into $log and never surfaces here: if the
|
|
232
|
-
# parent re-imports the children's transcripts, the whole point is lost.
|
|
233
|
-
# $CLAUDE_FLAGS is intentionally unquoted — it is a flag list, not one word.
|
|
234
|
-
run_phase() {
|
|
235
|
-
# $1 is the PHASE name (build|review|fix), which is not the same string as the
|
|
236
|
-
# command that runs it (`/cohorte-build`). Every command gained a `cohorte-`
|
|
237
|
-
# prefix in 2.0.0 so Claude Code's built-ins can never shadow them again — but
|
|
238
|
-
# the phase name is a DATA CONTRACT, written into the spec's `loop_phase`, into
|
|
239
|
-
# specs/reports/<id>.*.json and into pipeline-metrics.jsonl, and read back by
|
|
240
|
-
# --resume and the dashboard. Prefixing it too would orphan every historical
|
|
241
|
-
# metrics line and break resume on specs written by 1.x. So: prefix the command,
|
|
242
|
-
# never the phase.
|
|
243
|
-
phase="$1"
|
|
244
|
-
cmd="cohorte-$phase"
|
|
245
|
-
# Stamp the state BEFORE the phase runs: if this child dies (or the whole
|
|
246
|
-
# session does), the spec already says where the loop was — that is what
|
|
247
|
-
# --resume reads back. Child commands write `status` themselves (/cohorte-fix
|
|
248
|
-
# sets in-review); re-stamping here each phase keeps `in-progress` true.
|
|
249
|
-
fm_set status in-progress
|
|
250
|
-
fm_set loop_pass "$pass"
|
|
251
|
-
fm_set loop_phase "$phase"
|
|
252
|
-
printf '▶ /%-14s %-24s ' "$cmd" "$id"
|
|
253
|
-
printf '\n\n===== /%s %s =====\n' "$cmd" "$id" >>"$log"
|
|
254
|
-
# shellcheck disable=SC2086
|
|
255
|
-
if claude -p "/$cmd $id" $CLAUDE_FLAGS >>"$log" 2>&1; then
|
|
256
|
-
echo "ok"
|
|
257
|
-
return 0
|
|
258
|
-
fi
|
|
259
|
-
echo "fail"
|
|
260
|
-
return 1
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
# Scalar reads on a flat JSON object — no jq dependency (the pipeline ships no
|
|
264
|
-
# runtime deps). Only `blocking` and `fingerprint` are ever read; both are
|
|
265
|
-
# top-level scalars by construction of the verdict contract.
|
|
266
|
-
json_num() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$1" | head -n1; }
|
|
267
|
-
json_str() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n1; }
|
|
268
|
-
|
|
269
|
-
# Terminal status goes into the spec, not just into this stdout: a clean run
|
|
270
|
-
# leaves the feature ready to /cohorte-ship, any failure leaves it visibly `blocked` for
|
|
271
|
-
# the human and for the dashboard. Exit 64 never reaches here (usage dies earlier),
|
|
272
|
-
# so every code handled below is a real run outcome.
|
|
273
|
-
finish() {
|
|
274
|
-
if [ "$1" -eq 0 ]; then
|
|
275
|
-
fm_set status in-review
|
|
276
|
-
fm_set loop_pass 0
|
|
277
|
-
fm_set loop_phase done
|
|
278
|
-
else
|
|
279
|
-
fm_set status blocked
|
|
280
|
-
fi
|
|
281
|
-
echo "$2"
|
|
282
|
-
exit "$1"
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
# One short clause naming the deferred findings, appended to a closing line.
|
|
286
|
-
# They are NOT blocking (they live in the backlog, not in ## Remediation), so
|
|
287
|
-
# they never change an exit code — but a loop that silently drops them is the
|
|
288
|
-
# leak /cohorte-review §3.5 exists to close, so the driver names them.
|
|
289
|
-
def_note() {
|
|
290
|
-
d="$(json_num "$verdict" deferred 2>/dev/null)"
|
|
291
|
-
case "$d" in ''|0) return 0 ;; esac
|
|
292
|
-
printf ' · %s deferred finding(s) parked in specs/refactor-backlog.md' "$d"
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
# --- build -------------------------------------------------------------------
|
|
296
|
-
# The stamp is the driver's own bookkeeping — /cohorte-build knows nothing about it.
|
|
297
|
-
case "$build_mode" in
|
|
298
|
-
force) do_build=1 ;;
|
|
299
|
-
never) do_build=0 ;;
|
|
300
|
-
auto) [ -f "$stamp" ] && do_build=0 || do_build=1 ;;
|
|
301
|
-
esac
|
|
302
|
-
|
|
303
|
-
# --resume: continue from the pass the spec records, not from 1. A missing or
|
|
304
|
-
# junk value falls back to 1 — resuming must never be less safe than starting.
|
|
305
|
-
pass=1
|
|
306
|
-
if [ "$resume" -eq 1 ]; then
|
|
307
|
-
rp="$(fm_get loop_pass)"
|
|
308
|
-
case "$rp" in ''|*[!0-9]*|0) rp=1 ;; esac
|
|
309
|
-
[ "$rp" -le "$max" ] || {
|
|
310
|
-
echo "loop: --resume says pass $rp but --max=$max — raise --max to continue" >&2; exit 64; }
|
|
311
|
-
pass="$rp"
|
|
312
|
-
[ "$pass" -eq 1 ] || printf '↻ resuming at review pass %s (from %s)\n' "$pass" "$spec"
|
|
313
|
-
fi
|
|
314
|
-
|
|
315
|
-
if [ "$do_build" -eq 1 ]; then
|
|
316
|
-
# Delete first: a NOT-READY left by a previous build would abort this one on
|
|
317
|
-
# someone else's verdict (and a stale READY would hide a gate that never ran).
|
|
318
|
-
rm -f "$readiness" "$buildjson"
|
|
319
|
-
build_ok=0
|
|
320
|
-
run_phase build && build_ok=1
|
|
321
|
-
# The readiness gate is checked BEFORE the child's exit status: /cohorte-build aborting
|
|
322
|
-
# on NOT-READY is a cleaner diagnosis than "/cohorte-build failed", and it is the one
|
|
323
|
-
# outcome that more passes cannot fix.
|
|
324
|
-
if [ -f "$readiness" ] &&
|
|
325
|
-
grep -q '"verdict"[[:space:]]*:[[:space:]]*"NOT-READY"' "$readiness"; then
|
|
326
|
-
finish 4 "✗ spec not implementable — /cohorte-build's readiness gate returned NOT-READY and \
|
|
327
|
-
spawned no agent; see $readiness, then /cohorte-spec $id"
|
|
328
|
-
fi
|
|
329
|
-
# A dead implementer returns nothing, so /cohorte-build can finish "successfully" having
|
|
330
|
-
# built one surface of two. Reviewing that would spend N reviewers auditing a
|
|
331
|
-
# half-built feature and report its gaps as findings to fix — the wrong diagnosis
|
|
332
|
-
# at the wrong price. `dead` is a non-empty array only when a surface died twice.
|
|
333
|
-
if [ -f "$buildjson" ] && grep -q '"dead"[[:space:]]*:[[:space:]]*\[[^]]' "$buildjson"; then
|
|
334
|
-
finish 2 "✗ an implementer died — the surface(s) in \"dead\" were never built; see $buildjson and $log"
|
|
335
|
-
fi
|
|
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"
|
|
345
|
-
date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
|
|
346
|
-
fi
|
|
347
|
-
|
|
348
|
-
# --- review ⇄ fix ------------------------------------------------------------
|
|
349
|
-
prev_fp=""
|
|
350
|
-
while [ "$pass" -le "$max" ]; do
|
|
351
|
-
# Delete first: a stale verdict from the previous pass read as this pass's
|
|
352
|
-
# answer would end the loop on someone else's numbers.
|
|
353
|
-
rm -f "$verdict"
|
|
354
|
-
run_phase review || true # exit status of the child is not the verdict
|
|
355
|
-
|
|
356
|
-
[ -f "$verdict" ] || finish 2 \
|
|
357
|
-
"✗ /cohorte-review wrote no verdict (pass $pass) — see $log"
|
|
358
|
-
|
|
359
|
-
if grep -q '"aborted"' "$verdict"; then
|
|
360
|
-
finish 2 "✗ /cohorte-review aborted on a red preflight — typecheck/lint/tests are broken, \
|
|
361
|
-
see $reports/$id.preflight.txt"
|
|
362
|
-
fi
|
|
363
|
-
|
|
364
|
-
# A reviewer that died twice leaves its surface unaudited, and `blocking` counts only
|
|
365
|
-
# what the SURVIVING reviewers found — so blocking == 0 here would mean "clean" about
|
|
366
|
-
# code nobody read. Checked BEFORE blocking, because it invalidates it.
|
|
367
|
-
if grep -q '"unreviewed"[[:space:]]*:[[:space:]]*\[[^]]' "$verdict"; then
|
|
368
|
-
finish 2 "✗ a reviewer died — the surface(s) in \"unreviewed\" carry no verdict (pass $pass); see $verdict"
|
|
369
|
-
fi
|
|
370
|
-
|
|
371
|
-
blocking="$(json_num "$verdict" blocking)"
|
|
372
|
-
[ -n "$blocking" ] || finish 2 \
|
|
373
|
-
"✗ verdict has no usable 'blocking' count (pass $pass) — see $verdict"
|
|
374
|
-
|
|
375
|
-
[ "$blocking" -eq 0 ] && finish 0 \
|
|
376
|
-
"✓ clean after $pass review pass(es) — no blocking findings$(def_note)"
|
|
377
|
-
|
|
378
|
-
fp="$(json_str "$verdict" fingerprint)"
|
|
379
|
-
if [ -n "$fp" ] && [ "$fp" = "$prev_fp" ]; then
|
|
380
|
-
finish 3 "✗ non-convergent — the same $blocking blocking finding(s) survived a fix pass; see $verdict"
|
|
381
|
-
fi
|
|
382
|
-
prev_fp="$fp"
|
|
383
|
-
|
|
384
|
-
# Last pass: report and stop. A /cohorte-fix here would leave unreviewed code behind.
|
|
385
|
-
[ "$pass" -eq "$max" ] && finish 1 \
|
|
386
|
-
"✗ ceiling — $blocking blocking finding(s) after $max pass(es); re-run with a higher --max --resume$(def_note)"
|
|
387
|
-
|
|
388
|
-
run_phase fix || true
|
|
389
|
-
|
|
390
|
-
# Non-fatal by design: nothing to commit is a legitimate outcome (an agent
|
|
391
|
-
# that decided a finding needed no code change). The commit itself is the
|
|
392
|
-
# rollback point for the pass that just ran.
|
|
393
|
-
git add -A >>"$log" 2>&1
|
|
394
|
-
git commit -m "loop($id): fix pass $pass" >>"$log" 2>&1 || true
|
|
395
|
-
|
|
396
|
-
pass=$((pass + 1))
|
|
397
|
-
done
|
|
398
|
-
|
|
399
|
-
finish 1 "✗ ceiling — $max pass(es) exhausted"
|