cohorte 1.3.3 → 1.4.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 (43) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +4 -7
  3. package/bin/cli.js +49 -4
  4. package/core/agents/implementer.template.md +10 -5
  5. package/core/agents/profile-reader.md +22 -0
  6. package/core/commands/doctor.md +8 -4
  7. package/core/hooks/gate.py +21 -6
  8. package/core/templates/agent-handoff.md +7 -2
  9. package/core/templates/review-feedback.md +7 -4
  10. package/core/templates/spec.template.md +5 -2
  11. package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
  12. package/core/workflows/audit.js +88 -7
  13. package/core/workflows/refactor.js +85 -9
  14. package/core/workflows/review.js +132 -11
  15. package/dashboard/README.md +22 -5
  16. package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
  17. package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
  18. package/dashboard/dist/index.html +2 -2
  19. package/dashboard/server/doctor.js +68 -20
  20. package/dashboard/server/fleet.js +19 -5
  21. package/dashboard/server/index.js +79 -7
  22. package/dashboard/server/metrics.js +16 -4
  23. package/dashboard/server/versions.js +28 -6
  24. package/dashboard/server/yaml.js +4 -1
  25. package/install.ps1 +4 -0
  26. package/install.sh +19 -1
  27. package/package.json +5 -2
  28. package/profile/SCHEMA.md +23 -26
  29. package/scripts/kanban-move.sh +34 -20
  30. package/scripts/metrics/collect.mjs +495 -0
  31. package/scripts/metrics/prices.json +39 -0
  32. package/scripts/new-feature.sh.template +3 -1
  33. package/scripts/preflight.sh +16 -3
  34. package/scripts/remove-feature.sh.template +2 -1
  35. package/scripts/telemetry-send.sh +15 -1
  36. package/scripts/test-dashboard.mjs +362 -0
  37. package/scripts/test-gate.mjs +273 -0
  38. package/scripts/test-metrics.mjs +135 -0
  39. package/scripts/test-workflows.mjs +321 -0
  40. package/scripts/validate-core.mjs +51 -1
  41. package/core/commands/cycle.md +0 -54
  42. package/core/workflows/cycle.js +0 -407
  43. package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,139 @@
3
3
  Entries are shown by `/update-pipeline` ("What's new") after a core refresh. Keep them short,
