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 +43 -0
- package/README.md +4 -7
- package/bin/cli.js +29 -2
- package/core/agents/profile-reader.md +22 -0
- package/core/commands/doctor.md +5 -3
- package/core/workflows/audit.js +68 -4
- package/core/workflows/refactor.js +69 -4
- package/core/workflows/review.js +73 -4
- package/dashboard/server/doctor.js +10 -3
- package/dashboard/server/metrics.js +5 -4
- package/package.json +1 -1
- package/profile/SCHEMA.md +12 -34
- package/scripts/metrics/collect.mjs +495 -0
- package/scripts/metrics/prices.json +39 -0
- package/scripts/test-dashboard.mjs +7 -1
- package/scripts/test-metrics.mjs +135 -0
- package/scripts/test-workflows.mjs +56 -178
- package/scripts/validate-core.mjs +5 -4
- package/core/commands/cycle.md +0 -61
- package/core/workflows/cycle.js +0 -513
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
|
|
301
|
-
|
|
302
|
-
|
|
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:
|
|
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 (
|
|
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
|
package/core/commands/doctor.md
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
|
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
|
package/core/workflows/audit.js
CHANGED
|
@@ -21,9 +21,67 @@ export const meta = {
|
|
|
21
21
|
],
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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'
|
package/core/workflows/review.js
CHANGED
|
@@ -26,10 +26,73 @@ export const meta = {
|
|
|
26
26
|
],
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
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 (
|
|
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'
|
|
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
|
|
15
|
-
// list its per-surface results parse fine but
|
|
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
|
|
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
|
+
"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"
|