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.
Files changed (57) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +49 -41
  3. package/bin/cli.js +324 -28
  4. package/core/adapter/render.js +389 -0
  5. package/core/agents/implementer.template.md +3 -3
  6. package/core/agents/release.md +7 -6
  7. package/core/agents/review.md +17 -2
  8. package/core/commands/cohorte-audit.md +2 -0
  9. package/core/commands/cohorte-brainstorm.md +3 -11
  10. package/core/commands/cohorte-build.md +37 -23
  11. package/core/commands/cohorte-doctor.md +61 -36
  12. package/core/commands/cohorte-fix.md +3 -5
  13. package/core/commands/cohorte-init-pipeline.md +7 -8
  14. package/core/commands/cohorte-patch.md +113 -0
  15. package/core/commands/cohorte-refactor.md +5 -2
  16. package/core/commands/cohorte-review.md +20 -18
  17. package/core/commands/cohorte-ship.md +13 -14
  18. package/core/commands/cohorte-spec.md +4 -13
  19. package/core/commands/cohorte-update-pipeline.md +13 -12
  20. package/core/hooks/gate.py +203 -16
  21. package/core/runtimes/claude.json +73 -0
  22. package/core/runtimes/codex.json +82 -0
  23. package/core/runtimes/cursor.json +75 -0
  24. package/core/runtimes/gemini.json +75 -0
  25. package/core/runtimes/opencode.json +72 -0
  26. package/core/templates/patch.template.md +86 -0
  27. package/core/templates/spec.template.md +1 -3
  28. package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
  29. package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -11
  30. package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
  31. package/core/templates/steps/init-pipeline/05-report.md +1 -1
  32. package/core/workflows/review.js +1 -3
  33. package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
  34. package/dashboard/dist/index.html +1 -1
  35. package/dashboard/server/doctor.js +156 -69
  36. package/dashboard/server/index.js +12 -2
  37. package/dashboard/server/metrics.js +13 -6
  38. package/dashboard/server/runtime.js +115 -0
  39. package/dashboard/server/versions.js +12 -1
  40. package/install.ps1 +26 -3
  41. package/install.sh +28 -6
  42. package/package.json +6 -2
  43. package/profile/PIPELINE.template.md +8 -6
  44. package/profile/SCHEMA.md +70 -108
  45. package/profile/cohorte.config.template.yaml +0 -16
  46. package/scripts/kanban-move.sh +11 -1
  47. package/scripts/metrics/collect.mjs +5 -3
  48. package/scripts/preflight.sh +27 -8
  49. package/scripts/test-adapter.mjs +368 -0
  50. package/scripts/test-dashboard.mjs +70 -0
  51. package/scripts/test-gate.mjs +62 -0
  52. package/scripts/validate-core.mjs +26 -24
  53. package/core/commands/cohorte-loop.md +0 -110
  54. package/scripts/loop-detach.sh +0 -153
  55. package/scripts/loop.sh +0 -399
  56. package/scripts/telemetry-send.sh +0 -77
  57. 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.
@@ -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"