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.
Files changed (53) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +41 -32
  3. package/bin/cli.js +316 -26
  4. package/core/adapter/render.js +389 -0
  5. package/core/agents/implementer.template.md +3 -3
  6. package/core/agents/release.md +7 -4
  7. package/core/agents/review.md +10 -2
  8. package/core/commands/cohorte-audit.md +2 -0
  9. package/core/commands/cohorte-brainstorm.md +3 -6
  10. package/core/commands/cohorte-build.md +14 -17
  11. package/core/commands/cohorte-doctor.md +59 -28
  12. package/core/commands/cohorte-fix.md +2 -3
  13. package/core/commands/cohorte-init-pipeline.md +7 -8
  14. package/core/commands/cohorte-refactor.md +5 -2
  15. package/core/commands/cohorte-review.md +20 -16
  16. package/core/commands/cohorte-ship.md +44 -9
  17. package/core/commands/cohorte-spec.md +3 -7
  18. package/core/commands/cohorte-update-pipeline.md +8 -8
  19. package/core/hooks/gate.py +203 -16
  20. package/core/runtimes/claude.json +73 -0
  21. package/core/runtimes/codex.json +82 -0
  22. package/core/runtimes/cursor.json +75 -0
  23. package/core/runtimes/gemini.json +75 -0
  24. package/core/runtimes/opencode.json +72 -0
  25. package/core/templates/spec.template.md +1 -3
  26. package/core/templates/steps/init-pipeline/01-detect-stack.md +7 -3
  27. package/core/templates/steps/init-pipeline/02-interview-gaps.md +8 -2
  28. package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
  29. package/core/templates/steps/init-pipeline/05-report.md +1 -1
  30. package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
  31. package/dashboard/dist/index.html +1 -1
  32. package/dashboard/server/doctor.js +156 -69
  33. package/dashboard/server/index.js +12 -2
  34. package/dashboard/server/metrics.js +13 -6
  35. package/dashboard/server/runtime.js +115 -0
  36. package/dashboard/server/versions.js +12 -1
  37. package/install.ps1 +23 -2
  38. package/install.sh +22 -4
  39. package/package.json +6 -2
  40. package/profile/PIPELINE.template.md +27 -6
  41. package/profile/SCHEMA.md +88 -49
  42. package/scripts/kanban-move.sh +11 -1
  43. package/scripts/metrics/collect.mjs +5 -3
  44. package/scripts/preflight.sh +27 -8
  45. package/scripts/telemetry-send.sh +10 -3
  46. package/scripts/test-adapter.mjs +368 -0
  47. package/scripts/test-dashboard.mjs +70 -0
  48. package/scripts/test-gate.mjs +62 -0
  49. package/scripts/validate-core.mjs +1 -1
  50. package/core/commands/cohorte-loop.md +0 -110
  51. package/scripts/loop-detach.sh +0 -153
  52. package/scripts/loop.sh +0 -399
  53. 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` are written by
23
- // the /cohorte-loop driver they are what makes an interrupted autonomous loop resumable, so a
24
- // dashboard that flagged them as invalid would report the pipeline's own state as a defect.
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
- const agentsDir = path.join(projectRoot, '.claude', 'agents');
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
- const cfg = readJson(path.join(projectRoot, '.claude', 'gate-config.json'));
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', '.claude/gate-config.json missing or unreadable',
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
- 'regenerate .claude/gate-config.json to mirror the gate block');
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. `.claude/preflight.ok` is the one that BREAKS things when
151
- // versioned rather than merely being noise: the stamp names the tree it verified, committing
152
- // it moves HEAD past that, and the committed copy then rides into every fresh clone and
153
- // worktree the phase gate either blocks a clean tree or greens one it never checked.
154
- const LOCAL_ARTIFACTS = [
155
- { path: '.claude/preflight.ok', why: 'the phase-gate stamp — a versioned one breaks the gate in every clone and worktree' },
156
- { path: '.claude/pipeline-metrics.jsonl', why: 'the per-dispatch metrics sink (local, append-only)' },
157
- { path: 'specs/reports/', why: 'the /cohorte-review report buffer (derived, regenerated each run)' },
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 unignored = LOCAL_ARTIFACTS.filter(a => !ignoreCovers(projectRoot, a.path));
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
- `${LOCAL_ARTIFACTS.length} local-only artifact path(s) all gitignored`);
214
+ `${artifacts.length} local-only artifact path(s) all gitignored`);
191
215
  }
192
- const stamp = unignored.find(a => a.path === '.claude/preflight.ok');
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 ? ', then `git rm --cached --ignore-unmatch .claude/preflight.ok`' : ''));
220
+ + (stamp ? `, then \`git rm --cached --ignore-unmatch ${stamp.path}\`` : ''));
197
221
  }