4
4
  user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` section per release.
5
5
 
6
+ ## 1.4.0 — 2026-08-01
7
+
8
+ > **Re-run `npx cohorte@latest update --global` (or `update`)** — the workflow fixes only apply
9
+ > once the installed core is refreshed. Both the workflow scripts and the `profile-reader` agent
10
+ > are replaced by the update.
11
+
12
+ - **BREAKING — `/cycle` and `cycle.js` are removed.** The full-cycle workflow is gone: the command
13
+ file, the script, its tests and its documentation. The conversational path it wrapped is
14
+ unchanged and remains the way to run a feature — `/build` → `/smoke` → `/review` → `/fix` →
15
+ `/ship`, with `/clear` safe between each. `review.js`, `audit.js` and `refactor.js` are
16
+ untouched. Nothing else in the pipeline depended on it; a `/cycle` in an old habit will simply
17
+ report an unknown command. Metrics files and dashboards that already carry `phase: "cycle"`
18
+ lines keep rendering them.
19
+ - **A workflow could dispatch zero agents and still report a verdict.** Phase 0's `profile-reader`
20
+ (haiku) intermittently returned the profile as a JSON *string* nested under a wrapper field
21
+ (`{"output": "{\"surfaces\": …}"}`) instead of at the top level. The schema was
22
+ `{type: 'object', additionalProperties: true}` — no declared properties, no required keys — so
23
+ the wrapper validated cleanly and every field then read as `undefined`: `surfaces` fell back to
24
+ `[]`, `parallel([])` dispatched **nothing**, and because every later guard compares against
25
+ `surfaces`, an empty list made them all vacuously pass. The run finished with a verdict, no code
26
+ written, and no complaint — indistinguishable from a clean run with an empty diff. Fixed in three
27
+ places: `profile-reader.md` now states that the profile's keys go at the top level of the
28
+ structured-output tool (with the wrong shapes shown), the schema declares what it expects, and a
29
+ profile with no surfaces **aborts loudly** instead of proceeding. All three workflows.
30
+ - **`args` given as a JSON string became the feature id.** A caller that JSON-encoded its arguments
31
+ got that whole blob used as the id — which is how a report was written to
32
+ `specs/reports/{"feature": "x"}.md` — and the other options (`maxRounds`, `smoke`) silently read
33
+ as `undefined` on the same run, so a run could skip smoke without saying so. `args` is now parsed
34
+ back into an object (a bare slug is still valid shorthand), and a feature id that is not a slug
35
+ throws with an actionable message **before** anything touches the filesystem — so no junk file
36
+ can be written, and a path-shaped id is rejected.
37
+ - **`/doctor` warned about a file cohorte itself had written.** `/audit` writes
38
+ `specs/refactor-backlog.md` by design; the spec scanner globbed `specs/*.md` and flagged it for
39
+ having no valid front-matter `status`. It fired in every project that had ever run `/audit`. Both
40
+ the conversational `/doctor` and the dashboard port now exclude it.
41
+ - **New: `cohorte metrics` — real cost and runtime per command.** Reconstructs tokens, USD,
42
+ wall/active time and subagent counts from Claude Code's own transcripts, so it needs nothing
43
+ enabled and works retroactively on runs that already happened. It is worktree-aware (a feature
44
+ built across worktrees adds up instead of being dropped), attributes subagent spend back to the
45
+ command that spawned it, and de-duplicates the repeated `usage` blocks a single API response
46
+ writes across several transcript lines — summing those naively inflates tokens ~1.8×.
47
+ `--json`, `--runs`, `--days=N`, `--since=ISO`. Prices live in `scripts/metrics/prices.json`.
48
+
49
+ ## 1.3.4 — 2026-07-31
50
+
51
+ > **Re-run `npx cohorte@latest update --global` (or `update`)** — the workflow and script
52
+ > fixes only apply once the installed core is refreshed.
53
+
54
+ - **The dashboard server now has tests.** `dashboard/server/*.js` is shipped runtime code — a
55
+ hand-rolled YAML parser every `/doctor` check derives from, the metrics aggregator, the JS port
56
+ of `/doctor`, the board parser, the fleet registry, and the HTTP guards — with no coverage at
57
+ all. `scripts/test-dashboard.mjs` (83 assertions, in CI) pins each module, including every
58
+ `/doctor` check both green and deliberately broken.
59
+ - **The gate hook now has tests.** `hooks/gate.py` is the one component that can block a command,
60
+ and CI only ever checked that it *parsed* — every one of its shipped regressions reached users
61
+ first. `scripts/test-gate.mjs` drives its real stdin→stdout contract (42 assertions: deny/ask
62
+ tiers, chained-command splitting, branch-conditional gating at the payload's cwd, the
63
+ `bypassPermissions` ask⇒deny escalation, config robustness, the preflight phase gate, worktree
64
+ HEAD matching) and runs in CI. No new defect was found in the gate itself — the behaviour is
65
+ now pinned.
66
+ - **A crashed reviewer scored as a clean surface.** `agent()` returns `null` when a subagent dies,
67
+ and a dead reviewer returns zero findings — byte-identical to a surface with nothing wrong. Both
68
+ `review.js` and `cycle.js` read that as `SHIP`: the review workflow answered "`/ship`" when
69
+ *every* reviewer had crashed, and the cycle workflow exited **SHIP-READY**, ticked the DoD and
70
+ stamped the freshness gate over code nobody had read. Unreviewed surfaces are now named in
71
+ `unreviewedSurfaces` + `questions`, can never score `SHIP`, and the cycle re-reviews instead of
72
+ dispatching an empty fix round. `scripts/test-workflows.mjs` (new, run in CI) pins this.
73
+ - **Swept the whole "dead agent read as success" family across all four workflows** — the same
74
+ root cause as the two above, found at eight more call sites by auditing every `agent()` result
75
+ in `cycle.js` · `review.js` · `audit.js` · `refactor.js`. The worst: a dead **diff-staging**
76
+ agent in `review.js` returned `verdict: SHIP` ("no diff — nothing to review") for a feature
77
+ nobody had looked at; and a dead **close/staging** agent let both `/cycle` and the review
78
+ workflow report `SHIP-READY` + a report path + "ship is a straight shot" when the report, the
79
+ DoD ticks, the freshness stamp and the metrics had never been written (and `/ship`'s freshness
80
+ gate skips silently when those fields are absent, so the human would have shipped on it). Also:
81
+ a dead auditor made a domain look **clean** instead of unaudited; a dead backlog writer/reader
82
+ and a dead item-ticker were each reported as success. Every one of these now distinguishes
83
+ "died" from "succeeded with nothing to say", and `scripts/test-workflows.mjs` pins all of them.
84
+ - **A dead contract agent was reported as a successful re-authoring.** Same failure shape as the
85
+ crashed reviewer: when the lead-equivalent agent that re-authors spec §5 + the contract file
86
+ died, `/cycle` still pushed a `contractChanges` entry and handed **every** consuming surface a
87
+ CRITICAL "the contract was RE-AUTHORED — re-read it and realign" item, pointing at a file
88
+ nobody had touched. It now reports the contract as UNCHANGED and ripples nothing.
89
+ - **A red preflight nobody owns burned every remaining round.** When no surface path appeared in
90
+ the failure tail and no implementer had survived the build, the fix round dispatched *zero*
91
+ agents, the next round found the same red gates, and the loop spun to the cap doing literally
92
+ nothing before reporting a stale verdict. It now stops immediately with the failure tail.
93
+ - **Findings belonging to no surface were dropped silently.** A finding whose file sits outside
94
+ every surface tree — reachable when the diff-staging agent names a key the profile lacks —
95
+ stayed in the open set (so the loop could never exit clean) while nobody was ever dispatched to
96
+ fix it. They are now named, with their `file:line`, in `questions`.
97
+ - **`/cycle` build telemetry hid dead implementers**: results were mapped over the *survivors*, so
98
+ two of three surfaces reported `ok,ok` and the dead one vanished from the funnel entirely.
99
+ - **The review workflow sent HIGH findings straight to `/ship`.** A `SHIP` verdict can legitimately
100
+ carry HIGH/MEDIUM findings (only CRITICAL and security force a fix), but the conversational
101
+ `/review` routes any surviving HIGH to `/fix` — the workflow said `/ship`. It now only recommends
102
+ shipping when nothing above LOW survived, and stamps the freshness gate on that same condition.
103
+ - **Smoke is now opt-in in the cycle workflow** — `/cycle <id> smoke` (or `args.smoke: true`).
104
+ Booting the app every round is expensive and a library project has nothing to smoke. Without it
105
+ the run reports `smoke: "SKIPPED"`, leaves the runtime-flow DoD boxes unticked, and says so.
106
+ - **The dashboard reset could move the shared global core.** Nothing stopped a project path of
107
+ `~` — reset would then rename `~/.claude` into a backup dir and break every repo on the machine,
108
+ while the UI promised the global core is never touched. It now refuses that path outright.
109
+ - **The cycle workflow polluted the metrics with phantom surfaces.** It wrote `rounds` / `verdict` /
110
+ `smoke` inside the metrics line's `surfaces` map, so the dashboard rendered them as three surface
111
+ rows and scored `rounds: "1"` as a failing surface. Run-level facts now sit outside `surfaces`,
112
+ and the dashboard knows the `cycle` phase.
113
+ - **Telemetry from bundled installs reported no core version.** `telemetry-send.sh` read `VERSION`
114
+ only from the *global* core; it now resolves the core that ships it. Payloads are also hardened —
115
+ a quote or newline in the results string used to produce JSON the collector dropped.
116
+ - **Every kanban card move added a blank line.** Ten moves of one card padded a board with fifteen
117
+ of them, and every phase command moves cards. Runs of blank lines are now collapsed; a board is
118
+ byte-stable across moves. Sub-notes of a duplicate card are no longer duplicated either.
119
+ - **Python bytecode could reach the published package and users' `.claude`.** `.npmignore` is inert
120
+ under an explicit `files` allowlist, so its `__pycache__/` rule never fired, and all three
121
+ installers copied the directory verbatim. Excluded at both ends, asserted in CI.
122
+ - **Every non-design surface agent rendered with a blank first TDD step.** `<SURFACE_TDD_STEP1>`
123
+ sat as numbered item 1 of the TDD list but is filled only for `uses_design` surfaces, so every
124
+ other agent got an empty "1." above its real first step. It is now a lead-in paragraph.
125
+ - Doc/template corrections found by reading the whole core against the code: the spec template's
126
+ `## 6+. Surface tasks` could collide with the `§8`/`§9` sections the pipeline references by
127
+ number; `/init-pipeline` step 04 forgot `smoke.md` in its "leave the fixed agents as-is" list;
128
+ the getting-started page placed `implementer.template.md` in `agents/` (it ships in `pipeline/`);
129
+ the dashboard docs never documented the CSRF/DNS-rebinding guard; `dashboard/README.md` was
130
+ missing `metrics.js`, `/api/metrics` and `/audit`; the two reference-only templates
131
+ (`agent-handoff.md`, `review-feedback.md`) are unreferenced copies of shapes that live in the
132
+ agents — `review-feedback.md` had drifted and is re-synced, and both now say so.
133
+ - Smaller: the dashboard no longer stalls for 13 s per project when npm is unreachable (failed
134
+ lookups are cached and de-duplicated); `--port=` rejects a non-numeric value instead of listening
135
+ on a random port; a missing hashed asset 404s instead of being served `index.html`; a project
136
+ card is now keyboard-activatable; long headless logs are trimmed instead of growing unbounded;
137
+ `install.sh --help` exists; the preflight stamp is written once per distinct directory.
138
+
6
139
  ## 1.3.3 — 2026-07-30
7
140
 
8
141
  > **Re-run `npx cohorte@latest update --global` (or `update`)** — the gate fixes only apply once
package/README.md CHANGED
@@ -217,7 +217,6 @@ it in `.claude/pipeline/VERSION` and bundled repos in their committed `pipeline.
217
217
  | `/brainstorm` | Interactive persona panel that pressure-tests a feature idea. |
218
218
  | `/spec` | Freeze the feature spec + contract into `specs/<id>.md` (UI features also get a standalone design brief at `specs/design/<id>.md`). Also applies review returns. |
219
219
  | `/build <id>` | Lead authors the contract, then dispatches one implementer per surface in parallel. |
220
- | `/cycle <id>` | Launch the full dev-cycle **workflow** on a frozen spec: build → smoke ∥ review → fix until zero findings; deferred questions in the output. Needs workflows enabled (falls back to the conversational path). |
221
220
  | `/smoke <id>` | Run the feature for real: infra up, contract endpoints, UI flows, design conformance. |
222
221
  | `/review <id>` | Read-only review agents (one per touched surface, parallel) audit the diff vs the spec. |
223
222
  | `/fix <id>` | Apply a review/smoke report: remediation into the spec, re-dispatch only the surfaces with findings. |
@@ -278,13 +277,12 @@ Rules that make it safe:
278
277
 
279
278
  ### Workflows — deterministic multi-agent runs (opt-in)
280
279
 
281
- Three phases also ship as **workflow scripts** for the Claude Code Workflow runtime — the same
280
+ Four phases also ship as **workflow scripts** for the Claude Code Workflow runtime — the same
282
281
  fan-out the commands orchestrate, but driven by a deterministic script instead of the lead reasoning
283
282
  it out turn by turn:
284
283
 
285
284
  | Script | What it runs |
286
285
  | ----------------------- | ----------------------------------------------------------------------------------------- |
287
- | `workflows/cycle.js` | **The full dev cycle on a frozen spec**: contract → parallel build → smoke ∥ review(+cross-check) → fix, looping until zero findings + PASS (contract changes handled in-loop by a lead-equivalent agent). Human decisions come back in a `questions` array at the END — empty when `/brainstorm`+`/spec` did their job. Exits SHIP-ready (DoD ticked, freshness stamped) so `/ship` is a straight shot. |
288
286
  | `workflows/review.js` | Preflight gate (aborts while red — zero agents), one reviewer per touched surface, adversarial cross-check of CRITICAL/security findings, merged verdict only. |
289
287
  | `workflows/audit.js` | One auditor per domain (every surface + shared) concurrently, prioritized `specs/refactor-backlog.md`. |
290
288
  | `workflows/refactor.js` | Big domains only: `shared` first and alone, then parallel surface implementers, per-domain verify + one retry. |
@@ -297,10 +295,9 @@ The essentials:
297
295
  - **Prerequisite: Claude Code ≥ 2.1.154** with workflows enabled. `/doctor` (check 8) tells you
298
296
  which path your session will take and why.
299
297
  - **No input mid-run — questions at the edges.** A workflow runs to completion without asking
300
- anything. `cycle.js` moves the decisions to its boundaries: a readiness gate refuses a non-frozen
301
- spec up front, and whatever would have been a mid-run question lands in the result's `questions`
302
- array at the end (spec gaps, hit round-cap) — answer them, rerun the cycle. The destructive-
303
- command gate still fires inside workflow subagents; in unattended runs its confirms become denies.
298
+ anything: whatever would have been a mid-run question lands in the result at the end. The
299
+ destructive-command gate still fires inside workflow subagents; in unattended runs its confirms
300
+ become denies.
304
301
  - Phase 0 of every script is the `profile-reader` agent (haiku) — it reads `PIPELINE.md` and hands
305
302
  the script the profile as JSON, since workflow scripts have no filesystem access. Mechanical
306
303
  phases run on haiku; judgment phases use the same pinned agents as the commands.
package/bin/cli.js CHANGED
@@ -35,6 +35,7 @@ Usage:
35
35
  cohorte install [target] [--global]
36
36
  cohorte update [target] [--global]
37
37
  cohorte dashboard [target] [--port=N] [--host=ADDR] [--open]
38
+ cohorte metrics [target] [--days=N] [--since=ISO] [--runs] [--json]
38
39
  cohorte version
39
40
 
40
41
  Commands:
@@ -49,6 +50,11 @@ Commands:
49
50
  by default (loopback only — its actions execute code). --host=ADDR
50
51
  to expose (e.g. --host=0.0.0.0, prints a security warning). --open
51
52
  to launch the browser.
53
+ metrics Cost and runtime per command, reconstructed from Claude Code's own
54
+ transcripts (tokens, USD, wall/active time, subagent count). Reads
55
+ ~/.claude/projects — nothing to enable, and it covers runs that
56
+ already happened. Worktree-aware, so a feature adds up. --json for
57
+ the raw rollup, --runs to include every individual invocation.
52
58
  version Print the installed CLI version.`);
53
59
  process.exit(code);
54
60
  }
@@ -64,12 +70,30 @@ let port = parseInt(process.env.COHORTE_DASHBOARD_PORT, 10) || 4317;
64
70
  let host = process.env.COHORTE_DASHBOARD_HOST || '127.0.0.1';
65
71
  let openBrowser = false;
66
72
 
73
+ // Flags that belong to `metrics` and are forwarded verbatim to the collector. They are
74
+ // listed here rather than parsed, so the collector stays the single source of truth for
75
+ // its own options — but the generic "unknown flag" guard below must not reject them.
76
+ const metricsFlags = [];
77
+ const isMetricsFlag = (a) =>
78
+ a === '--json' || a === '--runs' || a.startsWith('--days=') || a.startsWith('--since=');
79
+
67
80
  for (const a of args) {
68
- if (a === 'install' || a === 'update' || a === 'dashboard') mode = a;
81
+ if (a === 'install' || a === 'update' || a === 'dashboard' || a === 'metrics') mode = a;
82
+ else if (isMetricsFlag(a)) metricsFlags.push(a);
69
83
  else if (a === 'version' || a === '--version' || a === '-v') { console.log(VERSION); process.exit(0); }
70
84
  else if (a === '--global' || a === '-g') scope = 'global';
71
- else if (a.startsWith('--port=')) { port = parseInt(a.slice(7), 10); }
72
- else if (a.startsWith('--host=')) { host = a.slice(7); }
85
+ else if (a.startsWith('--port=')) {
86
+ // A bad value used to land as NaN, which http.listen() silently treats as
87
+ // "any free port" — the banner then printed `localhost:NaN` and nothing worked.
88
+ port = parseInt(a.slice(7), 10);
89
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
90
+ console.error(`error: --port must be an integer 1-65535 (got "${a.slice(7)}")`);
91
+ process.exit(2);
92
+ }
93
+ } else if (a.startsWith('--host=')) {
94
+ host = a.slice(7).trim();
95
+ if (!host) { console.error('error: --host= needs an address (e.g. --host=0.0.0.0)'); process.exit(2); }
96
+ }
73
97
  else if (a === '--open') { openBrowser = true; }
74
98
  else if (a === 'help' || a === '--help' || a === '-h') usage(0);
75
99
  else if (a.startsWith('-')) { console.error(`error: unknown flag: ${a}`); usage(2); }
@@ -86,6 +110,19 @@ if (mode === 'dashboard') {
86
110
  return;
87
111
  }
88
112
 
113
+ // --- metrics: cost + runtime per command ------------------------------------
114
+ // The collector is ESM and this CLI is CommonJS, so it runs as a child process rather
115
+ // than being required. stdio is inherited so --json stays pipeable.
116
+ if (mode === 'metrics') {
117
+ const script = path.join(pkgRoot, 'scripts', 'metrics', 'collect.mjs');
118
+ if (!fs.existsSync(script)) {
119
+ console.error(`error: metrics collector not found at ${script}`);
120
+ process.exit(1);
121
+ }
122
+ const r = spawnSync(process.execPath, [script, target, ...metricsFlags], { stdio: 'inherit' });
123
+ process.exit(r.status == null ? 1 : r.status);
124
+ }
125
+
89
126
  // --- paths -------------------------------------------------------------------
90
127
  const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
91
128
  const dest = scope === 'global' ? globalDir : path.join(target, '.claude');
@@ -103,8 +140,16 @@ function copyCore() {
103
140
  // Workflow runtime resolves from .claude/workflows (bundled) or ~/.claude/workflows
104
141
  // (global) — same copy rule in both modes, like commands.
105
142
  for (const d of ['commands', 'hooks', 'templates', 'workflows']) {
106
- fs.cpSync(path.join(src, 'core', d), path.join(dest, d), { recursive: true, force: true });
143
+ fs.cpSync(path.join(src, 'core', d), path.join(dest, d), {
144
+ recursive: true,
145
+ force: true,
146
+ // Never carry a Python bytecode cache into a user's .claude. It appears in a
147
+ // source checkout the moment anyone compiles or imports gate.py (CI does), it
148
+ // is machine- and interpreter-specific, and copy-over never deletes it later.
149
+ filter: s => !s.split(/[\\/]/).includes('__pycache__') && !s.endsWith('.pyc'),
150
+ });
107
151
  }
152
+ fs.rmSync(path.join(dest, 'hooks', '__pycache__'), { recursive: true, force: true });
108
153
  // 0.1.19 renamed questionnaire-domain-brief.md → research-brief.md; drop the stale copy.
109
154
  fs.rmSync(path.join(dest, 'templates', 'questionnaire-domain-brief.md'), { force: true });
110
155
  const pipelineDir = path.join(dest, 'pipeline');
@@ -61,12 +61,17 @@ tools are unavailable or come up empty.
61
61
 
62
62
  ## How you work — strict TDD (red → green → refactor)
63
63
 
64
- 1. <SURFACE_TDD_STEP1>
65
- 2. **Write the failing test(s) first** from the frozen contract. Cover exactly what your baked
64
+ <!-- <SURFACE_TDD_STEP1> is a LEAD-IN paragraph, not a numbered item: it is filled only for a
65
+ `uses_design` surface (the design-pull step) and renders as nothing otherwise. As a numbered
66
+ item it left every non-design agent with a blank "1." above the real first step. -->
67
+
68
+ <SURFACE_TDD_STEP1>
69
+
70
+ 1. **Write the failing test(s) first** from the frozen contract. Cover exactly what your baked
66
71
  Testing rules (§Your conventions) prescribe. Run the test command and watch it fail (red).
67
- 3. Implement until green, following your baked conventions.
68
- 4. Refactor to the conventions. Keep tests green.
69
- 5. **Lint + format before handoff:** run your surface's lint and fix every issue. If the project
72
+ 2. Implement until green, following your baked conventions.
73
+ 3. Refactor to the conventions. Keep tests green.
74
+ 4. **Lint + format before handoff:** run your surface's lint and fix every issue. If the project
70
75
  registers a PostToolUse format hook (see `.claude/settings.json`), your files are already
71
76
  formatted on every write — skip `format_cmd`; otherwise run it too. Code you hand off must be
72
77
  lint-clean and formatted.
@@ -21,6 +21,28 @@ nothing only when something is wrong.
21
21
  3. Return **only** the JSON object — no fences, no commentary, no markdown. Your final message is
22
22
  parsed by a script.
23
23
 
24
+ ## Returning through a structured-output tool
25
+
26
+ When you are given a tool to return structured output, the profile's own keys go at the **top
27
+ level** of that tool's input — `surfaces`, `commands`, `vcs`, `contract`, and the rest, as real
28
+ JSON values.
29
+
30
+ Do **not** serialize the profile to a string and nest it under a wrapper field. These are wrong:
31
+
32
+ ```json
33
+ {"output": "{\"surfaces\": [ … ]}"} ← the profile as text under a wrapper
34
+ {"profile": {"surfaces": [ … ]}} ← the profile nested one level down
35
+ ```
36
+
37
+ This is right:
38
+
39
+ ```json
40
+ {"surfaces": [ … ], "commands": { … }, "vcs": { … }}
41
+ ```
42
+
43
+ A wrapped return costs the caller everything: the script reads `surfaces` as absent, builds
44
+ nothing, and reports success anyway.
45
+
24
46
  ## Failure shape
25
47
 
26
48
  If `PIPELINE.md` is missing, or the `yaml pipeline-profile` fence is absent or unparseable, return
@@ -37,14 +37,16 @@ fix only with the human's go-ahead (or hand them the command).
37
37
  (`review.md`, `release.md`, `smoke.md`, `profile-reader.md` — repo or `~/.claude/agents/`) must
38
38
  each carry their `model:` line too (sonnet/haiku/sonnet/haiku). **Command pins:** every mechanical command file
39
39
  (`build`, `review`, `fix`, `smoke`, `ship`, `audit`, `refactor`, `doctor`, `align-ds`,
40
- `update-pipeline`, `cycle` — in `.claude/commands/` or `~/.claude/commands/`) carries `model: sonnet` in
40
+ `update-pipeline` — in `.claude/commands/` or `~/.claude/commands/`) carries `model: sonnet` in
41
41
  its frontmatter — ⚠️ if missing (the lead's orchestration turn then bills at the session model,
42
42
  e.g. Opus/Fable). `brainstorm`, `spec`, and `init-pipeline` are intentionally unpinned
43
43
  (interactive — they inherit the session model).
44
44
  3. **Hooks & gate.** `.claude/gate-config.json` exists and mirrors the profile's `gate` block
45
45
  (regenerate if drifted). The PreToolUse gate hook is registered **once** for the install mode
46
46
  (bundled: repo `settings.json`; global: `~/.claude/settings.json` — flag double registration,
47
- it double-prompts). Hook files exist at the registered paths.
47
+ it double-prompts) **with a matcher covering both `Bash` and `Task`** — a `Bash`-only matcher
48
+ leaves the preflight phase gate dead (the 1.3.0–1.3.1 regression). Hook files exist at the
49
+ registered paths.
48
50
  4. **Retrieval** (if `retrieval.provider` ≠ `none`). Run the SCHEMA.md §Code retrieval health
49
51
  check: CLI resolvable from PATH, `.mcp.json` entry present in PATH-proof launcher form,
50
52
  `.serena/` gitignored, server actually connects.
@@ -71,7 +73,7 @@ fix only with the human's go-ahead (or hand them the command).
71
73
  take and why:
72
74
  - **Claude Code version** ≥ 2.1.154 (`claude --version 2>/dev/null | head -1`) — older or no CLI
73
75
  on PATH ⇒ conversational only.
74
- - **Scripts present:** `<core>/workflows/review.js` + `audit.js` + `refactor.js` + `cycle.js`
76
+ - **Scripts present:** `<core>/workflows/review.js` + `audit.js` + `refactor.js` —
75
77
  missing on a current core ⇒ half-done install, re-run install/update.
76
78
  - **Phase-0 agent present:** `<agents dir>/profile-reader.md` (repo `.claude/agents/` bundled or
77
79
  `~/.claude/agents/` global) — the workflows abort without it.
@@ -82,7 +84,9 @@ fix only with the human's go-ahead (or hand them the command).
82
84
  End the check with ONE summary line, e.g.
83
85
  `workflows: available (opt-in — ask to "run the review workflow")` or
84
86
  `workflows: unavailable (<first failing prerequisite>) — conversational commands (the default)`.
85
- 9. **Specs & metrics.** Every `specs/*.md` front-matter `status` is a valid stage; `shipped` specs
87
+ 9. **Specs & metrics.** Every `specs/*.md` front-matter `status` is a valid stage excluding
88
+ `_`-prefixed files and `specs/refactor-backlog.md`, which `/audit` writes as a backlog, not a
89
+ spec, and which has no front-matter to check; `shipped` specs
86
90
  with a live worktree flagged (see 6). `.claude/pipeline-metrics.jsonl` and `specs/reports/` (the
87
91
  `/review`·`/smoke` report buffer that lets a `/fix` survive a `/clear`) are gitignored. Metrics
88
92
  belong to the **main checkout** — a `pipeline-metrics.jsonl` inside a live feature worktree is a
@@ -100,18 +100,33 @@ def current_branch(cwd: str):
100
100
  return None
101
101
 
102
102
 
103
- def current_head(cwd: str):
104
- """HEAD sha at `cwd`, or None."""
103
+ def known_heads(cwd: str):
104
+ """Every HEAD sha this repo currently has checked out: `cwd`'s plus each
105
+ linked worktree's. The preflight may legitimately have run in a feature
106
+ worktree while the Task dispatch fires from the main checkout (or vice
107
+ versa) — comparing against a single HEAD flags those runs as stale."""
108
+ heads = set()
105
109
  try:
106
110
  out = subprocess.run(
107
111
  ["git", "rev-parse", "HEAD"],
108
112
  cwd=cwd, capture_output=True, text=True, timeout=3,
109
113
  )
114
+ if out.returncode == 0 and out.stdout.strip():
115
+ heads.add(out.stdout.strip())
116
+ except Exception:
117
+ pass
118
+ try:
119
+ out = subprocess.run(
120
+ ["git", "worktree", "list", "--porcelain"],
121
+ cwd=cwd, capture_output=True, text=True, timeout=3,
122
+ )
110
123
  if out.returncode == 0:
111
- return out.stdout.strip() or None
124
+ for line in out.stdout.splitlines():
125
+ if line.startswith("HEAD "):
126
+ heads.add(line.split(" ", 1)[1].strip())
112
127
  except Exception:
113
128
  pass
114
- return None
129
+ return heads
115
130
 
116
131
 
117
132
  def check_preflight(payload: dict, cfg: dict) -> int:
@@ -140,8 +155,8 @@ def check_preflight(payload: dict, cfg: dict) -> int:
140
155
  if age_min > max_age:
141
156
  why = f"the preflight stamp is {age_min:.0f} min old (max {max_age:.0f})"
142
157
  else:
143
- head = current_head(session_cwd(payload))
144
- if head and sha not in ("", "none") and head != sha:
158
+ heads = known_heads(session_cwd(payload))
159
+ if heads and sha not in ("", "none") and sha not in heads:
145
160
  why = "HEAD moved since the preflight ran"
146
161
  except Exception:
147
162
  why = "the preflight stamp is unreadable (expected `<epoch> <sha>`)"
@@ -1,6 +1,11 @@
1
- # HANDOFF — <surface> · `<feature_id>`
1
+ # HANDOFF — <surface> · <feature_id>
2
2
 
3
- <!-- Keep it tight: the lead only acts on mismatches, test failures, remediation ticks, and TODOs.
3
+ <!-- REFERENCE COPY not read at runtime. The AUTHORITATIVE shape is inlined in
4
+ core/agents/implementer.template.md (§"Your return"), because a rendered agent must
5
+ not need a second file read to know its own output format. This file exists for humans
6
+ reading the core; keep it byte-aligned with the template, or delete both together.
7
+
8
+ Keep it tight: the lead only acts on mismatches, test failures, remediation ticks, and TODOs.
4
9
  Never list files one by one — the lead has `git diff --stat`. Never paste code excerpts —
5
10
  a file:line reference is enough, the code is on disk. One line per item. -->
6
11
 
@@ -1,15 +1,18 @@
1
1
  # REVIEW REPORT
2
2
 
3
- <!-- Emitted-report shape. Everything in an HTML comment is guidance do NOT emit it.
3
+ <!-- REFERENCE COPY not read at runtime. The AUTHORITATIVE shape is inlined in
4
+ core/agents/review.md (§"Your return"), because an agent must not need a second
5
+ file read to know its own output format. This file exists for humans reading the
6
+ core; keep it byte-aligned with the agent, or delete both together.
7
+
8
+ Everything in an HTML comment is guidance — do NOT emit it.
4
9
  Verdict rules live in the review agent's instructions (SHIP = no CRITICAL/security;
5
10
  REVISE = ≥1 CRITICAL; BLOCK = security vulnerability).
6
11
  CAPPED: max 20 findings, ONE line each (severity · file:line · type · concrete fix),
7
12
  ZERO code excerpts — file:line is enough, the source is on disk. Overflow ⇒ keep all
8
13
  CRITICAL/HIGH/security, fill by severity, close with one `+<n> more …` line. -->
9
14
 
10
- feature_id: <feature_id>
11
- Feature branch: <feature_branch_prefix><feature_id>
12
- Commit SHA: <first 12 chars>
15
+ feature_id: <feature_id> · scope: <surface.key>
13
16
 
14
17
  | Severity | Count |
15
18
  | -------- | ----- |
@@ -49,9 +49,12 @@ design_files: [] # design page links — full URLs of the form https://claude.ai
49
49
 
50
50
  - `<slug>...Request` / `<slug>...Response` / `<slug>Base` … (sketch the schemas here)
51
51
 
52
- ## 6+. Surface tasks
52
+ ## 6. Surface tasks
53
53
 
54
- > One subsection per surface in `PIPELINE.md` §surfaces (e.g. backend, frontend), each TDD.
54
+ > One `###` subsection per surface in `PIPELINE.md` §surfaces (e.g. backend, frontend), each TDD.
55
+ > Surfaces are subsections of §6, never top-level sections — §8 and §9 are referenced by number
56
+ > across the whole pipeline (`/smoke` drives "§8 flows", `/review` checks "§9 acceptance"), so a
57
+ > third surface must not become a §8 of its own.
55
58
 
56
59
  ### <surface.key>
57
60
 
@@ -14,7 +14,8 @@
14
14
  PIPELINE.md you just wrote), and the surface-specific blocks
15
15
  (`<SURFACE_EXTRA_NEVER>`, `<SURFACE_DESIGN_INPUT>`, `<SURFACE_TDD_STEP1>` — fill design-related ones
16
16
  only when `uses_design`).
17
- Leave `review.md` + `release.md` + `profile-reader.md` as-is (generic).
17
+ Leave the fixed agents as-is (generic, shipped by the installer): `review.md`, `release.md`,
18
+ `smoke.md`, `profile-reader.md`.
18
19
  4. **Generate `.claude/gate-config.json`** from the `gate` block — copy all five keys verbatim:
19
20
  `{"deny": [...], "ask": [...], "ask_on_default_branch": [...], "default_branch": "<vcs.default_branch>",
20
21
  "preflight": {"enabled": <gate.preflight.enabled>, "agents": [...], "max_age_minutes": <n>}}`
@@ -21,9 +21,67 @@ export const meta = {
21
21
  ],
22
22
  }
23
23
 
24
- const target = (typeof args === 'string' ? args.trim() : args && args.target) || ''
24
+ // The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
25
+ // JSON-ENCODED STRING instead of a real object gets that string back here. The old
26
+ // `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
27
+ // is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
28
+ // maxRounds/smoke were silently dropped on the same run. Parse it back into the object
29
+ // it was meant to be; a bare slug stays valid shorthand.
30
+ const ARGS = (() => {
31
+ if (typeof args === 'string') {
32
+ const t = args.trim()
33
+ if (t.startsWith('{')) {
34
+ try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
35
+ }
36
+ return { feature: t, target: t }
37
+ }
38
+ return args && typeof args === 'object' ? args : {}
39
+ })()
40
+ const target = ARGS.target || ''
25
41
 
26
- const PROFILE = { type: 'object', additionalProperties: true }
42
+ // The profile-reader returns through a StructuredOutput tool call, and a haiku agent
43
+ // intermittently nests the whole profile as a JSON *string* under a single wrapper field
44
+ // ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
45
+ // The schema here used to be {type:'object', additionalProperties:true} — no declared
46
+ // properties, no required keys — so that wrapper validated cleanly and every field then read
47
+ // as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
48
+ // dead-agent guard had no surfaces to find missing, and the run reported a verdict having
49
+ // done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
50
+ // Declaring the shape gives the tool layer something to validate and the agent something to
51
+ // aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
52
+ // zero-surface abort below makes the silent-success path impossible either way.
53
+ // See also the structured-output section of core/agents/profile-reader.md.
54
+ const PROFILE = {
55
+ type: 'object', additionalProperties: true,
56
+ properties: {
57
+ error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
58
+ surfaces: {
59
+ type: 'array',
60
+ description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
61
+ items: {
62
+ type: 'object', required: ['key'], additionalProperties: true,
63
+ properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
64
+ },
65
+ },
66
+ },
67
+ }
68
+
69
+ // Salvage a profile handed back as JSON text rather than as an object — either the whole
70
+ // return, or nested under a single wrapper field. Anything already shaped like a profile
71
+ // (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
72
+ const unwrapProfile = p => {
73
+ if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
74
+ if (!p || typeof p !== 'object') return null
75
+ if (Array.isArray(p.surfaces) || p.error) return p
76
+ for (const v of Object.values(p)) {
77
+ if (typeof v !== 'string') continue
78
+ try {
79
+ const inner = JSON.parse(v)
80
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
81
+ } catch {}
82
+ }
83
+ return p
84
+ }
27
85
 
28
86
  const GATES = {
29
87
  type: 'object', required: ['failures'], additionalProperties: false,
@@ -64,15 +122,21 @@ const BACKLOG = {
64
122
 
65
123
  // ── Phase 0 — profile ────────────────────────────────────────────────────────
66
124
  phase('Profile')
67
- const profile = await agent(
125
+ const profile = unwrapProfile(await agent(
68
126
  'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
69
127
  { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
70
- )
128
+ ))
71
129
  if (!profile || profile.error) {
72
130
  return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
73
131
  }
74
132
  const cmds = profile.commands || {}
75
133
  const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
134
+ // A profile with no surfaces cannot do this workflow's work, and every later
135
+ // guard compares against `surfaces` — an empty list makes them all vacuously
136
+ // pass. Fail loudly here instead of finishing with nothing done.
137
+ if (!surfaces.length) {
138
+ return { error: 'profile has no surfaces — nothing would be audited. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
139
+ }
76
140
  const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
77
141
  const scope = target || 'the whole repo'
78
142
 
@@ -114,11 +178,20 @@ const audited = await parallel(domains.map(d => () => agent(
114
178
  { agentType: 'review', label: `audit:${d.key}`, schema: BACKLOG },
115
179
  ).then(r => r && { key: d.key, items: r.items, overflow: r.overflow || 0 })))
116
180
  const perDomain = audited.filter(Boolean)
181
+ // A dead auditor returns null, and a domain with no result is indistinguishable
182
+ // from a domain with nothing to report — the backlog would simply omit it and the
183
+ // human would read that as "clean". Name them instead.
184
+ const deadDomains = domains.filter(d => !perDomain.some(p => p.key === d.key)).map(d => d.key)
185
+ if (deadDomains.length) log(`Auditor died on: ${deadDomains.join(', ')} — those domains are NOT audited`)
117
186
 
118
187
  // ── Phase 3 — merge + write the backlog ──────────────────────────────────────
119
188
  phase('Backlog')
120
189
  const SEV = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
121
190
  const body = ['# Refactor backlog', '', `> Generated by the cohorte-audit workflow (scope: ${scope}).`]
191
+ if (deadDomains.length) {
192
+ body.push('', `> ⚠ NOT audited (the auditor died): ${deadDomains.join(', ')} — absence of items below`,
193
+ '> for those domains means "not looked at", not "clean". Re-run the audit for them.')
194
+ }
122
195
  let total = 0
123
196
  for (const d of perDomain) {
124
197
  const items = [...d.items].sort((a, b) => SEV[a.severity] - SEV[b.severity])
@@ -127,18 +200,26 @@ for (const d of perDomain) {
127
200
  for (const it of items) body.push(`- [ ] ${it.severity} · ${it.file}:${it.line} · ${it.kind} · ${it.fix}`)
128
201
  if (d.overflow) body.push(`- [ ] (+${d.overflow} more beyond the cap — re-audit ${d.key} after this pass)`)
129
202
  }
130
- await agent(
203
+ const written = await agent(
131
204
  `Write EXACTLY this content to specs/refactor-backlog.md (overwrite), then return the single word done:\n<<<BACKLOG\n${body.join('\n')}\nBACKLOG`,
132
205
  { model: 'haiku', label: 'write-backlog', effort: 'low' },
133
206
  )
207
+ // Returning `backlog: <path>` when the writer died points /refactor at a file
208
+ // that does not exist (or, worse, at the PREVIOUS run's stale backlog).
209
+ const backlogOk = written != null && /done/i.test(String(written))
134
210
 
135
211
  return {
136
- backlog: 'specs/refactor-backlog.md',
212
+ backlog: backlogOk ? 'specs/refactor-backlog.md' : '(NOT written — the backlog writer died)',
213
+ notAudited: deadDomains, // absence of findings here means "not looked at"
137
214
  mechanicalFailures: mech.length,
138
215
  domains: Object.fromEntries(perDomain.map(d => [d.key, d.items.length + (d.overflow || 0)])),
139
216
  total,
140
217
  top: perDomain.flatMap(d => d.items.map(it => ({ ...it, domain: d.key })))
141
218
  .sort((a, b) => SEV[a.severity] - SEV[b.severity]).slice(0, 10)
142
219
  .map(it => `[${it.severity}] ${it.domain} · ${it.file}:${it.line} — ${it.fix}`),
143
- next: 'refactor a domain with /refactor <domain> (or the refactor workflow for big domains)',
220
+ next: !backlogOk
221
+ ? 'the backlog was NEVER written (writer died) — the counts above are real but nothing is on disk; re-run the audit'
222
+ : deadDomains.length
223
+ ? `re-audit ${deadDomains.join(', ')} (auditor died — not covered), then /refactor <domain>`
224
+ : 'refactor a domain with /refactor <domain> (or the refactor workflow for big domains)',
144
225
  }