cohorte 1.4.0 → 1.5.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.
@@ -12,7 +12,10 @@ const { versions } = require('./versions');
12
12
  // Rendered surface agents live alongside these fixed (non-surface) agents; exclude them
13
13
  // from the orphan check so they're never mistaken for a stray surface agent.
14
14
  const FIXED_AGENTS = new Set([
15
- 'review', 'release', 'smoke', 'profile-reader',
15
+ 'review', 'release', 'profile-reader',
16
+ // retired (1.5.0) — still excluded so a stale install's leftover file isn't
17
+ // reported as a stray surface agent.
18
+ 'smoke',
16
19
  'implementer.template',
17
20
  ]);
18
21
 
@@ -126,7 +129,7 @@ function checkGate(profile, projectRoot) {
126
129
  const wantPf = gate.preflight || {};
127
130
  const havePf = (cfg.preflight && typeof cfg.preflight === 'object') ? cfg.preflight : {};
128
131
  if (!!wantPf.enabled !== !!havePf.enabled
129
- || !sameSet(wantPf.agents || ['review', 'smoke'], havePf.agents || ['review', 'smoke'])
132
+ || !sameSet(wantPf.agents || ['review'], havePf.agents || ['review'])
130
133
  || Number(wantPf.max_age_minutes || 30) !== Number(havePf.max_age_minutes || 30)) {
131
134
  drifted.push('preflight');
132
135
  }
@@ -11,6 +11,7 @@ const { versions } = require('./versions');
11
11
  const { state } = require('./doctor');
12
12
  const { kanban } = require('./kanban');
13
13
  const { metrics } = require('./metrics');
14
+ const { usage } = require('./usage');
14
15
  const fleet = require('./fleet');
15
16
 
16
17
  const MIME = {
@@ -321,6 +322,12 @@ function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, versi
321
322
  const root = q ? path.resolve(q) : projectRoot;
322
323
  return sendJson(res, 200, metrics({ projectRoot: root }));
323
324
  }
325
+ if (url === '/api/usage') {
326
+ const q = new URL(req.url, 'http://localhost');
327
+ const root = q.searchParams.get('project') ? path.resolve(q.searchParams.get('project')) : projectRoot;
328
+ const days = Number(q.searchParams.get('days')) || null;
329
+ return sendJson(res, 200, usage({ projectRoot: root, days }));
330
+ }
324
331
  if (url === '/api/projects') {
325
332
  const body = await readBody(req);
326
333
  if (req.method === 'POST') {
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  // Read a project's `.claude/pipeline-metrics.jsonl` (one line per phase batch, appended by
3
- // /build, /review, /fix and /smoke) and aggregate it per feature: wall-clock per phase, fix
3
+ // /build, /review and /fix) and aggregate it per feature: wall-clock per phase, fix
4
4
  // rounds, and per-surface results. Dependency-free; a missing file is simply "no data yet".
5
5
  //
6
6
  // Two line formats coexist in the file:
@@ -11,9 +11,9 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
 
14
- // `cycle` is a RETIRED phase, kept so metrics files written before its removal still render.
15
- // Without it in this list its per-surface results parse fine but land in no column — the
16
- // surface table showed rows with every cell empty.
14
+ // `smoke` and `cycle` are RETIRED phases, kept so metrics files written before their removal
15
+ // still render. Without them in this list their per-surface results parse fine but land in no
16
+ // column — the surface table showed rows with every cell empty.
17
17
  const PHASES = ['build', 'review', 'fix', 'smoke', 'cycle'];
18
18
 
19
19
  // Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ // Serve the metrics collector's rollup (scripts/metrics/collect.mjs) to the dashboard:
3
+ // real cost and runtime per command, read from Claude Code's own transcripts.
4
+ //
5
+ // This is the SECOND metrics source in the cockpit, and the two answer different questions.
6
+ // `metrics.js` reads `.claude/pipeline-metrics.jsonl` — written by the model itself, so it
7
+ // carries per-surface verdicts (ok / REVISE:2 / error) that only the model knows, but it
8
+ // misses any run that ended early and can never report tokens. This one is derived from the
9
+ // transcripts, so it is complete and exact on cost and time but knows nothing about verdicts.
10
+ // Verdicts from one, money from the other; neither is a replacement for the other.
11
+ //
12
+ // The collector is ESM and this server is CommonJS, so it runs as a child process — the same
13
+ // bridge `bin/cli.js` uses. A spawn is ~1-2s on a large history, which is why the result is
14
+ // cached: the panel polls, and re-parsing tens of MB of transcripts on every poll would make
15
+ // the whole cockpit feel broken.
16
+
17
+ const path = require('path');
18
+ const { spawnSync } = require('child_process');
19
+
20
+ const COLLECT = path.join(__dirname, '..', '..', 'scripts', 'metrics', 'collect.mjs');
21
+
22
+ // Transcripts only grow, and nobody needs sub-minute freshness on a spend figure.
23
+ const CACHE_MS = 60_000;
24
+ const cache = new Map(); // projectRoot → { at, value }
25
+
26
+ function usage({ projectRoot, days = null, force = false }) {
27
+ const key = `${projectRoot}|${days || ''}`;
28
+ const hit = cache.get(key);
29
+ if (!force && hit && Date.now() - hit.at < CACHE_MS) return hit.value;
30
+
31
+ const args = [COLLECT, projectRoot, '--json'];
32
+ if (days) args.push(`--days=${days}`);
33
+
34
+ let value;
35
+ try {
36
+ const r = spawnSync(process.execPath, args, {
37
+ encoding: 'utf8',
38
+ // A pathological history must not wedge the cockpit's event loop forever.
39
+ timeout: 60_000,
40
+ maxBuffer: 64 * 1024 * 1024,
41
+ });
42
+ if (r.status !== 0 || !r.stdout) {
43
+ value = { present: false, error: (r.stderr || '').trim().split('\n').slice(-1)[0] || 'collector failed' };
44
+ } else {
45
+ const parsed = JSON.parse(r.stdout);
46
+ // No transcripts for this project is a normal state (a fresh checkout, or a project
47
+ // driven from another machine), not an error — say so rather than rendering zeros
48
+ // that look like "this pipeline is free".
49
+ value = parsed.totals && parsed.totals.sessions
50
+ ? { present: true, ...parsed }
51
+ : { present: false, error: 'no Claude Code transcripts found for this project' };
52
+ }
53
+ } catch (e) {
54
+ value = { present: false, error: String((e && e.message) || e) };
55
+ }
56
+
57
+ cache.set(key, { at: Date.now(), value });
58
+ return value;
59
+ }
60
+
61
+ module.exports = { usage };
package/install.ps1 CHANGED
@@ -151,6 +151,7 @@ try {
151
151
  Copy-Item (Join-Path $src 'scripts\kanban-move.sh') (Join-Path $dest 'pipeline\scripts') -Force
152
152
  Copy-Item (Join-Path $src 'scripts\telemetry-send.sh') (Join-Path $dest 'pipeline\scripts') -Force
153
153
  Copy-Item (Join-Path $src 'scripts\preflight.sh') (Join-Path $dest 'pipeline\scripts') -Force
154
+ Copy-Item (Join-Path $src 'scripts\loop.sh') (Join-Path $dest 'pipeline\scripts') -Force
154
155
  Copy-Item (Join-Path $src 'core\agents\implementer.template.md') (Join-Path $dest 'pipeline') -Force
155
156
  if (Test-Path (Join-Path $src 'CHANGELOG.md')) { Copy-Item (Join-Path $src 'CHANGELOG.md') (Join-Path $dest 'pipeline') -Force }
156
157
  [System.IO.File]::WriteAllText((Join-Path $dest 'pipeline\VERSION'), "$ver`n", [System.Text.UTF8Encoding]::new($false))
@@ -188,8 +189,10 @@ try {
188
189
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'agents') | Out-Null
189
190
  Copy-Item (Join-Path $src 'core\agents\review.md'),
190
191
  (Join-Path $src 'core\agents\release.md'),
191
- (Join-Path $src 'core\agents\smoke.md'),
192
192
  (Join-Path $src 'core\agents\profile-reader.md') (Join-Path $dest 'agents') -Force
193
+ # 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent.
194
+ Remove-Item -LiteralPath (Join-Path $dest 'agents\smoke.md') -Force -ErrorAction SilentlyContinue
195
+ Remove-Item -LiteralPath (Join-Path $dest 'commands\smoke.md') -Force -ErrorAction SilentlyContinue
193
196
  # 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
194
197
  # copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
195
198
  Remove-Item -LiteralPath (Join-Path $dest 'agents\questionnaire-researcher.md') -Force -ErrorAction SilentlyContinue
package/install.sh CHANGED
@@ -107,8 +107,9 @@ copy_core() {
107
107
  cp "$src/scripts/kanban-move.sh" "$dest/pipeline/scripts/"
108
108
  cp "$src/scripts/telemetry-send.sh" "$dest/pipeline/scripts/"
109
109
  cp "$src/scripts/preflight.sh" "$dest/pipeline/scripts/"
110
+ cp "$src/scripts/loop.sh" "$dest/pipeline/scripts/"
110
111
  chmod +x "$dest/pipeline/scripts/kanban-move.sh" "$dest/pipeline/scripts/telemetry-send.sh" \
111
- "$dest/pipeline/scripts/preflight.sh" 2>/dev/null || true
112
+ "$dest/pipeline/scripts/preflight.sh" "$dest/pipeline/scripts/loop.sh" 2>/dev/null || true
112
113
  cp "$src/core/agents/implementer.template.md" "$dest/pipeline/"
113
114
  [ -f "$src/CHANGELOG.md" ] && cp "$src/CHANGELOG.md" "$dest/pipeline/"
114
115
  printf '%s\n' "$ver" > "$dest/pipeline/VERSION"
@@ -149,8 +150,10 @@ PY
149
150
  copy_fixed_agents() {
150
151
  mkdir -p "$dest/agents"
151
152
  cp "$src/core/agents/review.md" "$src/core/agents/release.md" \
152
- "$src/core/agents/smoke.md" "$src/core/agents/profile-reader.md" \
153
+ "$src/core/agents/profile-reader.md" \
153
154
  "$dest/agents/"
155
+ # 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent.
156
+ rm -f "$dest/agents/smoke.md" "$dest/commands/smoke.md"
154
157
  # 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
155
158
  # copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
156
159
  rm -f "$dest/agents/questionnaire-researcher.md"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
@@ -106,7 +106,7 @@ commands:
106
106
  format: pnpm format
107
107
  typecheck: pnpm check-types
108
108
  test: pnpm test
109
- test_quiet: pnpm test --reporter=dot # bridled variant — what /review·/smoke preflight runs
109
+ test_quiet: pnpm test --reporter=dot # bridled variant — what the /review preflight runs
110
110
  # migration commands — omit / leave "" if the project has no DB migrations
111
111
  migrate: "cd apps/api && node ace migration:run"
112
112
  make_migration: "cd apps/api && node ace make:migration"
@@ -164,12 +164,12 @@ gate:
164
164
  - "git rebase"
165
165
  - "git reset"
166
166
  - "docker compose"
167
- # Phase gate: review/smoke dispatches require a fresh `.claude/preflight.ok` stamp,
167
+ # Phase gate: review dispatches require a fresh `.claude/preflight.ok` stamp,
168
168
  # written by pipeline/scripts/preflight.sh when typecheck+lint+tests are green —
169
169
  # gate.py "ask"s the dispatch when the stamp is missing, stale, or HEAD moved.
170
170
  preflight:
171
171
  enabled: true
172
- agents: [review, smoke] # subagent_types the stamp gates
172
+ agents: [review] # subagent_types the stamp gates
173
173
  max_age_minutes: 30
174
174
 
175
175
  ```
package/profile/SCHEMA.md CHANGED
@@ -34,7 +34,7 @@ generic pipeline uses it, so a stateless agent can read/regenerate the profile c
34
34
  | `contract.path` `.ext` `.index` | string | build | Where `<feature_id>` contract is authored + barrel. |
35
35
  | `contract.authored_by` | const `lead` | build | Implementers import it read-only, never edit. |
36
36
  | `commands.*` | string | all | Repo-wide install/dev/lint/format/typecheck/test + migrate. |
37
- | `commands.test_quiet` `.lint_quiet` | string | review, smoke, audit, workflows | Repo-wide bridled variants — what the `/review`·`/smoke` pre-flight runs. Same fallback as the per-surface ones. |
37
+ | `commands.test_quiet` `.lint_quiet` | string | review, audit, workflows | Repo-wide bridled variants — what the `/review` pre-flight runs. Same fallback as the per-surface ones. |
38
38
  | `rbac.enabled` | bool | brainstorm, review | Toggle RBAC personas + authz audit. |
39
39
  | `rbac.hierarchy` | list | review | Highest→lowest role list. |
40
40
  | `design.enabled` | bool | build, frontend, align-ds | `false` ⇒ design steps are no-ops. |
@@ -52,8 +52,8 @@ generic pipeline uses it, so a stateless agent can read/regenerate the profile c
52
52
  | `gate.ask[]` | list | hooks/gate.py, settings | Command substrings that require confirm, on any branch. |
53
53
  | `gate.ask_on_default_branch[]` | list | hooks/gate.py | Confirm ONLY on `default_branch`; free on feature branches. |
54
54
  | `gate.default_branch` | string | hooks/gate.py | Protected branch (default `main`); gate resolves via git. |
55
- | `gate.preflight.enabled` | bool | hooks/gate.py, review, smoke | Phase gate: review/smoke dispatches need a fresh preflight stamp. See §Preflight. |
56
- | `gate.preflight.agents[]` | list | hooks/gate.py | `subagent_type`s the stamp gates (default `[review, smoke]`). |
55
+ | `gate.preflight.enabled` | bool | hooks/gate.py, review | Phase gate: review dispatches need a fresh preflight stamp. See §Preflight. |
56
+ | `gate.preflight.agents[]` | list | hooks/gate.py | `subagent_type`s the stamp gates (default `[review]`). |
57
57
  | `gate.preflight.max_age_minutes` | number | hooks/gate.py | Stamp freshness window (default 30). |
58
58
 
59
59
  ## Prose sections
@@ -161,8 +161,8 @@ the frozen contract as the only cross-surface channel**. So specialization means
161
161
  Coarse first, specialize on evidence: start with one `frontend` / `backend` surface each; split only a
162
162
  surface that's proven slow and cleanly separable. The evidence lives in
163
163
  the **main checkout's** `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
164
- (`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
165
- and `/smoke`.
164
+ (`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`
165
+ and `/fix`.
166
166
  **`surfaces` keys are surface keys, nothing else** — run-level facts go in their own top-level
167
167
  fields. Anything put inside `surfaces` is read
168
168
  as a surface: the dashboard renders it as a row in the per-surface table and scores a non-`ok`
@@ -188,7 +188,7 @@ to log it. For what's EXPENSIVE, use Claude Code's own accounting:
188
188
  per-subagent attribution needs traces (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, beta).
189
189
 
190
190
  **Lead context discipline — the silent bill.** The lead session's conversation history is re-sent as
191
- input on EVERY turn; a session that spans spec→build→smoke→review→fix without clearing re-pays the
191
+ input on EVERY turn; a session that spans spec→build→review→fix without clearing re-pays the
192
192
  accumulated spec walk-through, handoffs, and reports on each turn. The pipeline is built so this is
193
193
  never necessary: every phase handoff (spec, contract, diff, staged reports) lives on disk, so `/clear`
194
194
  at each phase boundary is always safe — each command's closing line recommends it. Corollaries the
@@ -219,7 +219,7 @@ storing a bare `pnpm test` as the thing agents execute; `/update-pipeline` tops
219
219
 
220
220
  ## Preflight — the deterministic phase gate
221
221
 
222
- `/review` and `/smoke` start by running `pipeline/scripts/preflight.sh` — a plain shell script (no
222
+ `/review` starts by running `pipeline/scripts/preflight.sh` — a plain shell script (no
223
223
  agent) that executes the profile's mechanical checks in order (typecheck → lint → tests, quiet
224
224
  variants) with all output redirected to `specs/reports/<id>.preflight.txt`:
225
225
 
@@ -231,7 +231,7 @@ variants) with all output redirected to `specs/reports/<id>.preflight.txt`:
231
231
 
232
232
  `hooks/gate.py` enforces the stamp as a **phase gate** (the `preflight` block of `gate-config.json`,
233
233
  generated from `gate.preflight`): a Task dispatch of a listed `subagent_type` (default
234
- `review`/`smoke`) with a missing/stale stamp — older than `max_age_minutes`, or HEAD moved — gets an
234
+ `review`) with a missing/stale stamp — older than `max_age_minutes`, or HEAD moved — gets an
235
235
  "ask", so a lead can't accidentally skip the gate but a human can consciously override it. The gate
236
236
  hook fires for **every** agent in the session, including subagents spawned by the Workflow runtime
237
237
  (they run in `acceptEdits` whatever the session mode — Write/Edit auto-approved — but Bash and Task
@@ -409,7 +409,7 @@ card created in the target column if missing.
409
409
  | `/spec` opens (draft) | `spec` |
410
410
  | `/spec` freezes (`status: frozen`) | `ready` |
411
411
  | `/build` | `building` |
412
- | `/review` (owns the move — `/smoke` never moves the card) | `review` |
412
+ | `/review` | `review` |
413
413
  | `/fix` | `fix` |
414
414
  | `/ship` starts | `ship` |
415
415
  | PR opened (`status: shipped`) | `shipped` (+ `PR #<num>` on the card) |
@@ -437,7 +437,7 @@ pre-telemetry installs) ask ONE question, once per machine, default **No**, and
437
437
  `|| true`, so a **missing** script is equally silent: `/doctor` check 1 verifies `pipeline/scripts/`
438
438
  is fully populated.
439
439
 
440
- **Which commands ping** — the seven that make up the feature funnel, and only those. The point is to
440
+ **Which commands ping** — the six that make up the feature funnel, and only those. The point is to
441
441
  see where features stall, so every stage of `idea → PR` reports and nothing else does:
442
442
 
443
443
  | phase | fired when | `seconds` | `results` |
@@ -445,7 +445,6 @@ see where features stall, so every stage of `idea → PR` reports and nothing el
445
445
  | `brainstorm` | the return is staged | `0` | — |
446
446
  | `spec` | a freeze lands (Mode A only) | `0` | `frozen` |
447
447
  | `build` | after the batch metrics line | wall-clock | `ok,ok` / `error` |
448
- | `smoke` | after the verdict | wall-clock | `PASS` / `FAIL:<n>` |
449
448
  | `review` | after the merged verdict | wall-clock | `<verdict>:<count>` |
450
449
  | `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
451
450
  | `ship` | the release agent succeeded | `0` | `pr` / `compare` |
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # loop.sh — autonomous /build → /review → /fix → /review … loop for ONE feature.
4
+ #
5
+ # loop.sh <feature-id> [--max=N] [--no-build] [--rebuild]
6
+ #
7
+ # THE POINT: every phase runs as a SEPARATE `claude -p` child with its own fresh
8
+ # context. The session that typed /loop never sees the diff, the N review reports
9
+ # or the N contracts — it reads only this script's one-line-per-phase stdout and,
10
+ # at the end, the verdict JSON. Running the loop inside the calling session would
11
+ # accumulate all of it in a history that is re-sent at input price on every turn,
12
+ # which is the exact cost the pipeline's /clear discipline exists to avoid.
13
+ #
14
+ # Contract with the pipeline: /review writes specs/reports/<id>.verdict.json on
15
+ # every run. That file — `blocking` and `fingerprint` — is the ONLY channel
16
+ # between cohorte and this driver. No prose is parsed.
17
+ #
18
+ # Exit codes (three distinct diagnostics, do not collapse them):
19
+ # 0 clean — a review returned blocking == 0
20
+ # 1 ceiling — --max passes used, still blocking (the fix was progressing;
21
+ # re-run with a higher --max)
22
+ # 2 no usable verdict — /review produced nothing, or aborted on a red
23
+ # preflight (typecheck/lint/tests broken; the message says which)
24
+ # 3 non-convergent — two consecutive reviews returned the SAME blocking
25
+ # fingerprint: the fix is treading water, a higher --max will not help
26
+ # 64 usage — bad flag, bad id, missing spec, no `claude` on PATH
27
+ #
28
+ # No /fix runs on the last pass: fixing without a review behind it ships
29
+ # unaudited code. Each fix pass is committed — that commit is the only way back
30
+ # after N autonomous passes.
31
+
32
+ set -uo pipefail
33
+
34
+ usage() {
35
+ cat >&2 <<'EOF'
36
+ usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild]
37
+
38
+ --max=N stop after N review passes (default 5)
39
+ --no-build never build — re-run the /review ⇄ /fix loop on a feature that
40
+ is already built (the common case; the build stamp is ignored)
41
+ --rebuild force a /build even if the stamp says it was already built
42
+
43
+ env CLAUDE_FLAGS flags for every child session
44
+ (default: --permission-mode acceptEdits)
45
+ EOF
46
+ exit 64
47
+ }
48
+
49
+ id=""
50
+ max=5
51
+ build_mode="auto" # auto | never | force
52
+
53
+ for arg in "$@"; do
54
+ case "$arg" in
55
+ --max=*)
56
+ max="${arg#--max=}"
57
+ case "$max" in
58
+ ''|*[!0-9]*) echo "loop: --max must be a positive integer (got '${arg#--max=}')" >&2; exit 64 ;;
59
+ esac
60
+ [ "$max" -ge 1 ] || { echo "loop: --max must be >= 1" >&2; exit 64; }
61
+ ;;
62
+ --no-build) build_mode="never" ;;
63
+ --rebuild) build_mode="force" ;;
64
+ -h|--help) usage ;;
65
+ -*) echo "loop: unknown flag: $arg" >&2; usage ;;
66
+ *)
67
+ [ -z "$id" ] || { echo "loop: unexpected argument: $arg" >&2; usage; }
68
+ id="$arg"
69
+ ;;
70
+ esac
71
+ done
72
+
73
+ [ -n "$id" ] || usage
74
+ # --no-build --rebuild together is a contradiction, not a precedence puzzle.
75
+ case " $* " in
76
+ *" --no-build "*) case " $* " in *" --rebuild "*)
77
+ echo "loop: --no-build and --rebuild are mutually exclusive" >&2; exit 64 ;; esac ;;
78
+ esac
79
+
80
+ command -v claude >/dev/null 2>&1 || {
81
+ echo "loop: no 'claude' on PATH — the loop drives child claude -p sessions" >&2
82
+ exit 64
83
+ }
84
+
85
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
86
+ echo "loop: not inside a git checkout" >&2; exit 64; }
87
+ cd "$root" || exit 64
88
+
89
+ spec="specs/$id.md"
90
+ [ -f "$spec" ] || {
91
+ echo "loop: no spec at $spec — run /spec $id first" >&2; exit 64; }
92
+
93
+ reports="specs/reports"
94
+ mkdir -p "$reports"
95
+ verdict="$reports/$id.verdict.json"
96
+ stamp="$reports/$id.built"
97
+ log="$reports/$id.loop.log"
98
+
99
+ : "${CLAUDE_FLAGS:=--permission-mode acceptEdits}"
100
+
101
+ : >"$log"
102
+ {
103
+ printf '# loop %s — max=%s build=%s\n' "$id" "$max" "$build_mode"
104
+ printf '# flags: %s\n' "$CLAUDE_FLAGS"
105
+ } >>"$log"
106
+
107
+ # --- one phase = one throwaway child session ---------------------------------
108
+ # ALL child output is redirected into $log and never surfaces here: if the
109
+ # parent re-imports the children's transcripts, the whole point is lost.
110
+ # $CLAUDE_FLAGS is intentionally unquoted — it is a flag list, not one word.
111
+ run_phase() {
112
+ cmd="$1"
113
+ printf '▶ /%-6s %-24s ' "$cmd" "$id"
114
+ printf '\n\n===== /%s %s =====\n' "$cmd" "$id" >>"$log"
115
+ # shellcheck disable=SC2086
116
+ if claude -p "/$cmd $id" $CLAUDE_FLAGS >>"$log" 2>&1; then
117
+ echo "ok"
118
+ return 0
119
+ fi
120
+ echo "fail"
121
+ return 1
122
+ }
123
+
124
+ # Scalar reads on a flat JSON object — no jq dependency (the pipeline ships no
125
+ # runtime deps). Only `blocking` and `fingerprint` are ever read; both are
126
+ # top-level scalars by construction of the verdict contract.
127
+ json_num() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$1" | head -n1; }
128
+ json_str() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n1; }
129
+
130
+ finish() { echo "$2"; exit "$1"; }
131
+
132
+ # --- build -------------------------------------------------------------------
133
+ # The stamp is the driver's own bookkeeping — /build knows nothing about it.
134
+ case "$build_mode" in
135
+ force) do_build=1 ;;
136
+ never) do_build=0 ;;
137
+ auto) [ -f "$stamp" ] && do_build=0 || do_build=1 ;;
138
+ esac
139
+
140
+ if [ "$do_build" -eq 1 ]; then
141
+ run_phase build || finish 2 "✗ /build failed — see $log"
142
+ date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
143
+ fi
144
+
145
+ # --- review ⇄ fix ------------------------------------------------------------
146
+ prev_fp=""
147
+ pass=1
148
+ while [ "$pass" -le "$max" ]; do
149
+ # Delete first: a stale verdict from the previous pass read as this pass's
150
+ # answer would end the loop on someone else's numbers.
151
+ rm -f "$verdict"
152
+ run_phase review || true # exit status of the child is not the verdict
153
+
154
+ [ -f "$verdict" ] || finish 2 \
155
+ "✗ /review wrote no verdict (pass $pass) — see $log"
156
+
157
+ if grep -q '"aborted"' "$verdict"; then
158
+ finish 2 "✗ /review aborted on a red preflight — typecheck/lint/tests are broken, see $reports/$id.preflight.txt"
159
+ fi
160
+
161
+ blocking="$(json_num "$verdict" blocking)"
162
+ [ -n "$blocking" ] || finish 2 \
163
+ "✗ verdict has no usable 'blocking' count (pass $pass) — see $verdict"
164
+
165
+ [ "$blocking" -eq 0 ] && finish 0 \
166
+ "✓ clean after $pass review pass(es) — no blocking findings"
167
+
168
+ fp="$(json_str "$verdict" fingerprint)"
169
+ if [ -n "$fp" ] && [ "$fp" = "$prev_fp" ]; then
170
+ finish 3 "✗ non-convergent — the same $blocking blocking finding(s) survived a fix pass; see $verdict"
171
+ fi
172
+ prev_fp="$fp"
173
+
174
+ # Last pass: report and stop. A /fix here would leave unreviewed code behind.
175
+ [ "$pass" -eq "$max" ] && finish 1 \
176
+ "✗ ceiling — $blocking blocking finding(s) after $max pass(es); re-run with a higher --max"
177
+
178
+ run_phase fix || true
179
+
180
+ # Non-fatal by design: nothing to commit is a legitimate outcome (an agent
181
+ # that decided a finding needed no code change). The commit itself is the
182
+ # rollback point for the pass that just ran.
183
+ git add -A >>"$log" 2>&1
184
+ git commit -m "loop($id): fix pass $pass" >>"$log" 2>&1 || true
185
+
186
+ pass=$((pass + 1))
187
+ done
188
+
189
+ finish 1 "✗ ceiling — $max pass(es) exhausted"
@@ -60,21 +60,30 @@ 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', 'smoke', 'review', 'fix', 'ship',
63
+ for (const n of ['brainstorm', 'spec', 'build', 'review', 'fix', 'ship',
64
64
  'audit', 'refactor', 'align-ds', 'doctor', 'init-pipeline', 'update-pipeline']) names.add(n);
65
65
  }
66
66
  // Retired commands. The list above is read from the shipped core, so a command that is
67
67
  // removed stops being recognised — and every run of it already in the transcripts silently
68
68
  // reclassifies as (chat), rewriting history and inflating the catch-all bucket. Keep the
69
69
  // names here so past runs stay attributed to what actually ran.
70
- for (const n of ['cycle']) names.add(n);
70
+ for (const n of ['cycle', 'smoke']) names.add(n);
71
71
  return names;
72
72
  }
73
73
  const COMMANDS = knownCommands();
74
74
 
75
+ // 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
77
+ // command — a bug report, a design discussion, a pasted transcript. Counting those as runs
78
+ // inflates a command's run count and cost with conversation that never invoked it, which is
79
+ // exactly what happened in cohorte's own repo while this pipeline was being discussed.
80
+ // Only inline mentions are length-gated; an explicit <command-name> is always an invocation.
81
+ const MENTION_MAX_CHARS = 120;
82
+
75
83
  function commandIn(text) {
76
84
  const explicit = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>/.exec(text);
77
85
  if (explicit) return explicit[1].replace(/^\//, '');
86
+ if (text.trim().length > MENTION_MAX_CHARS) return null;
78
87
  // Last mention wins: "finish /build then /review" ends on the one being asked for.
79
88
  let found = null;
80
89
  for (const m of text.matchAll(/(?:^|\s)\/([a-z][a-z0-9-]{2,})\b/g)) {
@@ -1,6 +1,6 @@
1
1
  #!/bin/sh
2
2
  #
3
- # preflight.sh — deterministic phase gate for /review and /smoke.
3
+ # preflight.sh — deterministic phase gate for /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
@@ -15,7 +15,7 @@
15
15
  # The caller must stop there — no agents.
16
16
  # - All green: writes `<project>/.claude/preflight.ok` ("<epoch> <HEAD sha>") — the
17
17
  # stamp `hooks/gate.py` checks (gate-config.json `preflight` block) before letting
18
- # review/smoke agents dispatch.
18
+ # review agents dispatch.
19
19
 
20
20
  set -u
21
21
 
@@ -2,7 +2,7 @@
2
2
  # telemetry-send.sh — fire-and-forget anonymous usage ping (SCHEMA.md §Telemetry).
3
3
  #
4
4
  # telemetry-send.sh <phase> <feature_id> <seconds> [results]
5
- # phase brainstorm|spec|build|smoke|review|fix|ship — the feature funnel, and only it.
5
+ # phase brainstorm|spec|build|review|fix|ship — the feature funnel, and only it.
6
6
  # Setup/maintenance commands never ping (SCHEMA.md §Telemetry).
7
7
  # feature the feature id — NEVER sent raw; SHA-256-hashed to 12 hex chars
8
8
  # seconds batch wall-clock
@@ -39,7 +39,10 @@ phase="${1:-}"; feature="${2:-}"; seconds="${3:-0}"; results="${4:-}"
39
39
  # Allowlist the phase here — the collector accepts any string, so a typo in a command
40
40
  # file would silently pollute the dataset with a phantom phase nobody notices.
41
41
  case "$phase" in
42
- brainstorm|spec|build|smoke|review|fix|ship) ;;
42
+ brainstorm|spec|build|review|fix|ship) ;;
43
+ # `smoke` is a RETIRED phase (removed in 1.5.0) — still accepted so a stale install
44
+ # pinging it lands in its own bucket instead of being silently dropped.
45
+ smoke) ;;
43
46
  *) exit 0 ;;
44
47
  esac
45
48
 
@@ -20,6 +20,7 @@ const require = createRequire(import.meta.url);
20
20
  const root = fileURLToPath(new URL("..", import.meta.url));
21
21
  const { parse, parseProfileBlock } = require(join(root, "dashboard/server/yaml.js"));
22
22
  const { metrics } = require(join(root, "dashboard/server/metrics.js"));
23
+ const { usage } = require(join(root, "dashboard/server/usage.js"));
23
24
  const { state, scanSpecs } = require(join(root, "dashboard/server/doctor.js"));
24
25
  const { kanban } = require(join(root, "dashboard/server/kanban.js"));
25
26
  const fleet = require(join(root, "dashboard/server/fleet.js"));
@@ -106,6 +107,25 @@ console.log("metrics.js — the funnel aggregate");
106
107
  check("no metrics file ⇒ present:false", metrics({ projectRoot: scratch() }).present === false);
107
108
  }
108
109
 
110
+ // ── usage.js ─────────────────────────────────────────────────────────────────
111
+ // Wraps the ESM metrics collector for the CJS server. The failure that matters is
112
+ // not a crash: a project with no transcripts must say so, because rendering zeros
113
+ // reads as "this pipeline costs nothing" rather than "nothing was measured".
114
+ console.log("usage.js — the collector bridge");
115
+ {
116
+ const empty = usage({ projectRoot: scratch() });
117
+ check("a project with no transcripts reports present:false", empty.present === false);
118
+ check("…and says why rather than returning silent zeros",
119
+ typeof empty.error === "string" && empty.error.length > 0, JSON.stringify(empty));
120
+
121
+ // Same project twice: the second call must come from cache, or the panel's polling
122
+ // would re-parse tens of MB of transcripts on every refresh.
123
+ const d = scratch();
124
+ const t0 = Date.now(); usage({ projectRoot: d });
125
+ const t1 = Date.now(); usage({ projectRoot: d }); const cached = Date.now() - t1;
126
+ check("a repeated read is served from cache", cached <= Math.max(50, (t1 - t0)), `${cached}ms`);
127
+ }
128
+
109
129
  // ── doctor.js ────────────────────────────────────────────────────────────────
110
130
  console.log("doctor.js — the /doctor port");
111
131
  {
@@ -135,7 +155,7 @@ console.log("doctor.js — the /doctor port");
135
155
  const d = scratch();
136
156
  const gate = {
137
157
  deny: ["x"], ask: ["y"], ask_on_default_branch: ["git push"], default_branch: "main",
138
- preflight: { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 },
158
+ preflight: { enabled: true, agents: ["review"], max_age_minutes: 30 },
139
159
  };
140
160
  writeFileSync(join(d, "PIPELINE.md"), [
141
161
  "```yaml pipeline-profile",
@@ -153,7 +173,7 @@ console.log("doctor.js — the /doctor port");
153
173
  ' ask_on_default_branch: ["git push"]',
154
174
  " preflight:",
155
175
  " enabled: true",
156
- " agents: [review, smoke]",
176
+ " agents: [review]",
157
177
  " max_age_minutes: 30",
158
178
  "```",
159
179
  ].join("\n"));
@@ -181,7 +181,7 @@ console.log("gate.py — config robustness");
181
181
  // ── the preflight phase gate (Task dispatches) ───────────────────────────────
182
182
  console.log("gate.py — preflight phase gate");
183
183
  {
184
- const pf = { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 };
184
+ const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
185
185
  const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
186
186
  const head = gitRepo(d, "main");
187
187
  const stamp = (epoch, sha) =>
@@ -195,7 +195,6 @@ console.log("gate.py — preflight phase gate");
195
195
 
196
196
  stamp(now(), head);
197
197
  check("fresh stamp at the current HEAD ⇒ passes", run(task("review"), at).decision === null);
198
- check("smoke is gated too", run(task("smoke"), at).decision === null);
199
198
 
200
199
  stamp(now() - 60 * 60, head);
201
200
  check("stamp older than max_age_minutes ⇒ ask", run(task("review"), at).decision === "ask");