cohorte 1.3.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +36 -16
- package/bin/cli.js +34 -4
- package/core/agents/profile-reader.md +22 -0
- package/core/commands/build.md +4 -4
- package/core/commands/doctor.md +10 -8
- package/core/commands/fix.md +5 -5
- package/core/commands/loop.md +61 -0
- package/core/commands/review.md +38 -5
- package/core/hooks/gate.py +4 -4
- package/core/templates/spec.template.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -1
- package/core/templates/steps/init-pipeline/04-write-render.md +8 -4
- package/core/workflows/audit.js +68 -4
- package/core/workflows/refactor.js +69 -4
- package/core/workflows/review.js +73 -4
- package/dashboard/dist/assets/index-8owBnqyv.js +43 -0
- package/dashboard/dist/assets/{index-AFQnlfjO.css → index-dkO8UUVl.css} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/doctor.js +15 -5
- package/dashboard/server/index.js +7 -0
- package/dashboard/server/metrics.js +6 -5
- package/dashboard/server/usage.js +61 -0
- package/install.ps1 +4 -1
- package/install.sh +5 -2
- package/package.json +1 -1
- package/profile/PIPELINE.template.md +3 -3
- package/profile/SCHEMA.md +21 -44
- package/scripts/loop.sh +189 -0
- package/scripts/metrics/collect.mjs +504 -0
- package/scripts/metrics/prices.json +39 -0
- package/scripts/preflight.sh +2 -2
- package/scripts/telemetry-send.sh +5 -2
- package/scripts/test-dashboard.mjs +29 -3
- package/scripts/test-gate.mjs +1 -2
- package/scripts/test-metrics.mjs +144 -0
- package/scripts/test-workflows.mjs +56 -178
- package/scripts/validate-core.mjs +10 -9
- package/core/agents/smoke.md +0 -63
- package/core/commands/cycle.md +0 -61
- package/core/commands/smoke.md +0 -55
- package/core/workflows/cycle.js +0 -513
- package/dashboard/dist/assets/index-DLBzciIC.js +0 -43
|
@@ -12,12 +12,22 @@ const { versions } = require('./versions');
|
|
|
12
12
|
// Rendered surface agents live alongside these fixed (non-surface) agents; exclude them
|
|
13
13
|
// from the orphan check so they're never mistaken for a stray surface agent.
|
|
14
14
|
const FIXED_AGENTS = new Set([
|
|
15
|
-
'review', 'release', '
|
|
15
|
+
'review', 'release', 'profile-reader',
|
|
16
|
+
// retired (1.5.0) — still excluded so a stale install's leftover file isn't
|
|
17
|
+
// reported as a stray surface agent.
|
|
18
|
+
'smoke',
|
|
16
19
|
'implementer.template',
|
|
17
20
|
]);
|
|
18
21
|
|
|
19
22
|
const VALID_STATUS = ['draft', 'frozen', 'in-review', 'shipped'];
|
|
20
23
|
|
|
24
|
+
// Artifacts the pipeline itself writes into specs/ that are NOT feature specs and have no
|
|
25
|
+
// front-matter status. `/audit` writes specs/refactor-backlog.md by design, so scanning it
|
|
26
|
+
// as a spec made /doctor warn about a file cohorte had just created — a false positive that
|
|
27
|
+
// fired in every project that had ever run /audit. `_`-prefixed files (e.g. _template.md)
|
|
28
|
+
// are already skipped by the reader below.
|
|
29
|
+
const NON_SPEC_FILES = new Set(['refactor-backlog.md']);
|
|
30
|
+
|
|
21
31
|
const exists = p => { try { return fs.existsSync(p); } catch { return false; } };
|
|
22
32
|
const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
|
|
23
33
|
const readJson = p => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
|
|
@@ -119,7 +129,7 @@ function checkGate(profile, projectRoot) {
|
|
|
119
129
|
const wantPf = gate.preflight || {};
|
|
120
130
|
const havePf = (cfg.preflight && typeof cfg.preflight === 'object') ? cfg.preflight : {};
|
|
121
131
|
if (!!wantPf.enabled !== !!havePf.enabled
|
|
122
|
-
|| !sameSet(wantPf.agents || ['review'
|
|
132
|
+
|| !sameSet(wantPf.agents || ['review'], havePf.agents || ['review'])
|
|
123
133
|
|| Number(wantPf.max_age_minutes || 30) !== Number(havePf.max_age_minutes || 30)) {
|
|
124
134
|
drifted.push('preflight');
|
|
125
135
|
}
|
|
@@ -226,7 +236,7 @@ function checkIsolation(profile, projectRoot) {
|
|
|
226
236
|
return mk('isolation', 'Isolation', 'ok', 'feature scripts rendered (worktree state not checked here)');
|
|
227
237
|
}
|
|
228
238
|
|
|
229
|
-
// Workflow variants (
|
|
239
|
+
// Workflow variants (review/audit/refactor as deterministic multi-agent runs) are opt-in;
|
|
230
240
|
// the conversational commands stay the default path, so nothing here is ever 'bad'.
|
|
231
241
|
// Whether the session has workflows ENABLED needs a live Claude session — /doctor
|
|
232
242
|
// in-session checks that; here we only check what's on disk.
|
|
@@ -238,7 +248,7 @@ function checkWorkflows(projectRoot, globalDir, installMode) {
|
|
|
238
248
|
const agentsDir = installMode === 'bundled'
|
|
239
249
|
? path.join(projectRoot, '.claude', 'agents')
|
|
240
250
|
: path.join(globalDir, 'agents');
|
|
241
|
-
const scripts = ['review.js', 'audit.js', 'refactor.js'
|
|
251
|
+
const scripts = ['review.js', 'audit.js', 'refactor.js'];
|
|
242
252
|
const missing = scripts.filter(s => !exists(path.join(dir, s)));
|
|
243
253
|
if (missing.length === scripts.length) {
|
|
244
254
|
return mk('workflows', 'Workflows', 'warn',
|
|
@@ -263,7 +273,7 @@ function scanSpecs(projectRoot) {
|
|
|
263
273
|
const dir = path.join(projectRoot, 'specs');
|
|
264
274
|
const specs = [];
|
|
265
275
|
let files = [];
|
|
266
|
-
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_')); }
|
|
276
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_') && !NON_SPEC_FILES.has(f)); }
|
|
267
277
|
catch { return specs; }
|
|
268
278
|
for (const f of files) {
|
|
269
279
|
const txt = readText(path.join(dir, f)) || '';
|
|
@@ -11,6 +11,7 @@ const { versions } = require('./versions');
|
|
|
11
11
|
const { state } = require('./doctor');
|
|
12
12
|
const { kanban } = require('./kanban');
|
|
13
13
|
const { metrics } = require('./metrics');
|
|
14
|
+
const { usage } = require('./usage');
|
|
14
15
|
const fleet = require('./fleet');
|
|
15
16
|
|
|
16
17
|
const MIME = {
|
|
@@ -321,6 +322,12 @@ function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, versi
|
|
|
321
322
|
const root = q ? path.resolve(q) : projectRoot;
|
|
322
323
|
return sendJson(res, 200, metrics({ projectRoot: root }));
|
|
323
324
|
}
|
|
325
|
+
if (url === '/api/usage') {
|
|
326
|
+
const q = new URL(req.url, 'http://localhost');
|
|
327
|
+
const root = q.searchParams.get('project') ? path.resolve(q.searchParams.get('project')) : projectRoot;
|
|
328
|
+
const days = Number(q.searchParams.get('days')) || null;
|
|
329
|
+
return sendJson(res, 200, usage({ projectRoot: root, days }));
|
|
330
|
+
}
|
|
324
331
|
if (url === '/api/projects') {
|
|
325
332
|
const body = await readBody(req);
|
|
326
333
|
if (req.method === 'POST') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Read a project's `.claude/pipeline-metrics.jsonl` (one line per phase batch, appended by
|
|
3
|
-
// /build, /review
|
|
3
|
+
// /build, /review and /fix) and aggregate it per feature: wall-clock per phase, fix
|
|
4
4
|
// rounds, and per-surface results. Dependency-free; a missing file is simply "no data yet".
|
|
5
5
|
//
|
|
6
6
|
// Two line formats coexist in the file:
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
|
|
14
|
-
// `cycle`
|
|
15
|
-
// list
|
|
16
|
-
// showed rows with every cell empty.
|
|
14
|
+
// `smoke` and `cycle` are RETIRED phases, kept so metrics files written before their removal
|
|
15
|
+
// still render. Without them in this list their per-surface results parse fine but land in no
|
|
16
|
+
// column — the surface table showed rows with every cell empty.
|
|
17
17
|
const PHASES = ['build', 'review', 'fix', 'smoke', 'cycle'];
|
|
18
18
|
|
|
19
19
|
// Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
|
|
@@ -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;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Serve the metrics collector's rollup (scripts/metrics/collect.mjs) to the dashboard:
|
|
3
|
+
// real cost and runtime per command, read from Claude Code's own transcripts.
|
|
4
|
+
//
|
|
5
|
+
// This is the SECOND metrics source in the cockpit, and the two answer different questions.
|
|
6
|
+
// `metrics.js` reads `.claude/pipeline-metrics.jsonl` — written by the model itself, so it
|
|
7
|
+
// carries per-surface verdicts (ok / REVISE:2 / error) that only the model knows, but it
|
|
8
|
+
// misses any run that ended early and can never report tokens. This one is derived from the
|
|
9
|
+
// transcripts, so it is complete and exact on cost and time but knows nothing about verdicts.
|
|
10
|
+
// Verdicts from one, money from the other; neither is a replacement for the other.
|
|
11
|
+
//
|
|
12
|
+
// The collector is ESM and this server is CommonJS, so it runs as a child process — the same
|
|
13
|
+
// bridge `bin/cli.js` uses. A spawn is ~1-2s on a large history, which is why the result is
|
|
14
|
+
// cached: the panel polls, and re-parsing tens of MB of transcripts on every poll would make
|
|
15
|
+
// the whole cockpit feel broken.
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const COLLECT = path.join(__dirname, '..', '..', 'scripts', 'metrics', 'collect.mjs');
|
|
21
|
+
|
|
22
|
+
// Transcripts only grow, and nobody needs sub-minute freshness on a spend figure.
|
|
23
|
+
const CACHE_MS = 60_000;
|
|
24
|
+
const cache = new Map(); // projectRoot → { at, value }
|
|
25
|
+
|
|
26
|
+
function usage({ projectRoot, days = null, force = false }) {
|
|
27
|
+
const key = `${projectRoot}|${days || ''}`;
|
|
28
|
+
const hit = cache.get(key);
|
|
29
|
+
if (!force && hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
|
30
|
+
|
|
31
|
+
const args = [COLLECT, projectRoot, '--json'];
|
|
32
|
+
if (days) args.push(`--days=${days}`);
|
|
33
|
+
|
|
34
|
+
let value;
|
|
35
|
+
try {
|
|
36
|
+
const r = spawnSync(process.execPath, args, {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
// A pathological history must not wedge the cockpit's event loop forever.
|
|
39
|
+
timeout: 60_000,
|
|
40
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
if (r.status !== 0 || !r.stdout) {
|
|
43
|
+
value = { present: false, error: (r.stderr || '').trim().split('\n').slice(-1)[0] || 'collector failed' };
|
|
44
|
+
} else {
|
|
45
|
+
const parsed = JSON.parse(r.stdout);
|
|
46
|
+
// No transcripts for this project is a normal state (a fresh checkout, or a project
|
|
47
|
+
// driven from another machine), not an error — say so rather than rendering zeros
|
|
48
|
+
// that look like "this pipeline is free".
|
|
49
|
+
value = parsed.totals && parsed.totals.sessions
|
|
50
|
+
? { present: true, ...parsed }
|
|
51
|
+
: { present: false, error: 'no Claude Code transcripts found for this project' };
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
value = { present: false, error: String((e && e.message) || e) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cache.set(key, { at: Date.now(), value });
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { usage };
|
package/install.ps1
CHANGED
|
@@ -151,6 +151,7 @@ try {
|
|
|
151
151
|
Copy-Item (Join-Path $src 'scripts\kanban-move.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
152
152
|
Copy-Item (Join-Path $src 'scripts\telemetry-send.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
153
153
|
Copy-Item (Join-Path $src 'scripts\preflight.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
154
|
+
Copy-Item (Join-Path $src 'scripts\loop.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
154
155
|
Copy-Item (Join-Path $src 'core\agents\implementer.template.md') (Join-Path $dest 'pipeline') -Force
|
|
155
156
|
if (Test-Path (Join-Path $src 'CHANGELOG.md')) { Copy-Item (Join-Path $src 'CHANGELOG.md') (Join-Path $dest 'pipeline') -Force }
|
|
156
157
|
[System.IO.File]::WriteAllText((Join-Path $dest 'pipeline\VERSION'), "$ver`n", [System.Text.UTF8Encoding]::new($false))
|
|
@@ -188,8 +189,10 @@ try {
|
|
|
188
189
|
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'agents') | Out-Null
|
|
189
190
|
Copy-Item (Join-Path $src 'core\agents\review.md'),
|
|
190
191
|
(Join-Path $src 'core\agents\release.md'),
|
|
191
|
-
(Join-Path $src 'core\agents\smoke.md'),
|
|
192
192
|
(Join-Path $src 'core\agents\profile-reader.md') (Join-Path $dest 'agents') -Force
|
|
193
|
+
# 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent.
|
|
194
|
+
Remove-Item -LiteralPath (Join-Path $dest 'agents\smoke.md') -Force -ErrorAction SilentlyContinue
|
|
195
|
+
Remove-Item -LiteralPath (Join-Path $dest 'commands\smoke.md') -Force -ErrorAction SilentlyContinue
|
|
193
196
|
# 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
|
|
194
197
|
# copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
|
|
195
198
|
Remove-Item -LiteralPath (Join-Path $dest 'agents\questionnaire-researcher.md') -Force -ErrorAction SilentlyContinue
|
package/install.sh
CHANGED
|
@@ -107,8 +107,9 @@ copy_core() {
|
|
|
107
107
|
cp "$src/scripts/kanban-move.sh" "$dest/pipeline/scripts/"
|
|
108
108
|
cp "$src/scripts/telemetry-send.sh" "$dest/pipeline/scripts/"
|
|
109
109
|
cp "$src/scripts/preflight.sh" "$dest/pipeline/scripts/"
|
|
110
|
+
cp "$src/scripts/loop.sh" "$dest/pipeline/scripts/"
|
|
110
111
|
chmod +x "$dest/pipeline/scripts/kanban-move.sh" "$dest/pipeline/scripts/telemetry-send.sh" \
|
|
111
|
-
"$dest/pipeline/scripts/preflight.sh" 2>/dev/null || true
|
|
112
|
+
"$dest/pipeline/scripts/preflight.sh" "$dest/pipeline/scripts/loop.sh" 2>/dev/null || true
|
|
112
113
|
cp "$src/core/agents/implementer.template.md" "$dest/pipeline/"
|
|
113
114
|
[ -f "$src/CHANGELOG.md" ] && cp "$src/CHANGELOG.md" "$dest/pipeline/"
|
|
114
115
|
printf '%s\n' "$ver" > "$dest/pipeline/VERSION"
|
|
@@ -149,8 +150,10 @@ PY
|
|
|
149
150
|
copy_fixed_agents() {
|
|
150
151
|
mkdir -p "$dest/agents"
|
|
151
152
|
cp "$src/core/agents/review.md" "$src/core/agents/release.md" \
|
|
152
|
-
"$src/core/agents/
|
|
153
|
+
"$src/core/agents/profile-reader.md" \
|
|
153
154
|
"$dest/agents/"
|
|
155
|
+
# 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent.
|
|
156
|
+
rm -f "$dest/agents/smoke.md" "$dest/commands/smoke.md"
|
|
154
157
|
# 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
|
|
155
158
|
# copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
|
|
156
159
|
rm -f "$dest/agents/questionnaire-researcher.md"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cohorte",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cohorte": "bin/cli.js"
|
|
@@ -106,7 +106,7 @@ commands:
|
|
|
106
106
|
format: pnpm format
|
|
107
107
|
typecheck: pnpm check-types
|
|
108
108
|
test: pnpm test
|
|
109
|
-
test_quiet: pnpm test --reporter=dot # bridled variant — what /review
|
|
109
|
+
test_quiet: pnpm test --reporter=dot # bridled variant — what the /review preflight runs
|
|
110
110
|
# migration commands — omit / leave "" if the project has no DB migrations
|
|
111
111
|
migrate: "cd apps/api && node ace migration:run"
|
|
112
112
|
make_migration: "cd apps/api && node ace make:migration"
|
|
@@ -164,12 +164,12 @@ gate:
|
|
|
164
164
|
- "git rebase"
|
|
165
165
|
- "git reset"
|
|
166
166
|
- "docker compose"
|
|
167
|
-
# Phase gate: review
|
|
167
|
+
# Phase gate: review dispatches require a fresh `.claude/preflight.ok` stamp,
|
|
168
168
|
# written by pipeline/scripts/preflight.sh when typecheck+lint+tests are green —
|
|
169
169
|
# gate.py "ask"s the dispatch when the stamp is missing, stale, or HEAD moved.
|
|
170
170
|
preflight:
|
|
171
171
|
enabled: true
|
|
172
|
-
agents: [review
|
|
172
|
+
agents: [review] # subagent_types the stamp gates
|
|
173
173
|
max_age_minutes: 30
|
|
174
174
|
|
|
175
175
|
```
|
package/profile/SCHEMA.md
CHANGED
|
@@ -34,7 +34,7 @@ generic pipeline uses it, so a stateless agent can read/regenerate the profile c
|
|
|
34
34
|
| `contract.path` `.ext` `.index` | string | build | Where `<feature_id>` contract is authored + barrel. |
|
|
35
35
|
| `contract.authored_by` | const `lead` | build | Implementers import it read-only, never edit. |
|
|
36
36
|
| `commands.*` | string | all | Repo-wide install/dev/lint/format/typecheck/test + migrate. |
|
|
37
|
-
| `commands.test_quiet` `.lint_quiet` | string | review,
|
|
37
|
+
| `commands.test_quiet` `.lint_quiet` | string | review, audit, workflows | Repo-wide bridled variants — what the `/review` pre-flight runs. Same fallback as the per-surface ones. |
|
|
38
38
|
| `rbac.enabled` | bool | brainstorm, review | Toggle RBAC personas + authz audit. |
|
|
39
39
|
| `rbac.hierarchy` | list | review | Highest→lowest role list. |
|
|
40
40
|
| `design.enabled` | bool | build, frontend, align-ds | `false` ⇒ design steps are no-ops. |
|
|
@@ -52,8 +52,8 @@ generic pipeline uses it, so a stateless agent can read/regenerate the profile c
|
|
|
52
52
|
| `gate.ask[]` | list | hooks/gate.py, settings | Command substrings that require confirm, on any branch. |
|
|
53
53
|
| `gate.ask_on_default_branch[]` | list | hooks/gate.py | Confirm ONLY on `default_branch`; free on feature branches. |
|
|
54
54
|
| `gate.default_branch` | string | hooks/gate.py | Protected branch (default `main`); gate resolves via git. |
|
|
55
|
-
| `gate.preflight.enabled` | bool | hooks/gate.py, review
|
|
56
|
-
| `gate.preflight.agents[]` | list | hooks/gate.py | `subagent_type`s the stamp gates (default `[review
|
|
55
|
+
| `gate.preflight.enabled` | bool | hooks/gate.py, review | Phase gate: review dispatches need a fresh preflight stamp. See §Preflight. |
|
|
56
|
+
| `gate.preflight.agents[]` | list | hooks/gate.py | `subagent_type`s the stamp gates (default `[review]`). |
|
|
57
57
|
| `gate.preflight.max_age_minutes` | number | hooks/gate.py | Stamp freshness window (default 30). |
|
|
58
58
|
|
|
59
59
|
## Prose sections
|
|
@@ -161,10 +161,10 @@ the frozen contract as the only cross-surface channel**. So specialization means
|
|
|
161
161
|
Coarse first, specialize on evidence: start with one `frontend` / `backend` surface each; split only a
|
|
162
162
|
surface that's proven slow and cleanly separable. The evidence lives in
|
|
163
163
|
the **main checkout's** `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
|
|
164
|
-
(`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review
|
|
165
|
-
and `/
|
|
164
|
+
(`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`
|
|
165
|
+
and `/fix`.
|
|
166
166
|
**`surfaces` keys are surface keys, nothing else** — run-level facts go in their own top-level
|
|
167
|
-
fields
|
|
167
|
+
fields. Anything put inside `surfaces` is read
|
|
168
168
|
as a surface: the dashboard renders it as a row in the per-surface table and scores a non-`ok`
|
|
169
169
|
value as that surface failing. Always the main checkout, never the feature worktree (which dies at teardown while
|
|
170
170
|
metrics must accumulate across features) — resolve from anywhere with
|
|
@@ -188,7 +188,7 @@ to log it. For what's EXPENSIVE, use Claude Code's own accounting:
|
|
|
188
188
|
per-subagent attribution needs traces (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, beta).
|
|
189
189
|
|
|
190
190
|
**Lead context discipline — the silent bill.** The lead session's conversation history is re-sent as
|
|
191
|
-
input on EVERY turn; a session that spans spec→build→
|
|
191
|
+
input on EVERY turn; a session that spans spec→build→review→fix without clearing re-pays the
|
|
192
192
|
accumulated spec walk-through, handoffs, and reports on each turn. The pipeline is built so this is
|
|
193
193
|
never necessary: every phase handoff (spec, contract, diff, staged reports) lives on disk, so `/clear`
|
|
194
194
|
at each phase boundary is always safe — each command's closing line recommends it. Corollaries the
|
|
@@ -219,7 +219,7 @@ storing a bare `pnpm test` as the thing agents execute; `/update-pipeline` tops
|
|
|
219
219
|
|
|
220
220
|
## Preflight — the deterministic phase gate
|
|
221
221
|
|
|
222
|
-
`/review`
|
|
222
|
+
`/review` starts by running `pipeline/scripts/preflight.sh` — a plain shell script (no
|
|
223
223
|
agent) that executes the profile's mechanical checks in order (typecheck → lint → tests, quiet
|
|
224
224
|
variants) with all output redirected to `specs/reports/<id>.preflight.txt`:
|
|
225
225
|
|
|
@@ -231,7 +231,7 @@ variants) with all output redirected to `specs/reports/<id>.preflight.txt`:
|
|
|
231
231
|
|
|
232
232
|
`hooks/gate.py` enforces the stamp as a **phase gate** (the `preflight` block of `gate-config.json`,
|
|
233
233
|
generated from `gate.preflight`): a Task dispatch of a listed `subagent_type` (default
|
|
234
|
-
`review
|
|
234
|
+
`review`) with a missing/stale stamp — older than `max_age_minutes`, or HEAD moved — gets an
|
|
235
235
|
"ask", so a lead can't accidentally skip the gate but a human can consciously override it. The gate
|
|
236
236
|
hook fires for **every** agent in the session, including subagents spawned by the Workflow runtime
|
|
237
237
|
(they run in `acceptEdits` whatever the session mode — Write/Edit auto-approved — but Bash and Task
|
|
@@ -319,13 +319,12 @@ itself changes in ways `/build` §1.5 can't auto-grow (e.g. package manager or c
|
|
|
319
319
|
|
|
320
320
|
## Workflows — deterministic multi-agent runs (opt-in)
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
Three phases have a **workflow variant** — a deterministic orchestration script the Claude Code
|
|
323
323
|
Workflow runtime executes instead of the lead reasoning out the fan-out turn by turn:
|
|
324
|
-
`<core>/workflows/review.js`, `audit.js`, `refactor.js
|
|
324
|
+
`<core>/workflows/review.js`, `audit.js`, `refactor.js` (installed to `.claude/workflows/` bundled or
|
|
325
325
|
`~/.claude/workflows/` global). The conversational commands (`/review`, `/audit`, `/refactor`)
|
|
326
326
|
**remain the default path and the fallback** — a workflow runs only when the human explicitly asks
|
|
327
|
-
for it ("run the review workflow",
|
|
328
|
-
`cycle.js` and invokes the runtime for them), and requires Claude Code ≥ **2.1.154** with workflows
|
|
327
|
+
for it ("run the review workflow"), and requires Claude Code ≥ **2.1.154** with workflows
|
|
329
328
|
enabled.
|
|
330
329
|
`/doctor` reports which path a session will take. The interactive commands (`/init-pipeline`,
|
|
331
330
|
`/brainstorm`, `/spec`) and the dispatch-only ones (`/build`, `/ship`) have **no** workflow variant on
|
|
@@ -345,8 +344,8 @@ Shared design, all four scripts:
|
|
|
345
344
|
- **A dead agent is never a clean result.** `agent()` resolves to `null` when a subagent dies, and a
|
|
346
345
|
dead *reviewer* returns zero findings — byte-identical to a surface that is genuinely clean. Any
|
|
347
346
|
script that derives a verdict from "how many findings came back" must first subtract the agents
|
|
348
|
-
that never answered: `review.js`
|
|
349
|
-
`SHIP
|
|
347
|
+
that never answered: `review.js` names them in `unreviewedSurfaces` and refuses to score
|
|
348
|
+
`SHIP`. `scripts/test-workflows.mjs`
|
|
350
349
|
pins this — it is the one invariant the structural checks in `validate-core.mjs` cannot see.
|
|
351
350
|
- **`review.js`** — preflight gate (aborts red, zero agents), one `git diff --stat` staged per
|
|
352
351
|
touched surface, one reviewer per surface in parallel, then an **adversarial cross-check** phase
|
|
@@ -356,28 +355,9 @@ Shared design, all four scripts:
|
|
|
356
355
|
- **`refactor.js`** — big domains only (it skips domains with a handful of open items — the
|
|
357
356
|
conversational `/refactor` is cheaper there): `shared` first and alone, then the other domains'
|
|
358
357
|
implementers in parallel, each verified per-domain.
|
|
359
|
-
- **`cycle.js`** — the **full dev cycle** on a frozen spec: contract → parallel build → rounds of
|
|
360
|
-
[preflight → review(+cross-check) (∥ smoke if opted in) → fix on the surfaces with findings], looping until
|
|
361
|
-
**zero open findings (+ a PASS smoke when opted in)** (`maxRounds`, default 5, and the token budget are runaway
|
|
362
|
-
protection, not targets). Since a workflow can't ask anything mid-run, the decisions move to the
|
|
363
|
-
edges: a **readiness gate** aborts up front if the spec isn't frozen (other gaps ride along as
|
|
364
|
-
deferred questions), and everything genuinely human comes back at the END in the result's
|
|
365
|
-
`questions` array — empty when `/brainstorm` + `/spec` did their job. Even a finding that implies
|
|
366
|
-
a **contract change stays inside the loop**: a lead-equivalent agent re-authors spec §5 + the
|
|
367
|
-
contract file (exactly what conversational `/fix` §1 does — implementers still never touch it),
|
|
368
|
-
the consuming surfaces re-dispatch, and the loop continues; the re-authorings are reported in the
|
|
369
|
-
result's `contractChanges` for the human to review in the diff. A clean exit ticks the DoD and
|
|
370
|
-
stamps the freshness gate (when smoke was skipped — the default — the runtime-flows DoD box stays
|
|
371
|
-
unticked and `/ship` flags it); a stopped run appends its open
|
|
372
|
-
findings to the spec's `## Remediation` so a rerun of the cycle — or a conversational `/fix` —
|
|
373
|
-
continues seamlessly. `/ship` itself stays outside on purpose — outward-facing and irreversible,
|
|
374
|
-
it keeps its human confirmation.
|
|
375
|
-
**Corollary — harden the spec:** the more `/brainstorm` + `/spec` pre-answer (edge cases, error
|
|
376
|
-
envelopes, role matrix, design links), the further the cycle runs and the emptier `questions`
|
|
377
|
-
comes back; a vague spec just converts into deferred questions.
|
|
378
358
|
- **No input mid-run.** A workflow runs to completion without questions; anything interactive
|
|
379
|
-
(contract changes, human decisions) belongs to the conversational path
|
|
380
|
-
|
|
359
|
+
(contract changes, human decisions) belongs to the conversational path. The gate hook still
|
|
360
|
+
fires on workflow subagents (see
|
|
381
361
|
§Preflight) — in unattended runs its asks become denies.
|
|
382
362
|
- **Permissions:** `/init-pipeline` and `/update-pipeline` extend the generated `settings.json`
|
|
383
363
|
`allow` list with what workflow agents need (the quiet commands, the shipped
|
|
@@ -429,7 +409,7 @@ card created in the target column if missing.
|
|
|
429
409
|
| `/spec` opens (draft) | `spec` |
|
|
430
410
|
| `/spec` freezes (`status: frozen`) | `ready` |
|
|
431
411
|
| `/build` | `building` |
|
|
432
|
-
| `/review`
|
|
412
|
+
| `/review` | `review` |
|
|
433
413
|
| `/fix` | `fix` |
|
|
434
414
|
| `/ship` starts | `ship` |
|
|
435
415
|
| PR opened (`status: shipped`) | `shipped` (+ `PR #<num>` on the card) |
|
|
@@ -457,7 +437,7 @@ pre-telemetry installs) ask ONE question, once per machine, default **No**, and
|
|
|
457
437
|
`|| true`, so a **missing** script is equally silent: `/doctor` check 1 verifies `pipeline/scripts/`
|
|
458
438
|
is fully populated.
|
|
459
439
|
|
|
460
|
-
**Which commands ping** — the
|
|
440
|
+
**Which commands ping** — the six that make up the feature funnel, and only those. The point is to
|
|
461
441
|
see where features stall, so every stage of `idea → PR` reports and nothing else does:
|
|
462
442
|
|
|
463
443
|
| phase | fired when | `seconds` | `results` |
|
|
@@ -465,16 +445,13 @@ see where features stall, so every stage of `idea → PR` reports and nothing el
|
|
|
465
445
|
| `brainstorm` | the return is staged | `0` | — |
|
|
466
446
|
| `spec` | a freeze lands (Mode A only) | `0` | `frozen` |
|
|
467
447
|
| `build` | after the batch metrics line | wall-clock | `ok,ok` / `error` |
|
|
468
|
-
| `smoke` | after the verdict | wall-clock | `PASS` / `FAIL:<n>` |
|
|
469
448
|
| `review` | after the merged verdict | wall-clock | `<verdict>:<count>` |
|
|
470
449
|
| `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
|
|
471
450
|
| `ship` | the release agent succeeded | `0` | `pr` / `compare` |
|
|
472
451
|
|
|
473
|
-
> Workflow-variant runs (`review.js
|
|
474
|
-
> conversational commands measure wall-clock. `
|
|
475
|
-
>
|
|
476
|
-
> conversational `/fix` does. `results` is a free-text summary field, so both forms are valid — but
|
|
477
|
-
> read the `fix` column knowing which path produced it.
|
|
452
|
+
> Workflow-variant runs (`review.js`) report `seconds: 0` for their phases — only the
|
|
453
|
+
> conversational commands measure wall-clock. `results` is a free-text summary field, so both
|
|
454
|
+
> forms are valid — but read the `fix` column knowing which path produced it.
|
|
478
455
|
|
|
479
456
|
`seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
|
|
480
457
|
funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
|
package/scripts/loop.sh
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# loop.sh — autonomous /build → /review → /fix → /review … loop for ONE feature.
|
|
4
|
+
#
|
|
5
|
+
# loop.sh <feature-id> [--max=N] [--no-build] [--rebuild]
|
|
6
|
+
#
|
|
7
|
+
# THE POINT: every phase runs as a SEPARATE `claude -p` child with its own fresh
|
|
8
|
+
# context. The session that typed /loop never sees the diff, the N review reports
|
|
9
|
+
# or the N contracts — it reads only this script's one-line-per-phase stdout and,
|
|
10
|
+
# at the end, the verdict JSON. Running the loop inside the calling session would
|
|
11
|
+
# accumulate all of it in a history that is re-sent at input price on every turn,
|
|
12
|
+
# which is the exact cost the pipeline's /clear discipline exists to avoid.
|
|
13
|
+
#
|
|
14
|
+
# Contract with the pipeline: /review writes specs/reports/<id>.verdict.json on
|
|
15
|
+
# every run. That file — `blocking` and `fingerprint` — is the ONLY channel
|
|
16
|
+
# between cohorte and this driver. No prose is parsed.
|
|
17
|
+
#
|
|
18
|
+
# Exit codes (three distinct diagnostics, do not collapse them):
|
|
19
|
+
# 0 clean — a review returned blocking == 0
|
|
20
|
+
# 1 ceiling — --max passes used, still blocking (the fix was progressing;
|
|
21
|
+
# re-run with a higher --max)
|
|
22
|
+
# 2 no usable verdict — /review produced nothing, or aborted on a red
|
|
23
|
+
# preflight (typecheck/lint/tests broken; the message says which)
|
|
24
|
+
# 3 non-convergent — two consecutive reviews returned the SAME blocking
|
|
25
|
+
# fingerprint: the fix is treading water, a higher --max will not help
|
|
26
|
+
# 64 usage — bad flag, bad id, missing spec, no `claude` on PATH
|
|
27
|
+
#
|
|
28
|
+
# No /fix runs on the last pass: fixing without a review behind it ships
|
|
29
|
+
# unaudited code. Each fix pass is committed — that commit is the only way back
|
|
30
|
+
# after N autonomous passes.
|
|
31
|
+
|
|
32
|
+
set -uo pipefail
|
|
33
|
+
|
|
34
|
+
usage() {
|
|
35
|
+
cat >&2 <<'EOF'
|
|
36
|
+
usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild]
|
|
37
|
+
|
|
38
|
+
--max=N stop after N review passes (default 5)
|
|
39
|
+
--no-build never build — re-run the /review ⇄ /fix loop on a feature that
|
|
40
|
+
is already built (the common case; the build stamp is ignored)
|
|
41
|
+
--rebuild force a /build even if the stamp says it was already built
|
|
42
|
+
|
|
43
|
+
env CLAUDE_FLAGS flags for every child session
|
|
44
|
+
(default: --permission-mode acceptEdits)
|
|
45
|
+
EOF
|
|
46
|
+
exit 64
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
id=""
|
|
50
|
+
max=5
|
|
51
|
+
build_mode="auto" # auto | never | force
|
|
52
|
+
|
|
53
|
+
for arg in "$@"; do
|
|
54
|
+
case "$arg" in
|
|
55
|
+
--max=*)
|
|
56
|
+
max="${arg#--max=}"
|
|
57
|
+
case "$max" in
|
|
58
|
+
''|*[!0-9]*) echo "loop: --max must be a positive integer (got '${arg#--max=}')" >&2; exit 64 ;;
|
|
59
|
+
esac
|
|
60
|
+
[ "$max" -ge 1 ] || { echo "loop: --max must be >= 1" >&2; exit 64; }
|
|
61
|
+
;;
|
|
62
|
+
--no-build) build_mode="never" ;;
|
|
63
|
+
--rebuild) build_mode="force" ;;
|
|
64
|
+
-h|--help) usage ;;
|
|
65
|
+
-*) echo "loop: unknown flag: $arg" >&2; usage ;;
|
|
66
|
+
*)
|
|
67
|
+
[ -z "$id" ] || { echo "loop: unexpected argument: $arg" >&2; usage; }
|
|
68
|
+
id="$arg"
|
|
69
|
+
;;
|
|
70
|
+
esac
|
|
71
|
+
done
|
|
72
|
+
|
|
73
|
+
[ -n "$id" ] || usage
|
|
74
|
+
# --no-build --rebuild together is a contradiction, not a precedence puzzle.
|
|
75
|
+
case " $* " in
|
|
76
|
+
*" --no-build "*) case " $* " in *" --rebuild "*)
|
|
77
|
+
echo "loop: --no-build and --rebuild are mutually exclusive" >&2; exit 64 ;; esac ;;
|
|
78
|
+
esac
|
|
79
|
+
|
|
80
|
+
command -v claude >/dev/null 2>&1 || {
|
|
81
|
+
echo "loop: no 'claude' on PATH — the loop drives child claude -p sessions" >&2
|
|
82
|
+
exit 64
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
86
|
+
echo "loop: not inside a git checkout" >&2; exit 64; }
|
|
87
|
+
cd "$root" || exit 64
|
|
88
|
+
|
|
89
|
+
spec="specs/$id.md"
|
|
90
|
+
[ -f "$spec" ] || {
|
|
91
|
+
echo "loop: no spec at $spec — run /spec $id first" >&2; exit 64; }
|
|
92
|
+
|
|
93
|
+
reports="specs/reports"
|
|
94
|
+
mkdir -p "$reports"
|
|
95
|
+
verdict="$reports/$id.verdict.json"
|
|
96
|
+
stamp="$reports/$id.built"
|
|
97
|
+
log="$reports/$id.loop.log"
|
|
98
|
+
|
|
99
|
+
: "${CLAUDE_FLAGS:=--permission-mode acceptEdits}"
|
|
100
|
+
|
|
101
|
+
: >"$log"
|
|
102
|
+
{
|
|
103
|
+
printf '# loop %s — max=%s build=%s\n' "$id" "$max" "$build_mode"
|
|
104
|
+
printf '# flags: %s\n' "$CLAUDE_FLAGS"
|
|
105
|
+
} >>"$log"
|
|
106
|
+
|
|
107
|
+
# --- one phase = one throwaway child session ---------------------------------
|
|
108
|
+
# ALL child output is redirected into $log and never surfaces here: if the
|
|
109
|
+
# parent re-imports the children's transcripts, the whole point is lost.
|
|
110
|
+
# $CLAUDE_FLAGS is intentionally unquoted — it is a flag list, not one word.
|
|
111
|
+
run_phase() {
|
|
112
|
+
cmd="$1"
|
|
113
|
+
printf '▶ /%-6s %-24s ' "$cmd" "$id"
|
|
114
|
+
printf '\n\n===== /%s %s =====\n' "$cmd" "$id" >>"$log"
|
|
115
|
+
# shellcheck disable=SC2086
|
|
116
|
+
if claude -p "/$cmd $id" $CLAUDE_FLAGS >>"$log" 2>&1; then
|
|
117
|
+
echo "ok"
|
|
118
|
+
return 0
|
|
119
|
+
fi
|
|
120
|
+
echo "fail"
|
|
121
|
+
return 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Scalar reads on a flat JSON object — no jq dependency (the pipeline ships no
|
|
125
|
+
# runtime deps). Only `blocking` and `fingerprint` are ever read; both are
|
|
126
|
+
# top-level scalars by construction of the verdict contract.
|
|
127
|
+
json_num() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$1" | head -n1; }
|
|
128
|
+
json_str() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n1; }
|
|
129
|
+
|
|
130
|
+
finish() { echo "$2"; exit "$1"; }
|
|
131
|
+
|
|
132
|
+
# --- build -------------------------------------------------------------------
|
|
133
|
+
# The stamp is the driver's own bookkeeping — /build knows nothing about it.
|
|
134
|
+
case "$build_mode" in
|
|
135
|
+
force) do_build=1 ;;
|
|
136
|
+
never) do_build=0 ;;
|
|
137
|
+
auto) [ -f "$stamp" ] && do_build=0 || do_build=1 ;;
|
|
138
|
+
esac
|
|
139
|
+
|
|
140
|
+
if [ "$do_build" -eq 1 ]; then
|
|
141
|
+
run_phase build || finish 2 "✗ /build failed — see $log"
|
|
142
|
+
date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# --- review ⇄ fix ------------------------------------------------------------
|
|
146
|
+
prev_fp=""
|
|
147
|
+
pass=1
|
|
148
|
+
while [ "$pass" -le "$max" ]; do
|
|
149
|
+
# Delete first: a stale verdict from the previous pass read as this pass's
|
|
150
|
+
# answer would end the loop on someone else's numbers.
|
|
151
|
+
rm -f "$verdict"
|
|
152
|
+
run_phase review || true # exit status of the child is not the verdict
|
|
153
|
+
|
|
154
|
+
[ -f "$verdict" ] || finish 2 \
|
|
155
|
+
"✗ /review wrote no verdict (pass $pass) — see $log"
|
|
156
|
+
|
|
157
|
+
if grep -q '"aborted"' "$verdict"; then
|
|
158
|
+
finish 2 "✗ /review aborted on a red preflight — typecheck/lint/tests are broken, see $reports/$id.preflight.txt"
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
blocking="$(json_num "$verdict" blocking)"
|
|
162
|
+
[ -n "$blocking" ] || finish 2 \
|
|
163
|
+
"✗ verdict has no usable 'blocking' count (pass $pass) — see $verdict"
|
|
164
|
+
|
|
165
|
+
[ "$blocking" -eq 0 ] && finish 0 \
|
|
166
|
+
"✓ clean after $pass review pass(es) — no blocking findings"
|
|
167
|
+
|
|
168
|
+
fp="$(json_str "$verdict" fingerprint)"
|
|
169
|
+
if [ -n "$fp" ] && [ "$fp" = "$prev_fp" ]; then
|
|
170
|
+
finish 3 "✗ non-convergent — the same $blocking blocking finding(s) survived a fix pass; see $verdict"
|
|
171
|
+
fi
|
|
172
|
+
prev_fp="$fp"
|
|
173
|
+
|
|
174
|
+
# Last pass: report and stop. A /fix here would leave unreviewed code behind.
|
|
175
|
+
[ "$pass" -eq "$max" ] && finish 1 \
|
|
176
|
+
"✗ ceiling — $blocking blocking finding(s) after $max pass(es); re-run with a higher --max"
|
|
177
|
+
|
|
178
|
+
run_phase fix || true
|
|
179
|
+
|
|
180
|
+
# Non-fatal by design: nothing to commit is a legitimate outcome (an agent
|
|
181
|
+
# that decided a finding needed no code change). The commit itself is the
|
|
182
|
+
# rollback point for the pass that just ran.
|
|
183
|
+
git add -A >>"$log" 2>&1
|
|
184
|
+
git commit -m "loop($id): fix pass $pass" >>"$log" 2>&1 || true
|
|
185
|
+
|
|
186
|
+
pass=$((pass + 1))
|
|
187
|
+
done
|
|
188
|
+
|
|
189
|
+
finish 1 "✗ ceiling — $max pass(es) exhausted"
|