cohorte 1.6.0 → 2.0.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 +84 -2
  2. package/README.md +57 -57
  3. package/bin/cli.js +23 -15
  4. package/core/agents/implementer.template.md +3 -3
  5. package/core/agents/release.md +1 -1
  6. package/core/agents/review.md +3 -3
  7. package/core/commands/{audit.md → cohorte-audit.md} +3 -3
  8. package/core/commands/{brainstorm.md → cohorte-brainstorm.md} +4 -4
  9. package/core/commands/{build.md → cohorte-build.md} +15 -15
  10. package/core/commands/{doctor.md → cohorte-doctor.md} +17 -9
  11. package/core/commands/{fix.md → cohorte-fix.md} +15 -13
  12. package/core/commands/{init-pipeline.md → cohorte-init-pipeline.md} +1 -1
  13. package/core/commands/cohorte-loop.md +110 -0
  14. package/core/commands/{refactor.md → cohorte-refactor.md} +3 -3
  15. package/core/commands/{review.md → cohorte-review.md} +20 -19
  16. package/core/commands/{ship.md → cohorte-ship.md} +5 -5
  17. package/core/commands/{spec.md → cohorte-spec.md} +13 -13
  18. package/core/commands/{update-pipeline.md → cohorte-update-pipeline.md} +11 -6
  19. package/core/hooks/gate.py +101 -6
  20. package/core/templates/brainstorm-return.md +4 -4
  21. package/core/templates/decisions.template.md +1 -1
  22. package/core/templates/design-brief.md +1 -1
  23. package/core/templates/spec.template.md +7 -7
  24. package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
  25. package/core/templates/steps/init-pipeline/02-interview-gaps.md +6 -6
  26. package/core/templates/steps/init-pipeline/03-draft-profile.md +1 -1
  27. package/core/templates/steps/init-pipeline/04-write-render.md +16 -12
  28. package/core/templates/steps/init-pipeline/05-report.md +5 -5
  29. package/core/workflows/audit.js +6 -6
  30. package/core/workflows/refactor.js +14 -14
  31. package/core/workflows/review.js +22 -22
  32. package/dashboard/README.md +2 -2
  33. package/dashboard/dist/assets/{index-DYyn4p93.js → index-P1I1JGtj.js} +2 -2
  34. package/dashboard/dist/index.html +1 -1
  35. package/dashboard/server/doctor.js +69 -19
  36. package/dashboard/server/index.js +5 -5
  37. package/dashboard/server/metrics.js +1 -1
  38. package/install.ps1 +23 -14
  39. package/install.sh +24 -14
  40. package/package.json +2 -2
  41. package/profile/PIPELINE.template.md +17 -16
  42. package/profile/SCHEMA.md +89 -77
  43. package/profile/cohorte.config.template.yaml +8 -8
  44. package/scripts/loop-detach.sh +153 -0
  45. package/scripts/loop.sh +75 -27
  46. package/scripts/metrics/collect.mjs +17 -8
  47. package/scripts/new-feature.sh.template +3 -3
  48. package/scripts/preflight.sh +40 -4
  49. package/scripts/remove-feature.sh.template +2 -2
  50. package/scripts/test-dashboard.mjs +34 -7
  51. package/scripts/test-gate.mjs +58 -0
  52. package/scripts/test-loop.mjs +49 -7
  53. package/scripts/test-metrics.mjs +23 -11
  54. package/scripts/test-workflows.mjs +7 -7
  55. package/scripts/validate-core.mjs +45 -23
  56. package/core/commands/drive.md +0 -80
  57. /package/core/commands/{align-ds.md → cohorte-align-ds.md} +0 -0
@@ -0,0 +1,153 @@
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 CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # loop.sh — autonomous /build → /review → /fix → /review … loop for ONE feature.
3
+ # loop.sh — autonomous /cohorte-build → /cohorte-review → /cohorte-fix → /cohorte-review …
4
+ # loop for ONE feature.
4
5
  #
5
6
  # loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
6
7
  #
