cohorte 2.0.2 → 2.2.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 +148 -0
- package/README.md +41 -32
- package/bin/cli.js +316 -26
- package/core/adapter/render.js +389 -0
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +7 -4
- package/core/agents/review.md +10 -2
- package/core/commands/cohorte-audit.md +2 -0
- package/core/commands/cohorte-brainstorm.md +3 -6
- package/core/commands/cohorte-build.md +14 -17
- package/core/commands/cohorte-doctor.md +59 -28
- package/core/commands/cohorte-fix.md +2 -3
- package/core/commands/cohorte-init-pipeline.md +7 -8
- package/core/commands/cohorte-refactor.md +5 -2
- package/core/commands/cohorte-review.md +20 -16
- package/core/commands/cohorte-ship.md +44 -9
- package/core/commands/cohorte-spec.md +3 -7
- package/core/commands/cohorte-update-pipeline.md +8 -8
- package/core/hooks/gate.py +203 -16
- package/core/runtimes/claude.json +73 -0
- package/core/runtimes/codex.json +82 -0
- package/core/runtimes/cursor.json +75 -0
- package/core/runtimes/gemini.json +75 -0
- package/core/runtimes/opencode.json +72 -0
- package/core/templates/spec.template.md +1 -3
- package/core/templates/steps/init-pipeline/01-detect-stack.md +7 -3
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +8 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
- package/core/templates/steps/init-pipeline/05-report.md +1 -1
- package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +156 -69
- package/dashboard/server/index.js +12 -2
- package/dashboard/server/metrics.js +13 -6
- package/dashboard/server/runtime.js +115 -0
- package/dashboard/server/versions.js +12 -1
- package/install.ps1 +23 -2
- package/install.sh +22 -4
- package/package.json +6 -2
- package/profile/PIPELINE.template.md +27 -6
- package/profile/SCHEMA.md +88 -49
- package/scripts/kanban-move.sh +11 -1
- package/scripts/metrics/collect.mjs +5 -3
- package/scripts/preflight.sh +27 -8
- package/scripts/telemetry-send.sh +10 -3
- package/scripts/test-adapter.mjs +368 -0
- package/scripts/test-dashboard.mjs +70 -0
- package/scripts/test-gate.mjs +62 -0
- package/scripts/validate-core.mjs +1 -1
- package/core/commands/cohorte-loop.md +0 -110
- package/scripts/loop-detach.sh +0 -153
- package/scripts/loop.sh +0 -399
- package/scripts/test-loop.mjs +0 -330
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const { layouts, primary, stateDirs } = require('./runtime.js');
|
|
9
10
|
const { parseProfileBlock } = require('./yaml');
|
|
10
11
|
const { versions } = require('./versions');
|
|
11
12
|
|
|
@@ -19,9 +20,9 @@ const FIXED_AGENTS = new Set([
|
|
|
19
20
|
'implementer.template',
|
|
20
21
|
]);
|
|
21
22
|
|
|
22
|
-
// The spec lifecycle (SCHEMA.md §Spec status). `in-progress` and `blocked`
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// The spec lifecycle (SCHEMA.md §Spec status). `in-progress` and `blocked` stay valid after
|
|
24
|
+
// /cohorte-loop was retired in 2.2.0: specs in existing repos carry them, and an external driver
|
|
25
|
+
// still needs somewhere to record "a round is under way" / "a round gave up".
|
|
25
26
|
const VALID_STATUS = ['draft', 'frozen', 'in-progress', 'in-review', 'shipped', 'blocked'];
|
|
26
27
|
|
|
27
28
|
// Artifacts the pipeline itself writes into specs/ that are NOT feature specs and have no
|
|
@@ -42,6 +43,12 @@ const sameSet = (a, b) => {
|
|
|
42
43
|
return true;
|
|
43
44
|
};
|
|
44
45
|
|
|
46
|
+
// Paths are reported to a human looking at a repo: show them relative to it.
|
|
47
|
+
const rel = (projectRoot) => (p) => {
|
|
48
|
+
const r = path.relative(projectRoot, p);
|
|
49
|
+
return r && !r.startsWith('..') ? r : p;
|
|
50
|
+
};
|
|
51
|
+
|
|
45
52
|
function mk(id, label, status, detail, fix) {
|
|
46
53
|
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
|
|
47
54
|
}
|
|
@@ -84,11 +91,14 @@ function checkProfile(profile, hasPipelineMd) {
|
|
|
84
91
|
n ? undefined : 'add at least one surface to §surfaces');
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
function checkAgents(profile, projectRoot) {
|
|
94
|
+
function checkAgents(profile, projectRoot, layout) {
|
|
88
95
|
if (!profile || !(profile.surfaces || []).length) {
|
|
89
96
|
return mk('agents', 'Surfaces ↔ agents', 'skip', 'no surfaces to reconcile');
|
|
90
97
|
}
|
|
91
|
-
|
|
98
|
+
if (!layout) {
|
|
99
|
+
return mk('agents', 'Surfaces ↔ agents', 'skip', 'no core installed — nothing renders agents yet');
|
|
100
|
+
}
|
|
101
|
+
const agentsDir = layout.agents;
|
|
92
102
|
const surfaceAgents = profile.surfaces.map(s => s.agent).filter(Boolean);
|
|
93
103
|
|
|
94
104
|
const missing = surfaceAgents.filter(a => !exists(path.join(agentsDir, `${a}.md`)));
|
|
@@ -109,17 +119,21 @@ function checkAgents(profile, projectRoot) {
|
|
|
109
119
|
'remove the stale agent file, or add its surface to PIPELINE.md');
|
|
110
120
|
}
|
|
111
121
|
return mk('agents', 'Surfaces ↔ agents', 'ok',
|
|
112
|
-
`${surfaceAgents.length} surface agent(s) all rendered, no orphans`);
|
|
122
|
+
`${surfaceAgents.length} surface agent(s) all rendered, no orphans (${layout.label})`);
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
function checkGate(profile, projectRoot) {
|
|
125
|
+
function checkGate(profile, projectRoot, stateDirs) {
|
|
116
126
|
const gate = profile && profile.gate;
|
|
117
127
|
if (!gate || (!gate.deny && !gate.ask && !gate.ask_on_default_branch && !gate.preflight)) {
|
|
118
128
|
return mk('gate', 'Gate config', 'skip', 'no gate block in the profile');
|
|
119
129
|
}
|
|
120
|
-
|
|
130
|
+
// One gate config per PROJECT, wherever the installed runtime keeps it. A repo driven from
|
|
131
|
+
// two agents shares it deliberately — they must not disagree about what is gated.
|
|
132
|
+
const cfgPath = stateDirs.map(d => path.join(d, 'gate-config.json')).find(exists);
|
|
133
|
+
const cfg = cfgPath ? readJson(cfgPath) : null;
|
|
121
134
|
if (!cfg) {
|
|
122
|
-
return mk('gate', 'Gate config', 'bad',
|
|
135
|
+
return mk('gate', 'Gate config', 'bad',
|
|
136
|
+
`gate-config.json missing or unreadable (looked in ${stateDirs.map(rel(projectRoot)).join(', ')})`,
|
|
123
137
|
'/cohorte-init-pipeline (regenerate gate-config.json from the gate block)');
|
|
124
138
|
}
|
|
125
139
|
const drifted = [];
|
|
@@ -139,7 +153,7 @@ function checkGate(profile, projectRoot) {
|
|
|
139
153
|
if (drifted.length) {
|
|
140
154
|
return mk('gate', 'Gate config', 'warn',
|
|
141
155
|
`gate-config.json drifted from PIPELINE.md gate block (${drifted.join(', ')})`,
|
|
142
|
-
|
|
156
|
+
`regenerate ${rel(projectRoot)(cfgPath)} to mirror the gate block`);
|
|
143
157
|
}
|
|
144
158
|
const branchGated = (gate.ask_on_default_branch || []).length;
|
|
145
159
|
return mk('gate', 'Gate config', 'ok',
|
|
@@ -147,15 +161,24 @@ function checkGate(profile, projectRoot) {
|
|
|
147
161
|
(branchGated ? `, ${branchGated} gated on ${gate.default_branch || 'main'}` : '') + ')');
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
// Local-only pipeline artifacts
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
// Local-only pipeline artifacts, named relative to whichever `<state>` dir the installed
|
|
165
|
+
// runtime uses (`.claude` on a Claude install, `.cohorte` on the others). `preflight.ok` is the
|
|
166
|
+
// one that BREAKS things when versioned rather than merely being noise: the stamp names the tree
|
|
167
|
+
// it verified, committing it moves HEAD past that, and the committed copy then rides into every
|
|
168
|
+
// fresh clone and worktree — the phase gate either blocks a clean tree or greens one it never
|
|
169
|
+
// checked.
|
|
170
|
+
function localArtifacts(stateRels) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const d of stateRels) {
|
|
173
|
+
out.push({ path: `${d}/preflight.ok`, stamp: true,
|
|
174
|
+
why: 'the phase-gate stamp — a versioned one breaks the gate in every clone and worktree' });
|
|
175
|
+
out.push({ path: `${d}/pipeline-metrics.jsonl`,
|
|
176
|
+
why: 'the per-dispatch metrics sink (local, append-only)' });
|
|
177
|
+
}
|
|
178
|
+
out.push({ path: 'specs/reports/',
|
|
179
|
+
why: 'the /cohorte-review report buffer (derived, regenerated each run)' });
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
159
182
|
|
|
160
183
|
// Does any .gitignore in play cover `rel`? Deliberately literal — it recognizes the exact
|
|
161
184
|
// path, its basename, a trailing-slash dir prefix and a `*.<ext>` glob, which is every form
|
|
@@ -183,60 +206,110 @@ function ignoreCovers(projectRoot, rel) {
|
|
|
183
206
|
return false;
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
function checkLocalArtifacts(projectRoot) {
|
|
187
|
-
const
|
|
209
|
+
function checkLocalArtifacts(projectRoot, stateRels) {
|
|
210
|
+
const artifacts = localArtifacts(stateRels);
|
|
211
|
+
const unignored = artifacts.filter(a => !ignoreCovers(projectRoot, a.path));
|
|
188
212
|
if (!unignored.length) {
|
|
189
213
|
return mk('artifacts', 'Local artifacts', 'ok',
|
|
190
|
-
`${
|
|
214
|
+
`${artifacts.length} local-only artifact path(s) all gitignored`);
|
|
191
215
|
}
|
|
192
|
-
const stamp = unignored.find(a => a.
|
|
216
|
+
const stamp = unignored.find(a => a.stamp);
|
|
193
217
|
return mk('artifacts', 'Local artifacts', stamp ? 'bad' : 'warn',
|
|
194
218
|
`not gitignored: ${unignored.map(a => a.path).join(', ')} — ${unignored[0].why}`,
|
|
195
219
|
`add ${unignored.map(a => a.path).join(' + ')} to .gitignore`
|
|
196
|
-
+ (stamp ?
|
|
220
|
+
+ (stamp ? `, then \`git rm --cached --ignore-unmatch ${stamp.path}\`` : ''));
|
|
197
221
|
}
|
|
198
222
|
|
|
199
|
-
|
|
223
|
+
// Gate registrations in one config file. Three shapes across the runtimes that host the hook:
|
|
224
|
+
// Claude Code and Codex use the matcher-group form under `PreToolUse`, Gemini the same shape
|
|
225
|
+
// under `BeforeTool`, Cursor a flat `[{command}]` under `beforeShellExecution`. A registration
|
|
226
|
+
// read with the wrong shape looks absent, which is how a healthy install gets reported broken.
|
|
227
|
+
function gateRegs(settingsPath, event) {
|
|
200
228
|
const data = readJson(settingsPath);
|
|
201
|
-
const
|
|
229
|
+
const list = data && data.hooks && Array.isArray(data.hooks[event]) ? data.hooks[event] : [];
|
|
202
230
|
// Trailing-quote tolerant, like the installers since 1.3.2: the Windows form is
|
|
203
231
|
// `py "C:\…\gate.py"` — a bare .endsWith() reports every healthy Windows install
|
|
204
232
|
// as "not registered".
|
|
205
|
-
|
|
206
|
-
|
|
233
|
+
// Strip the trailing `--runtime <id>` BEFORE the quotes: the registered command is
|
|
234
|
+
// `python3 "/path/gate.py" --runtime cursor`, so taking the quotes off first leaves
|
|
235
|
+
// `…gate.py"` and the match fails — a healthy registration read as missing.
|
|
236
|
+
const isOurs = (c) => typeof c === 'string'
|
|
237
|
+
&& c.trim().replace(/\s+--runtime[= ]\S+$/, '').replace(/"+$/, '').endsWith('gate.py');
|
|
238
|
+
return list.filter(e => isOurs(e && e.command)
|
|
239
|
+
|| ((e && e.hooks) || []).some(h => isOurs(h && h.command)));
|
|
207
240
|
}
|
|
208
241
|
|
|
209
|
-
function checkHooks(projectRoot, globalDir, installMode) {
|
|
210
|
-
// A registration
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
242
|
+
function checkHooks(projectRoot, globalDir, installMode, all) {
|
|
243
|
+
// A registration can exist without a discoverable core — a repo whose core lives in a global
|
|
244
|
+
// dir the dashboard was not pointed at, or one predating `runtimes.json`. Falling through to
|
|
245
|
+
// "no runtime installed" there would hide a real double-registration, so assume the Claude
|
|
246
|
+
// layout: before the adapter it was the only one, and it is the only one whose hook can be
|
|
247
|
+
// registered outside its own core dir.
|
|
248
|
+
const known = all.length ? all : [{
|
|
249
|
+
id: 'claude', label: 'Claude Code', scope: installMode === 'bundled' ? 'project' : 'global',
|
|
250
|
+
core: path.join(projectRoot, '.claude'),
|
|
251
|
+
hooksConfig: path.join(projectRoot, '.claude', 'settings.json'),
|
|
252
|
+
capabilities: { subagents: true, hooks: true, workflows: true },
|
|
253
|
+
}];
|
|
254
|
+
const hosts = known.filter(l => l.capabilities && l.capabilities.hooks && l.hooksConfig);
|
|
255
|
+
const advisory = known.filter(l => !(l.capabilities && l.capabilities.hooks));
|
|
256
|
+
|
|
257
|
+
if (!hosts.length) {
|
|
258
|
+
if (advisory.length) {
|
|
259
|
+
// OpenCode extends via plugins, not a blocking hook. The gate still runs — the rendered
|
|
260
|
+
// commands call `gate.py --check` — but it is advisory, and saying ✅ here would claim an
|
|
261
|
+
// enforcement property that genuinely is not there.
|
|
262
|
+
return mk('hooks', 'Gate hook', 'warn',
|
|
263
|
+
`${advisory.map(l => l.label).join(', ')} has no blocking hook — the gate runs as an explicit --check the agent must call`,
|
|
264
|
+
'nothing to fix; run /cohorte-doctor inside that runtime for the full picture');
|
|
265
|
+
}
|
|
266
|
+
return mk('hooks', 'Gate hook', 'warn', 'no runtime installed to register the gate hook against',
|
|
267
|
+
'npx cohorte install (or --global)');
|
|
226
268
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
|
|
270
|
+
const problems = [];
|
|
271
|
+
const okLines = [];
|
|
272
|
+
for (const l of hosts) {
|
|
273
|
+
const event = (l.id === 'cursor') ? 'beforeShellExecution'
|
|
274
|
+
: (l.id === 'gemini') ? 'BeforeTool' : 'PreToolUse';
|
|
275
|
+
// A Claude registration serves the project from EITHER scope: bundled repos get it from
|
|
276
|
+
// /cohorte-init-pipeline in project settings, but on a machine with the global core it
|
|
277
|
+
// usually lives (correctly, exactly once) in global settings — warning there would
|
|
278
|
+
// prescribe a re-registration that double-prompts.
|
|
279
|
+
const paths = l.id === 'claude'
|
|
280
|
+
? (installMode === 'global'
|
|
281
|
+
? [path.join(globalDir, 'settings.json'), path.join(projectRoot, '.claude', 'settings.json')]
|
|
282
|
+
: [path.join(projectRoot, '.claude', 'settings.json'), path.join(globalDir, 'settings.json')])
|
|
283
|
+
: [l.hooksConfig];
|
|
284
|
+
const found = paths.map(p2 => ({ path: p2, regs: gateRegs(p2, event) })).filter(f => f.regs.length);
|
|
285
|
+
if (!found.length) {
|
|
286
|
+
problems.push(`${l.label}: not registered (${event})`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const total = found.reduce((n, f) => n + f.regs.length, 0);
|
|
290
|
+
if (total > 1) {
|
|
291
|
+
problems.push(`${l.label}: registered ${total}× — it will double-prompt`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Only the matcher-group runtimes carry one, and only Claude dispatches subagents through
|
|
295
|
+
// a `Task` tool — a Bash-only matcher there leaves the preflight phase gate dead.
|
|
296
|
+
const matcher = String(found[0].regs[0].matcher || '');
|
|
297
|
+
if (l.id === 'claude' && (!/\bTask\b/.test(matcher) || !/\bBash\b/.test(matcher))) {
|
|
298
|
+
problems.push(`${l.label}: matcher "${matcher}" must cover both Bash and Task`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
okLines.push(`${l.label} (${event}${matcher ? `, ${matcher}` : ''})`);
|
|
232
302
|
}
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
303
|
+
|
|
304
|
+
if (problems.length) {
|
|
305
|
+
// Name the healthy ones too. With several runtimes installed, "Claude Code: not registered"
|
|
306
|
+
// alone reads as if the gate were off everywhere, when four of five may be fine.
|
|
307
|
+
const tail = okLines.length ? ` (ok: ${okLines.join(', ')})` : '';
|
|
308
|
+
return mk('hooks', 'Gate hook', 'warn', problems.join(' · ') + tail,
|
|
309
|
+
'npx cohorte@latest update (re-registers the gate hook for every installed runtime)');
|
|
238
310
|
}
|
|
239
|
-
|
|
311
|
+
const tail = advisory.length ? ` · ${advisory.map(l => l.label).join(', ')}: advisory --check only` : '';
|
|
312
|
+
return mk('hooks', 'Gate hook', 'ok', `registered once for ${okLines.join(', ')}${tail}`);
|
|
240
313
|
}
|
|
241
314
|
|
|
242
315
|
function checkRetrieval(profile, projectRoot) {
|
|
@@ -292,14 +365,17 @@ function checkIsolation(profile, projectRoot) {
|
|
|
292
365
|
// the conversational commands stay the default path, so nothing here is ever 'bad'.
|
|
293
366
|
// Whether the session has workflows ENABLED needs a live Claude session — /cohorte-doctor
|
|
294
367
|
// in-session checks that; here we only check what's on disk.
|
|
295
|
-
function checkWorkflows(projectRoot, globalDir, installMode) {
|
|
368
|
+
function checkWorkflows(projectRoot, globalDir, installMode, all) {
|
|
296
369
|
if (installMode === 'none') return mk('workflows', 'Workflows', 'skip', 'no core installed');
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
370
|
+
// The Workflow engine is Claude Code's; the other runtimes are not shipped the scripts at
|
|
371
|
+
// all, and reporting them as "missing" would flag a deliberate omission as a defect.
|
|
372
|
+
const cc = all.find(l => l.id === 'claude');
|
|
373
|
+
if (!cc) {
|
|
374
|
+
return mk('workflows', 'Workflows', 'skip',
|
|
375
|
+
`not available on ${all.map(l => l.label).join(', ') || 'this runtime'} — conversational commands only`);
|
|
376
|
+
}
|
|
377
|
+
const dir = path.join(cc.core, 'workflows');
|
|
378
|
+
const agentsDir = cc.agents;
|
|
303
379
|
const scripts = ['review.js', 'audit.js', 'refactor.js'];
|
|
304
380
|
const missing = scripts.filter(s => !exists(path.join(dir, s)));
|
|
305
381
|
if (missing.length === scripts.length) {
|
|
@@ -333,7 +409,8 @@ function scanSpecs(projectRoot) {
|
|
|
333
409
|
const body = fm ? fm[1] : '';
|
|
334
410
|
const get = k => { const m = body.match(new RegExp(`^${k}:\\s*(.*)$`, 'm')); return m ? m[1].trim() : null; };
|
|
335
411
|
const status = get('status');
|
|
336
|
-
//
|
|
412
|
+
// Legacy resume state from the retired /cohorte-loop driver. Nothing writes it any more;
|
|
413
|
+
// it is still read so a spec left mid-flight by an older core still shows why on the board.
|
|
337
414
|
const pass = parseInt(get('loop_pass'), 10);
|
|
338
415
|
const phase = get('loop_phase');
|
|
339
416
|
specs.push({
|
|
@@ -371,17 +448,25 @@ async function state({ projectRoot, globalDir, cliVersion }) {
|
|
|
371
448
|
const profile = pipelineMd ? parseProfileBlock(pipelineMd) : null;
|
|
372
449
|
const specs = scanSpecs(projectRoot);
|
|
373
450
|
|
|
451
|
+
// Which coding agent(s) this repo is wired for. Every path-dependent check below reads from
|
|
452
|
+
// this rather than assuming `.claude/` — on a Cursor-only repo that assumption reported the
|
|
453
|
+
// core, the agents, the artifacts and the hook as all broken, and every one of them was fine.
|
|
454
|
+
const all = layouts({ projectRoot, globalDir });
|
|
455
|
+
const main = primary(all);
|
|
456
|
+
const stateAbs = stateDirs(all, projectRoot);
|
|
457
|
+
const stateRels = stateAbs.map(rel(projectRoot));
|
|
458
|
+
|
|
374
459
|
const checks = [
|
|
375
460
|
checkCore(v),
|
|
376
461
|
checkProfile(profile, pipelineMd != null),
|
|
377
|
-
checkAgents(profile, projectRoot),
|
|
378
|
-
checkGate(profile, projectRoot),
|
|
379
|
-
checkLocalArtifacts(projectRoot),
|
|
380
|
-
checkHooks(projectRoot, globalDir, v.installMode),
|
|
462
|
+
checkAgents(profile, projectRoot, main),
|
|
463
|
+
checkGate(profile, projectRoot, stateAbs),
|
|
464
|
+
checkLocalArtifacts(projectRoot, stateRels),
|
|
465
|
+
checkHooks(projectRoot, globalDir, v.installMode, all),
|
|
381
466
|
checkRetrieval(profile, projectRoot),
|
|
382
467
|
checkDesign(profile, projectRoot),
|
|
383
468
|
checkIsolation(profile, projectRoot),
|
|
384
|
-
checkWorkflows(projectRoot, globalDir, v.installMode),
|
|
469
|
+
checkWorkflows(projectRoot, globalDir, v.installMode, all),
|
|
385
470
|
checkSpecs(specs),
|
|
386
471
|
];
|
|
387
472
|
|
|
@@ -390,6 +475,8 @@ async function state({ projectRoot, globalDir, cliVersion }) {
|
|
|
390
475
|
|
|
391
476
|
return {
|
|
392
477
|
project: projectRoot,
|
|
478
|
+
runtimes: all.map(l => ({ id: l.id, label: l.label, scope: l.scope,
|
|
479
|
+
capabilities: l.capabilities, legacy: !!l.legacy })),
|
|
393
480
|
versions: v,
|
|
394
481
|
profile: profile ? {
|
|
395
482
|
name: profile.name,
|
|
@@ -171,7 +171,7 @@ function runClaude(req, res, body) {
|
|
|
171
171
|
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Full project reset: back up the project's pipeline footprint (
|
|
174
|
+
// Full project reset: back up the project's pipeline footprint (every runtime's dir, PIPELINE.md, and
|
|
175
175
|
// optionally specs/) to .claude.bak-<ts>, remove it, then reinstall a fresh BUNDLED core (or,
|
|
176
176
|
// for global-mode projects, leave the shared ~/.claude core untouched). Never touches ~/.claude.
|
|
177
177
|
// Streams progress; ends with __EXIT__ <code>. The profile is regenerated by /cohorte-init-pipeline after.
|
|
@@ -217,6 +217,16 @@ function runReset(req, res, body, { pkgRoot, globalDir }) {
|
|
|
217
217
|
|
|
218
218
|
let moved = 0;
|
|
219
219
|
if (fs.existsSync(claudeDir)) { fs.renameSync(claudeDir, path.join(backup, '.claude')); log(' · moved .claude/'); moved++; }
|
|
220
|
+
// A repo installed for another coding agent keeps its footprint elsewhere. Leaving these
|
|
221
|
+
// behind would make a "full reset" silently partial: the old rendered commands survive and
|
|
222
|
+
// the fresh install lands next to them.
|
|
223
|
+
for (const d of ['.cohorte', '.agents', '.cursor', '.gemini', '.opencode', '.codex']) {
|
|
224
|
+
const from = path.join(project, d);
|
|
225
|
+
if (!fs.existsSync(from)) continue;
|
|
226
|
+
fs.renameSync(from, path.join(backup, d));
|
|
227
|
+
log(` · moved ${d}/`);
|
|
228
|
+
moved++;
|
|
229
|
+
}
|
|
220
230
|
if (fs.existsSync(pipelineMd)) { fs.renameSync(pipelineMd, path.join(backup, 'PIPELINE.md')); log(' · moved PIPELINE.md'); moved++; }
|
|
221
231
|
if (purgeSpecs && fs.existsSync(specsDir)) { fs.renameSync(specsDir, path.join(backup, 'specs')); log(' · moved specs/'); moved++; }
|
|
222
232
|
if (!moved) log(' · nothing to move (no .claude/ or PIPELINE.md found)');
|
|
@@ -320,7 +330,7 @@ function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, versi
|
|
|
320
330
|
if (url === '/api/metrics') {
|
|
321
331
|
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
322
332
|
const root = q ? path.resolve(q) : projectRoot;
|
|
323
|
-
return sendJson(res, 200, metrics({ projectRoot: root }));
|
|
333
|
+
return sendJson(res, 200, metrics({ projectRoot: root, globalDir }));
|
|
324
334
|
}
|
|
325
335
|
if (url === '/api/usage') {
|
|
326
336
|
const q = new URL(req.url, 'http://localhost');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Read a project's
|
|
2
|
+
// Read a project's `pipeline-metrics.jsonl` (one line per phase batch, appended by
|
|
3
3
|
// /cohorte-build, /cohorte-review and /cohorte-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
|
//
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const { layouts, stateDirs } = require('./runtime.js');
|
|
13
14
|
|
|
14
15
|
// `smoke` and `cycle` are RETIRED phases, kept so metrics files written before their removal
|
|
15
16
|
// still render. Without them in this list their per-surface results parse fine but land in no
|
|
@@ -93,11 +94,17 @@ function aggregate(batches) {
|
|
|
93
94
|
return [...byFeature.values()].sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''));
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
function metrics({ projectRoot }) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
function metrics({ projectRoot, globalDir }) {
|
|
98
|
+
// The sink lives in `<state>`, which is `.claude` on a Claude install and `.cohorte` on the
|
|
99
|
+
// others. A repo driven from both has two, and the phases genuinely split across them — read
|
|
100
|
+
// every one rather than silently reporting half the spend.
|
|
101
|
+
const dirs = stateDirs(layouts({ projectRoot, globalDir }), projectRoot);
|
|
102
|
+
let raw = '';
|
|
103
|
+
for (const d of dirs) {
|
|
104
|
+
try { raw += fs.readFileSync(path.join(d, 'pipeline-metrics.jsonl'), 'utf8'); }
|
|
105
|
+
catch { /* absent in this layout */ }
|
|
106
|
+
}
|
|
107
|
+
if (!raw) return { present: false, phases: PHASES, features: [], batches: 0 };
|
|
101
108
|
|
|
102
109
|
const batches = parseBatches(raw);
|
|
103
110
|
return { present: true, phases: PHASES, features: aggregate(batches), batches: batches.length };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Which coding agent(s) this project is wired for, and where each one keeps its files.
|
|
3
|
+
//
|
|
4
|
+
// Before 2.2.0 every path in the dashboard was `.claude/…`. That is now one layout of five:
|
|
5
|
+
// a repo driven from Cursor keeps its core in `.cohorte/cursor/`, its agents in
|
|
6
|
+
// `.cursor/agents/` and its gate registration in `.cursor/hooks.json`. Checking the Claude
|
|
7
|
+
// paths there reports a perfectly healthy install as broken — "no core", "no rendered agent",
|
|
8
|
+
// "gate not registered" — which is worse than not checking at all: it sends a human fixing
|
|
9
|
+
// something that is not wrong.
|
|
10
|
+
//
|
|
11
|
+
// The installer writes `<core>/pipeline/runtimes.json` (a map of every runtime installed
|
|
12
|
+
// against that core), so discovery is a directory probe rather than a guess.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `<state>` — where the pipeline writes what it generates FOR THIS PROJECT (gate config,
|
|
23
|
+
// preflight stamp, metrics). Shared by every non-Claude runtime on purpose: it describes the
|
|
24
|
+
// project, not the agent driving it.
|
|
25
|
+
function stateDirFor(id) {
|
|
26
|
+
return id === 'claude' ? '.claude' : '.cohorte';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Candidate core directories, project scope first — a bundled core wins over a global one,
|
|
30
|
+
// same precedence the commands themselves use.
|
|
31
|
+
function candidateCores(projectRoot, globalDir) {
|
|
32
|
+
const home = os.homedir();
|
|
33
|
+
const out = [
|
|
34
|
+
{ dir: path.join(projectRoot, '.claude'), scope: 'project' },
|
|
35
|
+
{ dir: globalDir || path.join(home, '.claude'), scope: 'global' },
|
|
36
|
+
];
|
|
37
|
+
for (const [root, scope] of [[path.join(projectRoot, '.cohorte'), 'project'],
|
|
38
|
+
[path.join(home, '.cohorte'), 'global']]) {
|
|
39
|
+
let entries = [];
|
|
40
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { /* absent */ }
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (e.isDirectory()) out.push({ dir: path.join(root, e.name), scope });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every runtime installed against this project, most-authoritative first.
|
|
50
|
+
* Each layout resolves the paths a check needs, absolute where they are absolute.
|
|
51
|
+
* Returns [] when nothing is installed — callers report that as the missing core it is.
|
|
52
|
+
*/
|
|
53
|
+
function layouts({ projectRoot, globalDir }) {
|
|
54
|
+
const found = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const { dir, scope } of candidateCores(projectRoot, globalDir)) {
|
|
57
|
+
const registry = readJson(path.join(dir, 'pipeline', 'runtimes.json'));
|
|
58
|
+
// A core with no registry predates the adapter ⇒ it can only be a Claude install.
|
|
59
|
+
const ids = registry ? Object.keys(registry) : (
|
|
60
|
+
fs.existsSync(path.join(dir, 'pipeline', 'VERSION')) ? ['claude'] : []);
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
const key = `${id}@${dir}`;
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
const rec = (registry && registry[id]) || {};
|
|
66
|
+
const p = rec.paths || {};
|
|
67
|
+
const abs = (v, fallback) => {
|
|
68
|
+
const raw = v || fallback;
|
|
69
|
+
if (!raw) return null;
|
|
70
|
+
return path.isAbsolute(raw) ? raw : path.join(projectRoot, raw);
|
|
71
|
+
};
|
|
72
|
+
found.push({
|
|
73
|
+
id,
|
|
74
|
+
label: rec.label || id,
|
|
75
|
+
scope: rec.scope || scope,
|
|
76
|
+
core: dir,
|
|
77
|
+
agents: abs(p.agents, path.join(dir, 'agents')),
|
|
78
|
+
commands: abs(p.commands, path.join(dir, 'commands')),
|
|
79
|
+
state: abs(null, p.state || stateDirFor(id)),
|
|
80
|
+
// Pre-2.2.0 cores carry no registry, so nothing records where the hook is registered.
|
|
81
|
+
// Claude's is `<core>/settings.json` and always has been — without this fallback a
|
|
82
|
+
// legacy install reports its perfectly good registration as missing.
|
|
83
|
+
hooksConfig: (rec.paths && rec.paths.hooks_config)
|
|
84
|
+
? abs(rec.paths.hooks_config)
|
|
85
|
+
: (id === 'claude' ? path.join(dir, 'settings.json') : null),
|
|
86
|
+
capabilities: rec.capabilities || {
|
|
87
|
+
subagents: true, hooks: id === 'claude', workflows: id === 'claude',
|
|
88
|
+
},
|
|
89
|
+
legacy: !registry,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The layout the project-level checks should use. Claude first when present — it is the
|
|
98
|
+
* reference implementation and the layout every pre-2.2.0 repo has — else whatever is
|
|
99
|
+
* installed. Null when nothing is.
|
|
100
|
+
*/
|
|
101
|
+
function primary(all) {
|
|
102
|
+
if (!all.length) return null;
|
|
103
|
+
return all.find((l) => l.id === 'claude') || all[0];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The state dirs actually in play. A repo driven from Claude AND Cursor has both `.claude`
|
|
107
|
+
// and `.cohorte`; a check that looked at only one would miss a stamp or a metrics file.
|
|
108
|
+
function stateDirs(all, projectRoot) {
|
|
109
|
+
const dirs = [];
|
|
110
|
+
for (const l of all) if (!dirs.includes(l.state)) dirs.push(l.state);
|
|
111
|
+
if (!dirs.length) dirs.push(path.join(projectRoot, '.claude'));
|
|
112
|
+
return dirs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { layouts, primary, stateDirs, stateDirFor };
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { spawnSync } = require('child_process');
|
|
8
|
+
const { layouts } = require('./runtime.js');
|
|
8
9
|
|
|
9
10
|
function readTrimmed(file) {
|
|
10
11
|
try { return fs.readFileSync(file, 'utf8').trim(); } catch { return null; }
|
|
@@ -106,10 +107,20 @@ function cmpSemver(a, b) {
|
|
|
106
107
|
|
|
107
108
|
async function versions({ projectRoot, globalDir, cliVersion }) {
|
|
108
109
|
const global = coreAt(globalDir);
|
|
109
|
-
|
|
110
|
+
let bundled = coreAt(path.join(projectRoot, '.claude'));
|
|
110
111
|
const pointer = pointerAt(projectRoot);
|
|
111
112
|
const latest = await latestNpm();
|
|
112
113
|
|
|
114
|
+
// A non-Claude runtime keeps its core in `.cohorte/<id>/`, not `.claude/`. Probing only the
|
|
115
|
+
// Claude path reported a perfectly installed Cursor/Codex/Gemini/OpenCode repo as having no
|
|
116
|
+
// core at all — and every downstream check keys off `installMode`, so one wrong probe turned
|
|
117
|
+
// the whole report red.
|
|
118
|
+
if (!bundled.present) {
|
|
119
|
+
const projectCore = layouts({ projectRoot, globalDir })
|
|
120
|
+
.find(l => l.scope === 'project' && l.core.startsWith(projectRoot));
|
|
121
|
+
if (projectCore) bundled = coreAt(projectCore.core);
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
// The core that actually serves this project: bundled wins if present, else global.
|
|
114
125
|
const effective = bundled.present ? bundled : global.present ? global : null;
|
|
115
126
|
const installedVersion = effective ? effective.version : null;
|
package/install.ps1
CHANGED
|
@@ -103,6 +103,29 @@ try {
|
|
|
103
103
|
throw "pipeline source not found (no core/ in $src)"
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
# --- delegate to the Node CLI --------------------------------------------
|
|
107
|
+
# Since 2.2.0 the commands in core/ are runtime-NEUTRAL sources: they carry capability
|
|
108
|
+
# conditionals (`<!-- cohorte:if subagents -->`) and path tokens (`<core>`, `<state>`)
|
|
109
|
+
# that the adapter resolves per coding agent. Copying them verbatim, as this script used
|
|
110
|
+
# to, would install prompts full of unresolved markers — an install that looks successful
|
|
111
|
+
# and instructs the model with text meant for a different runtime. There is no PowerShell
|
|
112
|
+
# renderer, so hand the whole job to bin/cli.js, the documented route anyway.
|
|
113
|
+
$node = Get-Command node -ErrorAction SilentlyContinue
|
|
114
|
+
if ($node) {
|
|
115
|
+
$cliArgs = @((Join-Path $src 'bin\cli.js'), $(if ($Update) { 'update' } else { 'install' }))
|
|
116
|
+
if ($Global) { $cliArgs += '--global' } else { $cliArgs += $Target }
|
|
117
|
+
& $node.Source @cliArgs
|
|
118
|
+
exit $LASTEXITCODE
|
|
119
|
+
}
|
|
120
|
+
Write-Error @"
|
|
121
|
+
cohorte needs Node >= 18 to install.
|
|
122
|
+
The pipeline's commands are rendered per coding agent (Claude Code, Codex, Cursor,
|
|
123
|
+
Gemini CLI, OpenCode) at install time; there is no PowerShell equivalent of that
|
|
124
|
+
step, and a raw copy would install prompts this runtime cannot follow.
|
|
125
|
+
Install Node, then: npx cohorte install$(if ($Global) { ' --global' })
|
|
126
|
+
"@
|
|
127
|
+
exit 1
|
|
128
|
+
|
|
106
129
|
# --- resolve the destination .claude dir ---------------------------------
|
|
107
130
|
if ($Global) {
|
|
108
131
|
$dest = $env:CLAUDE_CONFIG_DIR
|
|
@@ -151,8 +174,6 @@ try {
|
|
|
151
174
|
Copy-Item (Join-Path $src 'scripts\kanban-move.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
152
175
|
Copy-Item (Join-Path $src 'scripts\telemetry-send.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
153
176
|
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
|
|
155
|
-
Copy-Item (Join-Path $src 'scripts\loop-detach.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
156
177
|
Copy-Item (Join-Path $src 'core\agents\implementer.template.md') (Join-Path $dest 'pipeline') -Force
|
|
157
178
|
if (Test-Path (Join-Path $src 'CHANGELOG.md')) { Copy-Item (Join-Path $src 'CHANGELOG.md') (Join-Path $dest 'pipeline') -Force }
|
|
158
179
|
[System.IO.File]::WriteAllText((Join-Path $dest 'pipeline\VERSION'), "$ver`n", [System.Text.UTF8Encoding]::new($false))
|