198
222
 
199
- function gateRegs(settingsPath) {
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 pre = data && data.hooks && Array.isArray(data.hooks.PreToolUse) ? data.hooks.PreToolUse : [];
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
- return pre.filter(e => (e.hooks || []).some(
206
- h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith('gate.py')));
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 in EITHER scope serves the project: bundled repos get it from
211
- // /cohorte-init-pipeline in project settings, but on a machine with the global core the
212
- // hook usually lives (correctly, exactly once) in global settings warning
213
- // there would prescribe a re-registration that double-prompts.
214
- const scopes = [
215
- { label: 'project', path: path.join(projectRoot, '.claude', 'settings.json') },
216
- { label: 'global', path: path.join(globalDir, 'settings.json') },
217
- ];
218
- if (installMode === 'global') scopes.reverse();
219
- const found = scopes.map(s => ({ ...s, regs: gateRegs(s.path) })).filter(s => s.regs.length);
220
-
221
- if (!found.length) {
222
- return mk('hooks', 'Gate hook', 'warn', 'gate.py not registered in project or global settings.json',
223
- installMode === 'global'
224
- ? 'npx cohorte install --global (re-registers the hook)'
225
- : '/cohorte-init-pipeline (register the PreToolUse gate hook)');
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
- const total = found.reduce((n, s) => n + s.regs.length, 0);
228
- if (total > 1) {
229
- return mk('hooks', 'Gate hook', 'warn',
230
- `gate.py registered ${total}× (${found.map(s => `${s.regs.length} in ${s.label}`).join(', ')}) — it will double-prompt`,
231
- 'keep exactly one PreToolUse entry (drop the project-level one when the global core is installed)');
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
- const matcher = String(found[0].regs[0].matcher || '');
234
- if (!/\bTask\b/.test(matcher) || !/\bBash\b/.test(matcher)) {
235
- return mk('hooks', 'Gate hook', 'warn',
236
- `registered with matcher "${matcher}" it must cover both Bash (command gating) and Task (preflight phase gate)`,
237
- 'npx cohorte@latest update --global (reconciles the matcher to Bash|Task)');
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
- return mk('hooks', 'Gate hook', 'ok', `registered once, matcher ${matcher} (${found[0].label} settings.json)`);
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
- const dir = installMode === 'bundled'
298
- ? path.join(projectRoot, '.claude', 'workflows')
299
- : path.join(globalDir, 'workflows');
300
- const agentsDir = installMode === 'bundled'
301
- ? path.join(projectRoot, '.claude', 'agents')
302
- : path.join(globalDir, 'agents');
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
- // The loop driver's resume state, when a /cohorte-loop is (or was) running on this spec.
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 (.claude/, PIPELINE.md, and
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 `.claude/pipeline-metrics.jsonl` (one line per phase batch, appended by
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
- const file = path.join(projectRoot, '.claude', 'pipeline-metrics.jsonl');
98
- let raw;
99
- try { raw = fs.readFileSync(file, 'utf8'); }
100
- catch { return { present: false, phases: PHASES, features: [], batches: 0 }; }
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
- const bundled = coreAt(path.join(projectRoot, '.claude'));
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))