cohorte 1.3.4 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
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
+
6
49
  ## 1.3.4 — 2026-07-31
7
50
 
8
51
  > **Re-run `npx cohorte@latest update --global` (or `update`)** — the workflow and script
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 → review → fix until zero findings (add `smoke` to run the app each round); 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. |
@@ -284,7 +283,6 @@ 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 → review(+cross-check) → fix (∥ smoke each round if opted in via `args.smoke`), looping until zero findings (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.
@@ -338,7 +335,7 @@ core/ # copied verbatim into ~/.claude (global) or <project>/.
338
335
  commands/ # init-pipeline + the pipeline commands + /update-pipeline
339
336
  hooks/ # gate.py (destructive-command gate; branch-aware; preflight phase gate)
340
337
  templates/ # handoff / brainstorm-return / design-brief / review-feedback / pr-body / spec
341
- workflows/ # opt-in Workflow-runtime scripts: cycle.js / review.js / audit.js / refactor.js
338
+ workflows/ # opt-in Workflow-runtime scripts: review.js / audit.js / refactor.js
342
339
  profile/
343
340
  PIPELINE.template.md # the profile skeleton /init-pipeline fills
344
341
  SCHEMA.md # field reference
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,8 +70,16 @@ 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
85
  else if (a.startsWith('--port=')) {
@@ -96,6 +110,19 @@ if (mode === 'dashboard') {
96
110
  return;
97
111
  }
98
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
+
99
126
  // --- paths -------------------------------------------------------------------
100
127
  const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
101
128
  const dest = scope === 'global' ? globalDir : path.join(target, '.claude');
@@ -109,7 +136,7 @@ fs.mkdirSync(dest, { recursive: true });
109
136
 
110
137
  // --- helpers (mirror install.sh) --------------------------------------------
111
138
  function copyCore() {
112
- // `workflows` = the deterministic orchestration scripts (cycle/review/audit/refactor) the
139
+ // `workflows` = the deterministic orchestration scripts (review/audit/refactor) the
113
140
  // Workflow runtime resolves from .claude/workflows (bundled) or ~/.claude/workflows
114
141
  // (global) — same copy rule in both modes, like commands.
115
142
  for (const d of ['commands', 'hooks', 'templates', 'workflows']) {
@@ -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,7 +37,7 @@ 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).
@@ -73,7 +73,7 @@ fix only with the human's go-ahead (or hand them the command).
73
73
  take and why:
74
74
  - **Claude Code version** ≥ 2.1.154 (`claude --version 2>/dev/null | head -1`) — older or no CLI
75
75
  on PATH ⇒ conversational only.
76
- - **Scripts present:** `<core>/workflows/review.js` + `audit.js` + `refactor.js` + `cycle.js`
76
+ - **Scripts present:** `<core>/workflows/review.js` + `audit.js` + `refactor.js` —
77
77
  missing on a current core ⇒ half-done install, re-run install/update.
78
78
  - **Phase-0 agent present:** `<agents dir>/profile-reader.md` (repo `.claude/agents/` bundled or
79
79
  `~/.claude/agents/` global) — the workflows abort without it.
@@ -84,7 +84,9 @@ fix only with the human's go-ahead (or hand them the command).
84
84
  End the check with ONE summary line, e.g.
85
85
  `workflows: available (opt-in — ask to "run the review workflow")` or
86
86
  `workflows: unavailable (<first failing prerequisite>) — conversational commands (the default)`.
87
- 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
88
90
  with a live worktree flagged (see 6). `.claude/pipeline-metrics.jsonl` and `specs/reports/` (the
89
91
  `/review`·`/smoke` report buffer that lets a `/fix` survive a `/clear`) are gitignored. Metrics
90
92
  belong to the **main checkout** — a `pipeline-metrics.jsonl` inside a live feature worktree is a
@@ -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
 
@@ -29,13 +29,72 @@ export const meta = {
29
29
  // /refactor handles it with less overhead than a workflow run.
30
30
  const MIN_ITEMS = 5
31
31
 
32
+ // The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
33
+ // JSON-ENCODED STRING instead of a real object gets that string back here. The old
34
+ // `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
35
+ // is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
36
+ // maxRounds/smoke were silently dropped on the same run. Parse it back into the object
37
+ // it was meant to be; a bare slug stays valid shorthand.
38
+ const ARGS = (() => {
39
+ if (typeof args === 'string') {
40
+ const t = args.trim()
41
+ if (t.startsWith('{')) {
42
+ try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
43
+ }
44
+ return { feature: t, target: t }
45
+ }
46
+ return args && typeof args === 'object' ? args : {}
47
+ })()
48
+
32
49
  const wanted = (() => {
33
- const d = args && args.domains
50
+ const d = ARGS.domains
34
51
  if (!d || d === 'all') return 'all'
35
52
  return Array.isArray(d) ? d : [String(d)]
36
53
  })()
37
54
 
38
- const PROFILE = { type: 'object', additionalProperties: true }
55
+ // The profile-reader returns through a StructuredOutput tool call, and a haiku agent
56
+ // intermittently nests the whole profile as a JSON *string* under a single wrapper field
57
+ // ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
58
+ // The schema here used to be {type:'object', additionalProperties:true} — no declared
59
+ // properties, no required keys — so that wrapper validated cleanly and every field then read
60
+ // as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
61
+ // dead-agent guard had no surfaces to find missing, and the run reported a verdict having
62
+ // done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
63
+ // Declaring the shape gives the tool layer something to validate and the agent something to
64
+ // aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
65
+ // zero-surface abort below makes the silent-success path impossible either way.
66
+ // See also the structured-output section of core/agents/profile-reader.md.
67
+ const PROFILE = {
68
+ type: 'object', additionalProperties: true,
69
+ properties: {
70
+ error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
71
+ surfaces: {
72
+ type: 'array',
73
+ description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
74
+ items: {
75
+ type: 'object', required: ['key'], additionalProperties: true,
76
+ properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
77
+ },
78
+ },
79
+ },
80
+ }
81
+
82
+ // Salvage a profile handed back as JSON text rather than as an object — either the whole
83
+ // return, or nested under a single wrapper field. Anything already shaped like a profile
84
+ // (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
85
+ const unwrapProfile = p => {
86
+ if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
87
+ if (!p || typeof p !== 'object') return null
88
+ if (Array.isArray(p.surfaces) || p.error) return p
89
+ for (const v of Object.values(p)) {
90
+ if (typeof v !== 'string') continue
91
+ try {
92
+ const inner = JSON.parse(v)
93
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
94
+ } catch {}
95
+ }
96
+ return p
97
+ }
39
98
 
40
99
  const OPEN = {
41
100
  type: 'object', required: ['domains'], additionalProperties: false,
@@ -65,14 +124,20 @@ const VERIFY = {
65
124
 
66
125
  // ── Phase 0 — profile ────────────────────────────────────────────────────────
67
126
  phase('Profile')
68
- const profile = await agent(
127
+ const profile = unwrapProfile(await agent(
69
128
  'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
70
129
  { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
71
- )
130
+ ))
72
131
  if (!profile || profile.error) {
73
132
  return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
74
133
  }
75
134
  const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
135
+ // A profile with no surfaces cannot do this workflow's work, and every later
136
+ // guard compares against `surfaces` — an empty list makes them all vacuously
137
+ // pass. Fail loudly here instead of finishing with nothing done.
138
+ if (!surfaces.length) {
139
+ return { error: 'profile has no surfaces — nothing would be refactored. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
140
+ }
76
141
  const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
77
142
  const contractPath = (profile.contract && profile.contract.path) || ''
78
143
  const base = (profile.vcs && profile.vcs.default_branch) || 'main'
@@ -26,10 +26,73 @@ export const meta = {
26
26
  ],
27
27
  }
28
28
 
29
- const feature = typeof args === 'string' ? args.trim() : args && args.feature
29
+ // The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
30
+ // JSON-ENCODED STRING instead of a real object gets that string back here. The old
31
+ // `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
32
+ // is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
33
+ // maxRounds/smoke were silently dropped on the same run. Parse it back into the object
34
+ // it was meant to be; a bare slug stays valid shorthand.
35
+ const ARGS = (() => {
36
+ if (typeof args === 'string') {
37
+ const t = args.trim()
38
+ if (t.startsWith('{')) {
39
+ try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
40
+ }
41
+ return { feature: t, target: t }
42
+ }
43
+ return args && typeof args === 'object' ? args : {}
44
+ })()
45
+ const isSlug = s => typeof s === 'string' && /^[A-Za-z0-9._-]+$/.test(s)
46
+ const feature = ARGS.feature
30
47
  if (!feature) throw new Error('cohorte-review needs args = {feature: "<feature_id>"}')
48
+ if (!isSlug(feature)) {
49
+ throw new Error(`cohorte-review got a feature id that is not a slug: ${JSON.stringify(feature)}. ` +
50
+ 'Pass args as a real object, e.g. {feature: "titlebar-project-switcher"} — not a JSON string.')
51
+ }
52
+
53
+ // The profile-reader returns through a StructuredOutput tool call, and a haiku agent
54
+ // intermittently nests the whole profile as a JSON *string* under a single wrapper field
55
+ // ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
56
+ // The schema here used to be {type:'object', additionalProperties:true} — no declared
57
+ // properties, no required keys — so that wrapper validated cleanly and every field then read
58
+ // as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
59
+ // dead-agent guard had no surfaces to find missing, and the run reported a verdict having
60
+ // done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
61
+ // Declaring the shape gives the tool layer something to validate and the agent something to
62
+ // aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
63
+ // zero-surface abort below makes the silent-success path impossible either way.
64
+ // See also the structured-output section of core/agents/profile-reader.md.
65
+ const PROFILE = {
66
+ type: 'object', additionalProperties: true,
67
+ properties: {
68
+ error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
69
+ surfaces: {
70
+ type: 'array',
71
+ description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
72
+ items: {
73
+ type: 'object', required: ['key'], additionalProperties: true,
74
+ properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
75
+ },
76
+ },
77
+ },
78
+ }
31
79
 
32
- const PROFILE = { type: 'object', additionalProperties: true }
80
+ // Salvage a profile handed back as JSON text rather than as an object either the whole
81
+ // return, or nested under a single wrapper field. Anything already shaped like a profile
82
+ // (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
83
+ const unwrapProfile = p => {
84
+ if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
85
+ if (!p || typeof p !== 'object') return null
86
+ if (Array.isArray(p.surfaces) || p.error) return p
87
+ for (const v of Object.values(p)) {
88
+ if (typeof v !== 'string') continue
89
+ try {
90
+ const inner = JSON.parse(v)
91
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
92
+ } catch {}
93
+ }
94
+ return p
95
+ }
33
96
 
34
97
  const PREFLIGHT = {
35
98
  type: 'object', required: ['pass'], additionalProperties: false,
@@ -88,16 +151,22 @@ const VERDICT = {
88
151
 
89
152
  // ── Phase 0 — profile ────────────────────────────────────────────────────────
90
153
  phase('Profile')
91
- const profile = await agent(
154
+ const profile = unwrapProfile(await agent(
92
155
  'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
93
156
  { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
94
- )
157
+ ))
95
158
  if (!profile || profile.error) {
96
159
  return { verdict: 'ABORTED', reason: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
97
160
  }
98
161
  const cmds = profile.commands || {}
99
162
  const base = (profile.vcs && profile.vcs.default_branch) || 'main'
100
163
  const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
164
+ // A profile with no surfaces cannot do this workflow's work, and every later
165
+ // guard compares against `surfaces` — an empty list makes them all vacuously
166
+ // pass. Fail loudly here instead of finishing with nothing done.
167
+ if (!surfaces.length) {
168
+ return { verdict: 'ABORTED', reason: 'profile has no surfaces — nothing would be reviewed. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
169
+ }
101
170
  const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
102
171
  const checks = [cmds.typecheck, quiet(cmds.lint_quiet, cmds.lint), quiet(cmds.test_quiet, cmds.test)]
103
172
  .filter(c => c && !String(c).startsWith('<'))
@@ -18,6 +18,13 @@ const FIXED_AGENTS = new Set([
18
18
 
19
19
  const VALID_STATUS = ['draft', 'frozen', 'in-review', 'shipped'];
20
20
 
21
+ // Artifacts the pipeline itself writes into specs/ that are NOT feature specs and have no
22
+ // front-matter status. `/audit` writes specs/refactor-backlog.md by design, so scanning it
23
+ // as a spec made /doctor warn about a file cohorte had just created — a false positive that
24
+ // fired in every project that had ever run /audit. `_`-prefixed files (e.g. _template.md)
25
+ // are already skipped by the reader below.
26
+ const NON_SPEC_FILES = new Set(['refactor-backlog.md']);
27
+
21
28
  const exists = p => { try { return fs.existsSync(p); } catch { return false; } };
22
29
  const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
23
30
  const readJson = p => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
@@ -226,7 +233,7 @@ function checkIsolation(profile, projectRoot) {
226
233
  return mk('isolation', 'Isolation', 'ok', 'feature scripts rendered (worktree state not checked here)');
227
234
  }
228
235
 
229
- // Workflow variants (cycle/review/audit/refactor as deterministic multi-agent runs) are opt-in;
236
+ // Workflow variants (review/audit/refactor as deterministic multi-agent runs) are opt-in;
230
237
  // the conversational commands stay the default path, so nothing here is ever 'bad'.
231
238
  // Whether the session has workflows ENABLED needs a live Claude session — /doctor
232
239
  // in-session checks that; here we only check what's on disk.
@@ -238,7 +245,7 @@ function checkWorkflows(projectRoot, globalDir, installMode) {
238
245
  const agentsDir = installMode === 'bundled'
239
246
  ? path.join(projectRoot, '.claude', 'agents')
240
247
  : path.join(globalDir, 'agents');
241
- const scripts = ['review.js', 'audit.js', 'refactor.js', 'cycle.js'];
248
+ const scripts = ['review.js', 'audit.js', 'refactor.js'];
242
249
  const missing = scripts.filter(s => !exists(path.join(dir, s)));
243
250
  if (missing.length === scripts.length) {
244
251
  return mk('workflows', 'Workflows', 'warn',
@@ -263,7 +270,7 @@ function scanSpecs(projectRoot) {
263
270
  const dir = path.join(projectRoot, 'specs');
264
271
  const specs = [];
265
272
  let files = [];
266
- try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_')); }
273
+ try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_') && !NON_SPEC_FILES.has(f)); }
267
274
  catch { return specs; }
268
275
  for (const f of files) {
269
276
  const txt = readText(path.join(dir, f)) || '';
@@ -11,9 +11,9 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
 
14
- // `cycle` is the workflow variant's own batch (cycle.js §Close). Without it in this
15
- // list its per-surface results parse fine but render in no column — the surface table
16
- // showed rows with every cell empty.
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.
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}),
@@ -74,7 +74,8 @@ function aggregate(batches) {
74
74
  };
75
75
  byFeature.set(b.feature, f);
76
76
  }
77
- // cycle.js reports its round count outside `surfaces` (that map is for surfaces).
77
+ // The retired cycle phase reported its round count outside `surfaces` (that map is for
78
+ // surfaces); historical files still carry it.
78
79
  if (b.phase === 'cycle' && Number(b.rounds) > 0) f.cycleRounds = Number(b.rounds);
79
80
  if (b.ts && (!f.firstTs || b.ts < f.firstTs)) f.firstTs = b.ts;
80
81
  if (b.ts && b.ts > f.lastTs) f.lastTs = b.ts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.3.4",
3
+ "version": "1.4.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"