7
8
  # THE POINT: every phase runs as a SEPARATE `claude -p` child with its own fresh
8
- # context. The session that typed /drive never sees the diff, the N review reports
9
+ # context. The session that typed /cohorte-loop never sees the diff, the N review reports
9
10
  # or the N contracts — it reads only this script's one-line-per-phase stdout and,
10
11
  # at the end, the verdict JSON. Running the loop inside the calling session would
11
12
  # accumulate all of it in a history that is re-sent at input price on every turn,
12
13
  # which is the exact cost the pipeline's /clear discipline exists to avoid.
13
14
  #
14
- # Contract with the pipeline: /review writes specs/reports/<id>.verdict.json on
15
- # every run, and /build writes <id>.readiness.json + <id>.build.json. Those three
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
16
17
  # files — `blocking`, `fingerprint`, `unreviewed`, `verdict`, `dead` — are the ONLY
17
18
  # channel between cohorte and this driver. No prose is parsed.
18
19
  #
@@ -25,16 +26,16 @@
25
26
  # 0 clean — a review returned blocking == 0
26
27
  # 1 ceiling — --max passes used, still blocking (the fix was progressing;
27
28
  # re-run with a higher --max)
28
- # 2 no usable verdict — /review produced nothing, or aborted on a red
29
+ # 2 no usable verdict — /cohorte-review produced nothing, or aborted on a red
29
30
  # preflight (typecheck/lint/tests broken; the message says which)
30
31
  # 3 non-convergent — two consecutive reviews returned the SAME blocking
31
32
  # fingerprint: the fix is treading water, a higher --max will not help
32
- # 4 not implementable — /build's readiness gate returned NOT-READY and spawned
33
+ # 4 not implementable — /cohorte-build's readiness gate returned NOT-READY and spawned
33
34
  # no agent: the frozen spec cannot be built (missing contract shape, unowned
34
- # area, absent dependency). Needs /spec, not more passes.
35
+ # area, absent dependency). Needs /cohorte-spec, not more passes.
35
36
  # 64 usage — bad flag, bad id, missing spec, no `claude` on PATH
36
37
  #
37
- # No /fix runs on the last pass: fixing without a review behind it ships
38
+ # No /cohorte-fix runs on the last pass: fixing without a review behind it ships
38
39
  # unaudited code. Each fix pass is committed — that commit is the only way back
39
40
  # after N autonomous passes.
40
41
  #
@@ -49,15 +50,51 @@
49
50
 
50
51
  set -uo pipefail
51
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
+
52
89
  usage() {
53
90
  cat >&2 <<'EOF'
54
91
  usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
55
92
 
56
93
  --max=N stop after N review passes (default 5) — a ceiling on the TOTAL
57
94
  pass count, so it still means "5 passes" when resuming at pass 3
58
- --no-build never build — re-run the /review ⇄ /fix loop on a feature that
95
+ --no-build never build — re-run the /cohorte-review ⇄ /cohorte-fix loop on a feature that
59
96
  is already built (the common case; the build stamp is ignored)
60
- --rebuild force a /build even if the stamp says it was already built
97
+ --rebuild force a /cohorte-build even if the stamp says it was already built
61
98
  --resume continue from the pass recorded in the spec's front-matter
62
99
  (loop_pass), instead of starting over at pass 1
63
100
 
@@ -111,7 +148,7 @@ cd "$root" || exit 64
111
148
 
112
149
  spec="specs/$id.md"
113
150
  [ -f "$spec" ] || {
114
- echo "loop: no spec at $spec — run /spec $id first" >&2; exit 64; }
151
+ echo "loop: no spec at $spec — run /cohorte-spec $id first" >&2; exit 64; }
115
152
 
116
153
  reports="specs/reports"
117
154
  mkdir -p "$reports"
