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.
- package/CHANGELOG.md +84 -2
- package/README.md +57 -57
- package/bin/cli.js +23 -15
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +1 -1
- package/core/agents/review.md +3 -3
- package/core/commands/{audit.md → cohorte-audit.md} +3 -3
- package/core/commands/{brainstorm.md → cohorte-brainstorm.md} +4 -4
- package/core/commands/{build.md → cohorte-build.md} +15 -15
- package/core/commands/{doctor.md → cohorte-doctor.md} +17 -9
- package/core/commands/{fix.md → cohorte-fix.md} +15 -13
- package/core/commands/{init-pipeline.md → cohorte-init-pipeline.md} +1 -1
- package/core/commands/cohorte-loop.md +110 -0
- package/core/commands/{refactor.md → cohorte-refactor.md} +3 -3
- package/core/commands/{review.md → cohorte-review.md} +20 -19
- package/core/commands/{ship.md → cohorte-ship.md} +5 -5
- package/core/commands/{spec.md → cohorte-spec.md} +13 -13
- package/core/commands/{update-pipeline.md → cohorte-update-pipeline.md} +11 -6
- package/core/hooks/gate.py +101 -6
- package/core/templates/brainstorm-return.md +4 -4
- package/core/templates/decisions.template.md +1 -1
- package/core/templates/design-brief.md +1 -1
- package/core/templates/spec.template.md +7 -7
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +6 -6
- package/core/templates/steps/init-pipeline/03-draft-profile.md +1 -1
- package/core/templates/steps/init-pipeline/04-write-render.md +16 -12
- package/core/templates/steps/init-pipeline/05-report.md +5 -5
- package/core/workflows/audit.js +6 -6
- package/core/workflows/refactor.js +14 -14
- package/core/workflows/review.js +22 -22
- package/dashboard/README.md +2 -2
- package/dashboard/dist/assets/{index-DYyn4p93.js → index-P1I1JGtj.js} +2 -2
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +69 -19
- package/dashboard/server/index.js +5 -5
- package/dashboard/server/metrics.js +1 -1
- package/install.ps1 +23 -14
- package/install.sh +24 -14
- package/package.json +2 -2
- package/profile/PIPELINE.template.md +17 -16
- package/profile/SCHEMA.md +89 -77
- package/profile/cohorte.config.template.yaml +8 -8
- package/scripts/loop-detach.sh +153 -0
- package/scripts/loop.sh +75 -27
- package/scripts/metrics/collect.mjs +17 -8
- package/scripts/new-feature.sh.template +3 -3
- package/scripts/preflight.sh +40 -4
- package/scripts/remove-feature.sh.template +2 -2
- package/scripts/test-dashboard.mjs +34 -7
- package/scripts/test-gate.mjs +58 -0
- package/scripts/test-loop.mjs +49 -7
- package/scripts/test-metrics.mjs +23 -11
- package/scripts/test-workflows.mjs +7 -7
- package/scripts/validate-core.mjs +45 -23
- package/core/commands/drive.md +0 -80
- /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 …
|
|
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 /
|
|
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
|
-
|
|
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
|
|
177
|
-
# in-review); re-stamping here each phase
|
|
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 "$
|
|
181
|
-
printf '▶ /%-
|
|
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
|
|
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,
|
|
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',
|
|
64
|
-
'
|
|
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
|
-
|
|
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
|
package/scripts/preflight.sh
CHANGED
|
@@ -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>")
|
|
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",
|