@@ -170,15 +207,24 @@ fm_set() { # fm_set <key> <value> (replace, else append)
170
207
  # parent re-imports the children's transcripts, the whole point is lost.
171
208
  # $CLAUDE_FLAGS is intentionally unquoted — it is a flag list, not one word.
172
209
  run_phase() {
173
- cmd="$1"
210
+ # $1 is the PHASE name (build|review|fix), which is not the same string as the
211
+ # command that runs it (`/cohorte-build`). Every command gained a `cohorte-`
212
+ # prefix in 2.0.0 so Claude Code's built-ins can never shadow them again — but
213
+ # the phase name is a DATA CONTRACT, written into the spec's `loop_phase`, into
214
+ # specs/reports/<id>.*.json and into pipeline-metrics.jsonl, and read back by
215
+ # --resume and the dashboard. Prefixing it too would orphan every historical
216
+ # metrics line and break resume on specs written by 1.x. So: prefix the command,
217
+ # never the phase.
218
+ phase="$1"
219
+ cmd="cohorte-$phase"
174
220
  # Stamp the state BEFORE the phase runs: if this child dies (or the whole
175
221
  # session does), the spec already says where the loop was — that is what
176
- # --resume reads back. Child commands write `status` themselves (/fix sets
177
- # in-review); re-stamping here each phase is what keeps `in-progress` true.
222
+ # --resume reads back. Child commands write `status` themselves (/cohorte-fix
223
+ # sets in-review); re-stamping here each phase keeps `in-progress` true.
178
224
  fm_set status in-progress
179
225
  fm_set loop_pass "$pass"
180
- fm_set loop_phase "$cmd"
181
- printf '▶ /%-6s %-24s ' "$cmd" "$id"
226
+ fm_set loop_phase "$phase"
227
+ printf '▶ /%-14s %-24s ' "$cmd" "$id"
182
228
  printf '\n\n===== /%s %s =====\n' "$cmd" "$id" >>"$log"
183
229
  # shellcheck disable=SC2086
184
230
  if claude -p "/$cmd $id" $CLAUDE_FLAGS >>"$log" 2>&1; then
@@ -196,7 +242,7 @@ json_num() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p
196
242
  json_str() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n1; }
197
243
 
198
244
  # Terminal status goes into the spec, not just into this stdout: a clean run
199
- # leaves the feature ready to /ship, any failure leaves it visibly `blocked` for
245
+ # leaves the feature ready to /cohorte-ship, any failure leaves it visibly `blocked` for
200
246
  # the human and for the dashboard. Exit 64 never reaches here (usage dies earlier),
201
247
  # so every code handled below is a real run outcome.
202
248
  finish() {
@@ -214,7 +260,7 @@ finish() {
214
260
  # One short clause naming the deferred findings, appended to a closing line.
215
261
  # They are NOT blocking (they live in the backlog, not in ## Remediation), so
216
262
  # they never change an exit code — but a loop that silently drops them is the
217
- # leak /review §3.5 exists to close, so the driver names them.
263
+ # leak /cohorte-review §3.5 exists to close, so the driver names them.
218
264
  def_note() {
219
265
  d="$(json_num "$verdict" deferred 2>/dev/null)"
220
266
  case "$d" in ''|0) return 0 ;; esac
@@ -222,7 +268,7 @@ def_note() {
222
268
  }
223
269
 
224
270
  # --- build -------------------------------------------------------------------
225
- # The stamp is the driver's own bookkeeping — /build knows nothing about it.
271
+ # The stamp is the driver's own bookkeeping — /cohorte-build knows nothing about it.
226
272
  case "$build_mode" in
227
273
  force) do_build=1 ;;
228
274
  never) do_build=0 ;;
@@ -247,21 +293,22 @@ if [ "$do_build" -eq 1 ]; then
247
293
  rm -f "$readiness" "$buildjson"
248
294
  build_ok=0
249
295
  run_phase build && build_ok=1
250
- # The readiness gate is checked BEFORE the child's exit status: /build aborting
251
- # on NOT-READY is a cleaner diagnosis than "/build failed", and it is the one
296
+ # The readiness gate is checked BEFORE the child's exit status: /cohorte-build aborting
297
+ # on NOT-READY is a cleaner diagnosis than "/cohorte-build failed", and it is the one
252
298
  # outcome that more passes cannot fix.
253
299
  if [ -f "$readiness" ] &&
254
300
  grep -q '"verdict"[[:space:]]*:[[:space:]]*"NOT-READY"' "$readiness"; then
255
- finish 4 "✗ spec not implementable — /build's readiness gate returned NOT-READY and spawned no agent; see $readiness, then /spec $id"
301
+ finish 4 "✗ spec not implementable — /cohorte-build's readiness gate returned NOT-READY and \
302
+ spawned no agent; see $readiness, then /cohorte-spec $id"
256
303
  fi
257
- # A dead implementer returns nothing, so /build can finish "successfully" having
304
+ # A dead implementer returns nothing, so /cohorte-build can finish "successfully" having
258
305
  # built one surface of two. Reviewing that would spend N reviewers auditing a
259
306
  # half-built feature and report its gaps as findings to fix — the wrong diagnosis
260
307
  # at the wrong price. `dead` is a non-empty array only when a surface died twice.
261
308
  if [ -f "$buildjson" ] && grep -q '"dead"[[:space:]]*:[[:space:]]*\[[^]]' "$buildjson"; then
262
309
  finish 2 "✗ an implementer died — the surface(s) in \"dead\" were never built; see $buildjson and $log"
263
310
  fi
264
- [ "$build_ok" -eq 1 ] || finish 2 "✗ /build failed — see $log"
311
+ [ "$build_ok" -eq 1 ] || finish 2 "✗ /cohorte-build failed — see $log"
265
312
  date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
266
313
  fi
267
314
 
@@ -274,10 +321,11 @@ while [ "$pass" -le "$max" ]; do
274
321
  run_phase review || true # exit status of the child is not the verdict
275
322
 
276
323
  [ -f "$verdict" ] || finish 2 \
277
- "✗ /review wrote no verdict (pass $pass) — see $log"
324
+ "✗ /cohorte-review wrote no verdict (pass $pass) — see $log"
278
325
 
279
326
  if grep -q '"aborted"' "$verdict"; then
280
- finish 2 "✗ /review aborted on a red preflight — typecheck/lint/tests are broken, see $reports/$id.preflight.txt"
327
+ finish 2 "✗ /cohorte-review aborted on a red preflight — typecheck/lint/tests are broken, \
328
+ see $reports/$id.preflight.txt"
281
329
  fi
282
330
 
283
331
  # A reviewer that died twice leaves its surface unaudited, and `blocking` counts only
@@ -300,7 +348,7 @@ while [ "$pass" -le "$max" ]; do
300
348
  fi
301
349
  prev_fp="$fp"
302
350
 
303
- # Last pass: report and stop. A /fix here would leave unreviewed code behind.
351
+ # Last pass: report and stop. A /cohorte-fix here would leave unreviewed code behind.
304
352
  [ "$pass" -eq "$max" ] && finish 1 \
305
353
  "✗ ceiling — $blocking blocking finding(s) after $max pass(es); re-run with a higher --max --resume$(def_note)"
306
354
 
@@ -41,12 +41,12 @@ const IDLE_GAP_S = 120;
41
41
 
42
42
  // A prompt this short with no command in it ("continue", "go", "ok next") is the human
43
43
  // steering a run that is already going, not starting a new one. Without this, a single
44
- // /review driven by three "continue"s reports as one /review plus three anonymous chat
44
+ // /cohorte-review driven by three "continue"s reports as one /cohorte-review plus three anonymous chat
45
45
  // runs, and three quarters of its cost lands under (chat).
46
46
  const CONTINUATION_MAX_CHARS = 40;
47
47
 
48
48
  // Commands are recognised two ways. `<command-name>` is emitted only when the whole prompt
49
- // IS the slash command; in practice people write "move on branding-ramp and /review", which
49
+ // IS the slash command; in practice people write "move on branding-ramp and /cohorte-review", which
50
50
  // the harness records as ordinary prose. So we also look for an inline mention, checked
51
51
  // against the real command list rather than any /token — otherwise a file path like
52
52
  // /usr/bin or a URL fragment would invent commands that were never run.
@@ -60,20 +60,29 @@ function knownCommands() {
60
60
  }
61
61
  // Fallback for a collector run outside the package (e.g. copied into a repo on its own).
62
62
  if (!names.size) {
63
- for (const n of ['brainstorm', 'spec', 'build', 'review', 'fix', 'ship',
64
- 'audit', 'refactor', 'align-ds', 'doctor', 'init-pipeline', 'update-pipeline']) names.add(n);
63
+ for (const n of ['cohorte-brainstorm', 'cohorte-spec', 'cohorte-build', 'cohorte-review',
64
+ 'cohorte-fix', 'cohorte-ship', 'cohorte-audit', 'cohorte-refactor',
65
+ 'cohorte-align-ds', 'cohorte-doctor', 'cohorte-loop',
66
+ 'cohorte-init-pipeline', 'cohorte-update-pipeline']) names.add(n);
65
67
  }
66
68
  // Retired commands. The list above is read from the shipped core, so a command that is
67
69
  // removed stops being recognised — and every run of it already in the transcripts silently
68
70
  // reclassifies as (chat), rewriting history and inflating the catch-all bucket. Keep the
69
71
  // names here so past runs stay attributed to what actually ran.
70
- for (const n of ['cycle', 'smoke']) names.add(n);
72
+ //
73
+ // 2.0.0 prefixed every command with `cohorte-`, which retires all 13 bare names at once:
74
+ // months of transcripts say `/build`, and without these they would all reclassify to (chat)
75
+ // — the largest instance of exactly the bug this list exists to prevent. `drive`/`loop` are
76
+ // both here because the driver was `/loop` → `/drive` (1.6.0) → `/cohorte-loop` (2.0.0).
77
+ for (const n of ['cycle', 'smoke', 'drive', 'loop', 'brainstorm', 'spec', 'build', 'review',
78
+ 'fix', 'ship', 'audit', 'refactor', 'align-ds', 'doctor',
79
+ 'init-pipeline', 'update-pipeline']) names.add(n);
71
80
  return names;
72
81
  }
73
82
  const COMMANDS = knownCommands();
74
83
 
75
84
  // An invocation is a short instruction that is mostly the command ("move on branding-ramp
76
- // and /review"). A long prompt that happens to name one is someone TALKING ABOUT the
85
+ // and /cohorte-review"). A long prompt that happens to name one is someone TALKING ABOUT the
77
86
  // command — a bug report, a design discussion, a pasted transcript. Counting those as runs
78
87
  // inflates a command's run count and cost with conversation that never invoked it, which is
79
88
  // exactly what happened in cohorte's own repo while this pipeline was being discussed.
@@ -84,7 +93,7 @@ function commandIn(text) {
84
93
  const explicit = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>/.exec(text);
85
94
  if (explicit) return explicit[1].replace(/^\//, '');
86
95
  if (text.trim().length > MENTION_MAX_CHARS) return null;
87
- // Last mention wins: "finish /build then /review" ends on the one being asked for.
96
+ // Last mention wins: "finish /cohorte-build then /cohorte-review" ends on the one being asked for.
88
97
  let found = null;
89
98
  for (const m of text.matchAll(/(?:^|\s)\/([a-z][a-z0-9-]{2,})\b/g)) {
90
99
  if (COMMANDS.has(m[1])) found = m[1];
@@ -250,7 +259,7 @@ function parseSession(file) {
250
259
  // Not every user-role turn is the human starting something. The harness injects
251
260
  // turns mid-run — a local-command echo, and (critically for cohorte) a
252
261
  // <task-notification> when a background agent finishes. Those arrive DURING a
253
- // a /build; treating them as boundaries chops one command into several
262
+ // a /cohorte-build; treating them as boundaries chops one command into several
254
263
  // cheap-looking fragments and strands the agent spend in the wrong segment.
255
264
  if (!cmd && /<(local-command-(stdout|stderr)|task-notification|system-reminder)>/.test(text)) continue;
256
265
  // A short steer with no command keeps the current run open rather than opening a new
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
3
  # new-feature.sh — spin up an ISOLATED git worktree for one feature so features
4
- # can be built fully in parallel. Rendered from a template by /init-pipeline;
4
+ # can be built fully in parallel. Rendered from a template by /cohorte-init-pipeline;
5
5
  # the double-underscore tokens below are substituted from PIPELINE.md §isolation.
6
- # (This comment deliberately avoids spelling the token pattern: /doctor flags any
6
+ # (This comment deliberately avoids spelling the token pattern: /cohorte-doctor flags any
7
7
  # leftover double-underscore-caps token in the RENDERED script as unrendered.)
8
8
  #
9
9
  # Usage: scripts/new-feature.sh <feature_id>
@@ -85,7 +85,7 @@ Fully isolated — run its dev + migrations alongside other worktrees:
85
85
  cd $worktree_dir
86
86
  __MIGRATE_CMD__
87
87
  cd $worktree_dir && __DEV_CMD__
88
- /brainstorm → /spec → (design) → /build $id → test → /review → /ship
88
+ /cohorte-brainstorm → /cohorte-spec → (design) → /cohorte-build $id → test → /cohorte-review → /cohorte-ship
89
89
 
90
90
  Tear down when merged: scripts/remove-feature.sh $id
91
91
  EOF
@@ -1,6 +1,6 @@
1
1
  #!/bin/sh
2
2
  #
3
- # preflight.sh — deterministic phase gate for /review.
3
+ # preflight.sh — deterministic phase gate for /cohorte-review.
4
4
  #
5
5
  # Runs the profile's mechanical checks (typecheck, lint, tests — whatever the caller
6
6
  # passes) BEFORE any agent is spawned. A red gate means the caller aborts and relays
@@ -13,9 +13,14 @@
13
13
  # never enters the calling agent's context.
14
14
  # - First failure: prints the last 40 lines of the report raw to stderr and exits 1.
15
15
  # The caller must stop there — no agents.
16
- # - All green: writes `<project>/.claude/preflight.ok` ("<epoch> <HEAD sha>") — the
17
- # stamp `hooks/gate.py` checks (gate-config.json `preflight` block) before letting
16
+ # - All green: writes `<project>/.claude/preflight.ok` ("<epoch> <HEAD sha> <tree digest>")
17
+ # — the stamp `hooks/gate.py` checks (gate-config.json `preflight` block) before letting
18
18
  # review agents dispatch.
19
+ #
20
+ # The stamp is a LOCAL, GITIGNORED artifact. Versioning it breaks the gate outright: the
21
+ # stamp records the pre-commit sha, committing it moves HEAD, so the checked-out stamp can
22
+ # never match its own commit — every dispatch then reports "HEAD moved" on a clean tree.
23
+ # A tracked stamp also rides into every new worktree, handing it a green it never earned.
19
24
 
20
25
  set -u
21
26
 
@@ -46,6 +51,37 @@ done
46
51
  # resolves to the MAIN checkout's .git from any worktree.
47
52
  proj="${CLAUDE_PROJECT_DIR:-$(dirname "$(git rev-parse --git-common-dir 2>/dev/null || echo ./.git)")}"
48
53
  sha=$(git rev-parse HEAD 2>/dev/null || echo none)
54
+
55
+ # Content digest of the state we actually verified: the git TREE id of the working tree,
56
+ # built in a throwaway index so nothing touches the real one. The sha alone is the wrong
57
+ # key — the reviewed tree is normally DIRTY (uncommitted feature work), so committing it
58
+ # invalidates the stamp without changing a line of code, while an implementer's edit
59
+ # changes every line without moving HEAD. A tree id is content-addressed: it survives a
60
+ # commit of the same content and dies on any real edit (including new untracked files).
61
+ # `.claude` (stamps, metrics) and `specs` (DoD ticks, report buffer) are excluded — the
62
+ # pipeline writes those itself between the preflight and the dispatch it must not invalidate.
63
+ # gate.py recomputes this identically; any change here must land there too.
64
+ digest=none
65
+ tmpidx=$(mktemp 2>/dev/null || echo "")
66
+ if [ -n "$tmpidx" ]; then
67
+ # Seed from the real index (not `read-tree HEAD`) so its stat cache is preserved and
68
+ # `add` only re-hashes files that actually changed.
69
+ idx=$(git rev-parse --git-path index 2>/dev/null || echo "")
70
+ if [ -n "$idx" ] && [ -f "$idx" ]; then
71
+ cp "$idx" "$tmpidx" 2>/dev/null || true
72
+ else
73
+ rm -f "$tmpidx" # a 0-byte index is a corrupt index
74
+ fi
75
+ # Drop the excluded paths from the throwaway index entirely: an `add` exclude only stops
76
+ # them being *updated*, so anything already tracked there (a committed stamp, a spec)
77
+ # would still land in the tree and shift the digest.
78
+ GIT_INDEX_FILE="$tmpidx" git rm --cached -r -q --ignore-unmatch -- .claude specs > /dev/null 2>&1 || true
79
+ if GIT_INDEX_FILE="$tmpidx" git add -A -- . ':(exclude).claude' ':(exclude)specs' > /dev/null 2>&1; then
80
+ digest=$(GIT_INDEX_FILE="$tmpidx" git write-tree 2>/dev/null || echo none)
81
+ fi
82
+ rm -f "$tmpidx" "$tmpidx.lock" 2>/dev/null || true
83
+ fi
84
+ [ -n "$digest" ] || digest=none
49
85
  # Stamp BOTH the main checkout and the cwd: gate.py reads CLAUDE_PROJECT_DIR,
50
86
  # which is the worktree when the session was opened there and the main checkout
51
87
  # when it wasn't — the two disagree, and either layout is supported.
@@ -55,7 +91,7 @@ for d in "$proj" "$(pwd)"; do
55
91
  [ "$d" = "$last" ] && continue # same dir twice in the main checkout
56
92
  last="$d"
57
93
  mkdir -p "$d/.claude" 2>/dev/null || true
58
- printf '%s %s\n' "$now" "$sha" > "$d/.claude/preflight.ok" 2>/dev/null || true
94
+ printf '%s %s %s\n' "$now" "$sha" "$digest" > "$d/.claude/preflight.ok" 2>/dev/null || true
59
95
  done
60
96
 
61
97
  echo "PREFLIGHT PASS ($n checks green) — full log: $report"
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
3
  # remove-feature.sh — tear down a feature's isolated worktree. Rendered from a
4
- # template by /init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
5
- # spelled out here so /doctor's unrendered-token check never trips on this comment).
4
+ # template by /cohorte-init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
5
+ # spelled out here so /cohorte-doctor's unrendered-token check never trips on this comment).
6
6
  #
7
7
  # Usage: scripts/remove-feature.sh <feature_id> [--drop-db]
8
8
  #
@@ -2,8 +2,8 @@
2
2
  // Tests for the dashboard's server modules (dashboard/server/*.js).
3
3
  //
4
4
  // These are shipped runtime code with real logic and zero coverage until now:
5
- // a hand-rolled YAML parser that every /doctor check is derived from, a metrics
6
- // aggregator, the JS port of /doctor, an Obsidian board parser, the fleet
5
+ // a hand-rolled YAML parser that every /cohorte-doctor check is derived from, a metrics
6
+ // aggregator, the JS port of /cohorte-doctor, an Obsidian board parser, the fleet
7
7
  // registry, and an HTTP layer whose guards are the dashboard's only defence
8
8
  // against a web page driving the local agent.
9
9
  //
@@ -127,7 +127,7 @@ console.log("usage.js — the collector bridge");
127
127
  }
128
128
 
129
129
  // ── doctor.js ────────────────────────────────────────────────────────────────
130
- console.log("doctor.js — the /doctor port");
130
+ console.log("doctor.js — the /cohorte-doctor port");
131
131
  {
132
132
  const spec = (fm) => `---\n${fm}\n---\n\n# x\n`;
133
133
  const d = scratch();
@@ -136,13 +136,13 @@ console.log("doctor.js — the /doctor port");
136
136
  writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
137
137
  writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
138
138
  writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
139
- // /audit writes this file by design and it has no front-matter. Scanning it as a
140
- // spec made /doctor warn about a file cohorte itself had just created — it fired in
141
- // every project that had ever run /audit.
139
+ // /cohorte-audit writes this file by design and it has no front-matter. Scanning it as a
140
+ // spec made /cohorte-doctor warn about a file cohorte itself had just created — it fired in
141
+ // every project that had ever run /cohorte-audit.
142
142
  writeFileSync(join(d, "specs", "refactor-backlog.md"), "# Refactor Backlog\n\n## backend\n- [ ] x\n");
143
143
  const specs = scanSpecs(d);
144
144
  eq("_template.md is excluded", specs.length, 3);
145
- eq("the /audit backlog is not scanned as a spec",
145
+ eq("the /cohorte-audit backlog is not scanned as a spec",
146
146
  specs.some(s => s.file === "refactor-backlog.md"), false);
147
147
  eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
148
148
  eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
@@ -201,6 +201,21 @@ console.log("doctor.js — the /doctor port");
201
201
  eq("retrieval wired in .mcp.json ⇒ ok", by(s.checks, "retrieval").status, "ok");
202
202
  eq("workflows + profile-reader ⇒ ok", by(s.checks, "workflows").status, "ok");
203
203
 
204
+ // Local artifacts: a versioned preflight stamp is what made the phase gate ask on
205
+ // every review dispatch forever, so its absence from .gitignore is a hard failure.
206
+ check("no .gitignore ⇒ local artifacts flagged bad (the stamp is the breaking one)",
207
+ by(s.checks, "artifacts").status === "bad"
208
+ && /preflight\.ok/.test(by(s.checks, "artifacts").detail),
209
+ by(s.checks, "artifacts").detail);
210
+ writeFileSync(join(d, ".gitignore"),
211
+ "node_modules/\n.claude/preflight.ok\n.claude/pipeline-metrics.jsonl\nspecs/reports/\n");
212
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
213
+ eq("all local artifacts gitignored ⇒ ok", by(s.checks, "artifacts").status, "ok");
214
+ writeFileSync(join(d, ".gitignore"), "node_modules/\n.claude/\nspecs/reports/\n");
215
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
216
+ eq("a `.claude/` directory rule covers the files inside it",
217
+ by(s.checks, "artifacts").status, "ok");
218
+
204
219
  // …and each check must actually FAIL when its precondition breaks.
205
220
  writeFileSync(join(d, ".claude", "gate-config.json"),
206
221
  JSON.stringify({ ...gate, preflight: { enabled: false } }));
@@ -368,6 +383,18 @@ console.log("index.js — HTTP guards");
368
383
  const badCmd = await post({ action: "claude", command: "/evil", project: proj });
369
384
  eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
370
385
 
386
+ // Both directions, because testing only the rejection missed a real bug: 2.0.0 prefixed
387
+ // every command, the error message was updated to say `/cohorte-audit`, but the allowlist
388
+ // regex still matched the bare names — so the server accepted the one command that no
389
+ // longer exists and rejected the only one the UI can send. A rejection-only test is blind
390
+ // to an allowlist that drifts away from the client.
391
+ const staleCmd = await post({ action: "claude", command: "/audit", project: proj });
392
+ eq("the pre-2.0.0 unprefixed command is rejected", staleCmd.status, 400);
393
+
394
+ const goodCmd = await post({ action: "claude", command: "/cohorte-audit", project: proj });
395
+ check("a prefixed whitelisted command passes the allowlist",
396
+ goodCmd.status !== 400 || !/unsupported command/.test((await goodCmd.json()).error || ""));
397
+
371
398
  eq("a missing hashed asset 404s (never index.html)",
372
399
  (await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
373
400
  eq("a malformed percent-escape is a 400, not a 500",