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
package/bin/cli.js CHANGED
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const { spawnSync } = require('child_process');
17
+ const adapter = require('../core/adapter/render.js');
17
18
 
18
19
  const pkgRoot = path.resolve(__dirname, '..');
19
20
  const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
@@ -32,8 +33,8 @@ function usage(code) {
32
33
  console.log(`cohorte v${VERSION}
33
34
 
34
35
  Usage:
35
- cohorte install [target] [--global]
36
- cohorte update [target] [--global]
36
+ cohorte install [target] [--global] [--runtime=a,b | --all-runtimes]
37
+ cohorte update [target] [--global] [--runtime=a,b | --all-runtimes]
37
38
  cohorte dashboard [target] [--port=N] [--host=ADDR] [--open]
38
39
  cohorte metrics [target] [--days=N] [--since=ISO] [--runs] [--json]
39
40
  cohorte version
@@ -55,7 +56,15 @@ Commands:
55
56
  ~/.claude/projects — nothing to enable, and it covers runs that
56
57
  already happened. Worktree-aware, so a feature adds up. --json for
57
58
  the raw rollup, --runs to include every individual invocation.
58
- version Print the installed CLI version.`);
59
+ version Print the installed CLI version.
60
+
61
+ Runtimes (--runtime=): ${adapter.listRuntimes().join(', ')}
62
+ The pipeline's doctrine is one set of source prompts; the installer renders them into
63
+ whatever each coding agent reads (markdown + frontmatter, plain markdown, or TOML) and
64
+ branches the text on what that agent can actually enforce — where there is no blocking
65
+ hook the gate becomes an explicit check the commands call. Real subagents are required.
66
+ Omit the flag and the installer detects what you have and asks; with no TTY it installs
67
+ for Claude Code alone.`);
59
68
  process.exit(code);
60
69
  }
61
70
 
@@ -77,9 +86,26 @@ const metricsFlags = [];
77
86
  const isMetricsFlag = (a) =>
78
87
  a === '--json' || a === '--runs' || a.startsWith('--days=') || a.startsWith('--since=');
79
88
 
89
+ // Which coding agents to install for. Empty ⇒ resolved later (detect, then ask on a TTY,
90
+ // then fall back to claude — the only behaviour that existed before 2.2.0).
91
+ let wantRuntimes = [];
92
+
80
93
  for (const a of args) {
81
94
  if (a === 'install' || a === 'update' || a === 'dashboard' || a === 'metrics') mode = a;
82
95
  else if (isMetricsFlag(a)) metricsFlags.push(a);
96
+ else if (a === '--all-runtimes') wantRuntimes = adapter.listRuntimes();
97
+ else if (a === '--runtimes' || a === '--runtime') {
98
+ console.error('error: --runtime needs a value, e.g. --runtime=codex,cursor'); process.exit(2);
99
+ }
100
+ else if (a.startsWith('--runtime=') || a.startsWith('--runtimes=')) {
101
+ for (const id of a.slice(a.indexOf('=') + 1).split(',').map((s) => s.trim()).filter(Boolean)) {
102
+ if (!adapter.listRuntimes().includes(id)) {
103
+ console.error(`error: unknown runtime "${id}" (known: ${adapter.listRuntimes().join(', ')})`);
104
+ process.exit(2);
105
+ }
106
+ if (!wantRuntimes.includes(id)) wantRuntimes.push(id);
107
+ }
108
+ }
83
109
  else if (a === 'version' || a === '--version' || a === '-v') { console.log(VERSION); process.exit(0); }
84
110
  else if (a === '--global' || a === '-g') scope = 'global';
85
111
  else if (a.startsWith('--port=')) {
@@ -125,22 +151,66 @@ if (mode === 'metrics') {
125
151
 
126
152
  // --- paths -------------------------------------------------------------------
127
153
  const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
128
- const dest = scope === 'global' ? globalDir : path.join(target, '.claude');
129
154
  const src = pkgRoot;
130
155
 
131
156
  if (!fs.existsSync(path.join(src, 'core'))) {
132
157
  console.error(`error: pipeline source not found (no core/ in ${src})`);
133
158
  process.exit(1);
134
159
  }
135
- fs.mkdirSync(dest, { recursive: true });
160
+
161
+ // Filled per runtime by the install loop. `dest` stays the Claude-shaped root (`.claude` /
162
+ // `~/.claude`) so every helper below — hook registration, the legacy scrubs — keeps working
163
+ // unchanged; non-Claude runtimes put their shared assets under `paths.core` instead.
164
+ // CLAUDE_CONFIG_DIR moves Claude Code's whole tree; the registry declares it as `~/.claude`,
165
+ // so every resolution has to be re-rooted or the install lands half here, half there.
166
+ const PATH_OVERRIDES = { '~/.claude': globalDir };
167
+
168
+ let runtime = adapter.loadRuntime('claude');
169
+ let paths = adapter.resolvePaths(runtime, scope, target, { overrides: PATH_OVERRIDES });
170
+ let dest = scope === 'global' ? globalDir : path.join(target, '.claude');
171
+
172
+ // 2.2.0 retired /cohorte-loop — the autonomous build→review→fix driver. Copy-over never
173
+ // deletes, so on an upgrade its command file and its two scripts would survive as a decoy the
174
+ // model can still fire: a command that spawns headless children against a core that no longer
175
+ // documents them. Scrub the command in every shape this runtime could have installed it, plus
176
+ // the scripts, for every runtime rather than only the Claude layout.
177
+ function scrubLoop() {
178
+ const ext = runtime.command.ext || '.md';
179
+ fs.rmSync(path.join(paths.commands, `cohorte-loop${ext}`), { recursive: true, force: true });
180
+ // Skills are a directory (`<name>/SKILL.md`), so the parent has to go too.
181
+ if (runtime.command.format === 'skill') {
182
+ fs.rmSync(path.join(paths.commands, 'cohorte-loop'), { recursive: true, force: true });
183
+ }
184
+ for (const f of ['loop.sh', 'loop-detach.sh']) {
185
+ fs.rmSync(path.join(paths.core, 'pipeline', 'scripts', f), { force: true });
186
+ }
187
+ }
188
+
189
+ // Resolve every capability conditional in the copied templates, in place. Unlike commands
190
+ // and agents these get no frontmatter and no preamble of their own: a template is always
191
+ // read from within a command that already established the runtime.
192
+ function resolveTemplateConditionals(dir) {
193
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
194
+ const p = path.join(dir, e.name);
195
+ if (e.isDirectory()) { resolveTemplateConditionals(p); continue; }
196
+ if (!p.endsWith('.md')) continue;
197
+ const src = fs.readFileSync(p, 'utf8');
198
+ if (!src.includes('cohorte:if')) continue;
199
+ fs.writeFileSync(p, adapter.applyConditionals(src, runtime));
200
+ }
201
+ }
136
202
 
137
203
  // --- helpers (mirror install.sh) --------------------------------------------
138
204
  function copyCore() {
139
- // `workflows` = the deterministic orchestration scripts (review/audit/refactor) the
140
- // Workflow runtime resolves from .claude/workflows (bundled) or ~/.claude/workflows
141
- // (global) same copy rule in both modes, like commands.
142
- for (const d of ['commands', 'hooks', 'templates', 'workflows']) {
143
- fs.cpSync(path.join(src, 'core', d), path.join(dest, d), {
205
+ // The runtime-neutral assets. `commands` is NOT among them any more: it is rendered per
206
+ // runtime by renderSurfaces() into whatever directory that agent actually reads.
207
+ // `workflows` = the deterministic orchestration scripts (review/audit/refactor), copied
208
+ // only where a Workflow engine exists shipping them elsewhere would advertise a path
209
+ // the rendered commands have already conditionalled out.
210
+ const dirs = ['hooks', 'templates'];
211
+ if (runtime.capabilities.workflows) dirs.push('workflows');
212
+ for (const d of dirs) {
213
+ fs.cpSync(path.join(src, 'core', d), path.join(paths.core, d), {
144
214
  recursive: true,
145
215
  force: true,
146
216
  // Never carry a Python bytecode cache into a user's .claude. It appears in a
@@ -149,14 +219,23 @@ function copyCore() {
149
219
  filter: s => !s.split(/[\\/]/).includes('__pycache__') && !s.endsWith('.pyc'),
150
220
  });
151
221
  }
152
- fs.rmSync(path.join(dest, 'hooks', '__pycache__'), { recursive: true, force: true });
222
+ fs.rmSync(path.join(paths.core, 'hooks', '__pycache__'), { recursive: true, force: true });
223
+ // Templates are read at run time BY a command, so they inherit that command's preamble and
224
+ // its `<core>`/`<state>` tokens — but their capability conditionals are still theirs to
225
+ // resolve, and copying them raw would leave `cohorte:if` markers in the model's context.
226
+ resolveTemplateConditionals(path.join(paths.core, 'templates'));
153
227
  // 0.1.19 renamed questionnaire-domain-brief.md → research-brief.md; drop the stale copy.
154
- fs.rmSync(path.join(dest, 'templates', 'questionnaire-domain-brief.md'), { force: true });
155
- const pipelineDir = path.join(dest, 'pipeline');
228
+ fs.rmSync(path.join(paths.core, 'templates', 'questionnaire-domain-brief.md'), { force: true });
229
+ const pipelineDir = path.join(paths.core, 'pipeline');
156
230
  fs.mkdirSync(path.join(pipelineDir, 'scripts'), { recursive: true });
157
231
  for (const f of ['PIPELINE.template.md', 'SCHEMA.md', 'cohorte.config.template.yaml']) {
158
232
  fs.copyFileSync(path.join(src, 'profile', f), path.join(pipelineDir, f));
159
233
  }
234
+ // SCHEMA.md is the agents' rulebook, read at RUN time from `<core>/pipeline/`, so its
235
+ // capability conditionals have to be resolved here like any other prompt — a `cohorte:if`
236
+ // left in it would reach the model as visible noise, and the wrong branch would tell it to
237
+ // write where this runtime does not look.
238
+ resolveTemplateConditionals(pipelineDir);
160
239
  // Copy the *.template files AND the shipped executables (kanban-move.sh,
161
240
  // telemetry-send.sh). Until 1.2.4 this loop took only `.template`, so every
162
241
  // `npx cohorte install/update` produced a core missing both scripts — and since
@@ -176,17 +255,47 @@ function copyCore() {
176
255
  try { fs.chmodSync(target, 0o755); } catch { /* optional */ }
177
256
  }
178
257
  }
179
- fs.copyFileSync(path.join(src, 'core', 'agents', 'implementer.template.md'),
180
- path.join(pipelineDir, 'implementer.template.md'));
258
+ // The per-surface implementer template is rendered per SURFACE later, by
259
+ // /cohorte-init-pipeline inside the target repo — but its runtime shape (frontmatter keys,
260
+ // capability branches, the preamble) is fixed here, at install time. Run it through the
261
+ // adapter with its <SURFACE_*> placeholders untouched, so init only fills the blanks.
262
+ fs.writeFileSync(path.join(pipelineDir, 'implementer.template.md'), adapter.renderAgent({
263
+ source: fs.readFileSync(path.join(src, 'core', 'agents', 'implementer.template.md'), 'utf8'),
264
+ name: 'implementer.template', runtime, paths, projectRoot: target,
265
+ }).content);
266
+ // What the runtimes ARE, for /cohorte-doctor and anything else that must branch on them at
267
+ // run time. A MAP, merged across installs, not one record: every non-Claude runtime shares
268
+ // the same `.cohorte` core, so a single-record file made each install silently erase the
269
+ // previous one and left /cohorte-doctor diagnosing the wrong agent. Each rendered command
270
+ // already names its own runtime in its preamble; this file supplies the details.
271
+ const registry = path.join(pipelineDir, 'runtimes.json');
272
+ let installed = {};
273
+ try { installed = JSON.parse(fs.readFileSync(registry, 'utf8')) || {}; } catch { /* first install */ }
274
+ installed[runtime.id] = {
275
+ label: runtime.label, scope, core_version: VERSION,
276
+ capabilities: runtime.capabilities,
277
+ excluded_commands: runtime.exclude_commands || [],
278
+ paths: {
279
+ core: paths.core, commands: paths.commands,
280
+ agents: paths.agents || path.join(paths.core, 'agents'),
281
+ // Where the gate registration lives. The dashboard needs it to tell a registered hook
282
+ // from a missing one without re-deriving each runtime's config layout itself.
283
+ hooks_config: paths.hooks_config,
284
+ state: adapter.stateDir(runtime), config: adapter.configPath(runtime),
285
+ },
286
+ };
287
+ fs.writeFileSync(registry, JSON.stringify(installed, null, 2) + '\n');
288
+ fs.rmSync(path.join(pipelineDir, 'runtime.json'), { force: true }); // 2.2.0-dev single-record form
181
289
  // /cohorte-doctor reads this to tell the human what they're missing; the shell installers
182
290
  // have always copied it, this port never did.
183
291
  const changelog = path.join(src, 'CHANGELOG.md');
184
292
  if (fs.existsSync(changelog)) fs.copyFileSync(changelog, path.join(pipelineDir, 'CHANGELOG.md'));
185
293
  fs.writeFileSync(path.join(pipelineDir, 'VERSION'), VERSION + '\n');
186
294
  if (process.platform !== 'win32') {
187
- try { fs.chmodSync(path.join(dest, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
295
+ try { fs.chmodSync(path.join(paths.core, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
188
296
  }
189
297
  scrubTddGate();
298
+ scrubLoop();
190
299
  }
191
300
 
192
301
  // The TDD gate was removed in 0.1.6. Older installs have hooks/tdd_gate.py on disk and
@@ -208,9 +317,14 @@ function scrubTddGate() {
208
317
  }
209
318
  }
210
319
 
211
- // the fixed (non-rendered) agents: the dev review/release pipeline agents
212
- function copyFixedAgents() {
213
- fs.mkdirSync(path.join(dest, 'agents'), { recursive: true });
320
+ // The fixed (non-rendered-per-surface) agents review, release, profile-reader — plus every
321
+ // command. Both go through the adapter, so the same source text lands as a native subagent +
322
+ // slash command on Claude/OpenCode and as a persona file + prompt file everywhere else.
323
+ function renderSurfaces() {
324
+ const agentsOut = paths.agents || path.join(paths.core, 'agents');
325
+ fs.mkdirSync(agentsOut, { recursive: true });
326
+ fs.mkdirSync(paths.commands, { recursive: true });
327
+
214
328
  // Every agent in core/agents/ EXCEPT the *.template.md ones, which /cohorte-init-pipeline renders
215
329
  // per-surface. Until 1.2.6 this was a hardcoded ['review.md', 'release.md'] that never grew
216
330
  // the agents the shell installers copy, so `npx cohorte install` shipped a command with no
@@ -219,8 +333,35 @@ function copyFixedAgents() {
219
333
  const agentDir = path.join(src, 'core', 'agents');
220
334
  for (const f of fs.readdirSync(agentDir)) {
221
335
  if (!f.endsWith('.md') || f.endsWith('.template.md')) continue;
222
- fs.copyFileSync(path.join(agentDir, f), path.join(dest, 'agents', f));
336
+ const out = adapter.renderAgent({
337
+ source: fs.readFileSync(path.join(agentDir, f), 'utf8'),
338
+ name: f.slice(0, -3), runtime, paths, projectRoot: target,
339
+ });
340
+ fs.writeFileSync(path.join(agentsOut, out.filename), out.content);
223
341
  }
342
+
343
+ const cmdDir = path.join(src, 'core', 'commands');
344
+ const excluded = runtime.exclude_commands || [];
345
+ for (const f of fs.readdirSync(cmdDir)) {
346
+ if (!f.endsWith('.md')) continue;
347
+ // A command the runtime cannot actually execute is not installed at all. Shipping it
348
+ // would put a working-looking entry in the slash menu that fails on first use — and the
349
+ // human would reasonably read that as the pipeline being broken.
350
+ if (excluded.includes(f.slice(0, -3))) continue;
351
+ const out = adapter.renderCommand({
352
+ source: fs.readFileSync(path.join(cmdDir, f), 'utf8'),
353
+ name: f.slice(0, -3), runtime, paths, projectRoot: target,
354
+ });
355
+ // A Codex skill is `<name>/SKILL.md`, so the filename can carry a directory.
356
+ const dest = path.join(paths.commands, out.filename);
357
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
358
+ fs.writeFileSync(dest, out.content);
359
+ }
360
+
361
+ // The scrubs below repair Claude-shaped installs that predate the adapter. They are a
362
+ // no-op anywhere else — a fresh non-Claude runtime has no history to clean up — so guard
363
+ // them rather than rm-ing paths that never existed.
364
+ if (runtime.id !== 'claude') return;
224
365
  // 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
225
366
  // copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
226
367
  fs.rmSync(path.join(dest, 'agents', 'questionnaire-researcher.md'), { force: true });
@@ -286,6 +427,59 @@ function setCfg(text, cfgKey, value) {
286
427
  }).join('\n');
287
428
  }
288
429
 
430
+ // Register gate.py as this runtime's blocking pre-command hook. Four of the five can do it —
431
+ // only OpenCode has no hook contract (plugins are a different thing), and there the rendered
432
+ // commands call `gate.py --check` explicitly instead.
433
+ //
434
+ // Three config shapes, all reconciled rather than appended: drop every existing cohorte gate
435
+ // entry, then add exactly one. Idempotent, collapses duplicates an older installer left, and
436
+ // repairs a stale matcher in place — an append-if-absent would find the stale entry and skip.
437
+ function registerRuntimeHook() {
438
+ const spec = runtime.hook;
439
+ const cfgPath = runtime.scopes[scope].hooks_config;
440
+ if (!spec || !cfgPath) return 'n/a (this runtime has no hook contract — the gate runs as an explicit --check)';
441
+ const python = findPython();
442
+ if (!python) return 'skipped (no python found — register the gate hook manually)';
443
+
444
+ const file = path.join(paths.core, 'hooks', 'gate.py');
445
+ // Quoted on every platform — see registerGlobalHook: an unquoted path with a space breaks
446
+ // every tool call in the session, and the config dirs these runtimes use live under the
447
+ // user's home, which on macOS routinely contains one.
448
+ const cmd = `${python} "${file}" --runtime ${runtime.id}`;
449
+ const isOurs = (s) => typeof s === 'string' && s.includes('gate.py');
450
+
451
+ const dest = adapter.expandHome(cfgPath);
452
+ const abs = path.isAbsolute(dest) ? dest : path.join(target, dest);
453
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
454
+ let data = {};
455
+ try {
456
+ const parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
457
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed;
458
+ } catch { /* absent or invalid → start fresh */ }
459
+ if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {};
460
+
461
+ if (spec.format === 'cursor') {
462
+ // { "version": 1, "hooks": { "beforeShellExecution": [ { "command": "…" } ] } }
463
+ if (!data.version) data.version = 1;
464
+ const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
465
+ data.hooks[spec.event] = list.filter((e) => !isOurs(e && e.command));
466
+ data.hooks[spec.event].push({ command: cmd });
467
+ } else {
468
+ // Claude/Codex PreToolUse and Gemini BeforeTool share the matcher-group shape.
469
+ const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
470
+ data.hooks[spec.event] = list.filter(
471
+ (e) => !((e && e.hooks) || []).some((h) => isOurs(h && h.command)));
472
+ data.hooks[spec.event].push({
473
+ matcher: spec.matcher, hooks: [{ type: 'command', command: cmd }],
474
+ });
475
+ }
476
+ fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
477
+ const where = adapter.displayPath(abs, target);
478
+ return spec.supports_ask
479
+ ? `registered in ${where} (${spec.event})`
480
+ : `registered in ${where} (${spec.event}) — no confirmation tier here, so a gated command is denied rather than queried`;
481
+ }
482
+
289
483
  // Fill the seeded config from a short TTY interview (shared Obsidian vault for the kanban mirror).
290
484
  // Kanban is per-project, so it is wired later by /cohorte-init-pipeline — not asked here.
291
485
  async function promptConfig(text) {
@@ -300,11 +494,24 @@ async function promptConfig(text) {
300
494
  // ~/.claude regardless of install scope. Seed it only if the user has no copy (consolidated OR
301
495
  // legacy). On a TTY, offer a quick interview to fill it; otherwise seed disabled defaults.
302
496
  async function seedConfig() {
303
- const cfg = path.join(globalDir, 'cohorte.config.yaml');
497
+ // One config per human, but it has to live somewhere the running agent can find without
498
+ // being told: `~/.claude` for Claude Code (unchanged — existing files stay authoritative),
499
+ // `~/.cohorte` for every other runtime. The shipped scripts probe both, in that order, so
500
+ // a human who drives one repo from two agents still has a single board and a single consent.
501
+ const cfg = adapter.expandHome(adapter.configPath(runtime));
502
+ fs.mkdirSync(path.dirname(cfg), { recursive: true });
304
503
  // Pre-rename names, newest first — read as a fallback so upgrades don't lose the config.
305
504
  const legacy = ['thebidouille.config.yaml']
306
505
  .map((n) => path.join(globalDir, n)).find(fs.existsSync);
307
506
  if (fs.existsSync(cfg)) { console.log(` · kept your existing ${cfg}`); return; }
507
+ // A second runtime must not fork the config: two files means two boards and two consent
508
+ // records, and the human edits whichever one they happen to open. The scripts read
509
+ // `~/.cohorte` first, so seeding it here would SHADOW a filled `~/.claude` copy.
510
+ const claudeCfg = adapter.expandHome(adapter.configPath({ id: 'claude' }));
511
+ if (cfg !== claudeCfg && fs.existsSync(claudeCfg)) {
512
+ console.log(` · reusing your existing ${claudeCfg} (the scripts read it as a fallback)`);
513
+ return;
514
+ }
308
515
  if (legacy) {
309
516
  console.log(` · found legacy ${legacy} — kept as-is (still read as a fallback).`);
310
517
  console.log(' Run /cohorte-update-pipeline to migrate it into cohorte.config.yaml + wire the kanban.');
@@ -354,7 +561,13 @@ function registerGlobalHook() {
354
561
  // once per copy on every Bash call).
355
562
  const isGate = entry => (entry.hooks || []).some(
356
563
  h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith(base));
357
- const cmd = process.platform === 'win32' ? `${python} "${file}"` : `${python} ${file}`;
564
+ // ALWAYS quote the path. The hook command is handed to a shell, so a config dir containing a
565
+ // space — `~/Library/Application Support/…`, which is exactly where a desktop host puts it —
566
+ // splits into two arguments and python reports `can't open file '/Users/x/Library/Application'`
567
+ // on EVERY tool call, in a session the human cannot easily un-break. Only the Windows form was
568
+ // quoted, so this failed silently on precisely the platform where the path is most likely to
569
+ // contain a space.
570
+ const cmd = `${python} "${file}"`;
358
571
 
359
572
  // Reconcile rather than append-if-absent: drop every existing gate.py
360
573
  // registration, then add exactly one. Idempotent, collapses duplicates older
@@ -379,13 +592,90 @@ function bumpPointerVersion(ptr) {
379
592
  }
380
593
  }
381
594
 
595
+ // --- runtime selection -------------------------------------------------------
596
+
597
+ // Which coding agents this install targets. Explicit flags win. Otherwise: detect what is
598
+ // configured on this machine, and — on a TTY — let the human confirm, because installing
599
+ // into a runtime they don't use litters a config dir they never asked us to touch. With no
600
+ // TTY and no flag we install for Claude Code alone: the behaviour of every version before
601
+ // the adapter, so a scripted `npx cohorte install` keeps doing exactly what it did.
602
+ async function selectRuntimes() {
603
+ if (wantRuntimes.length) return wantRuntimes;
604
+ const found = adapter.detectRuntimes();
605
+ if (found.length <= 1) return found.length ? found : ['claude'];
606
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
607
+ console.log(` · several runtimes detected (${found.join(', ')}) — installing for claude only.`);
608
+ console.log(' Pass --runtime=a,b or --all-runtimes to cover the others.');
609
+ return ['claude'];
610
+ }
611
+ console.log('\n Coding agents detected on this machine:');
612
+ found.forEach((id, i) => console.log(` ${i + 1}. ${adapter.loadRuntime(id).label}`));
613
+ const a = await ask(' Install for which? (Enter = all, or e.g. 1,3): ');
614
+ if (!a) return found;
615
+ const picked = a.split(/[,\s]+/).map((n) => found[parseInt(n, 10) - 1]).filter(Boolean);
616
+ return picked.length ? [...new Set(picked)] : found;
617
+ }
618
+
382
619
  // --- run ---------------------------------------------------------------------
383
620
  (async () => {
621
+ const selected = await selectRuntimes();
622
+ for (const id of selected) {
623
+ runtime = adapter.loadRuntime(id);
624
+ paths = adapter.resolvePaths(runtime, scope, target, { overrides: PATH_OVERRIDES });
625
+ dest = runtime.id === 'claude'
626
+ ? (scope === 'global' ? globalDir : path.join(target, '.claude'))
627
+ : paths.core;
628
+ fs.mkdirSync(paths.core, { recursive: true });
629
+ if (selected.length > 1) console.log(`\n── ${runtime.label} ──`);
630
+ await installOne();
631
+ }
632
+ if (selected.length > 1) {
633
+ console.log(`\n✓ installed for ${selected.length} runtimes: ${selected.join(', ')}.`);
634
+ console.log(' The doctrine is identical; what differs is enforcement — run /cohorte-doctor in');
635
+ console.log(' each one to see what it can and cannot guarantee.');
636
+ }
637
+ })();
638
+
639
+ async function installOne() {
640
+ if (runtime.id !== 'claude') {
641
+ // Everything below the Claude branch assumes Claude's own layout (settings.json hook
642
+ // registration, the `.claude` pointer). A non-Claude runtime gets the shared core, the
643
+ // rendered commands/personas, and nothing that would only pretend to be wired.
644
+ copyCore();
645
+ renderSurfaces();
646
+ const hookState = registerRuntimeHook();
647
+ await seedConfig();
648
+ if (mode === 'install' && scope === 'project') {
649
+ fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
650
+ const specTemplate = path.join(target, 'specs', '_template.md');
651
+ if (!fs.existsSync(specTemplate)) {
652
+ fs.copyFileSync(path.join(src, 'core', 'templates', 'spec.template.md'), specTemplate);
653
+ }
654
+ }
655
+ const cmds = adapter.displayPath(paths.commands, target);
656
+ const agentsWhere = paths.agents
657
+ ? `${adapter.displayPath(paths.agents, target)} (${runtime.agent.format})`
658
+ : 'sequential personas — this runtime has no subagents';
659
+ console.log(`
660
+ ✓ ${runtime.label}: core in ${adapter.displayPath(paths.core, target)}, commands in ${cmds} (version ${VERSION})
661
+ invoke: ${runtime.command.invoke.replace('<name>', 'cohorte-init-pipeline')}
662
+ agents: ${agentsWhere}
663
+ gate: ${hookState}`);
664
+ if ((runtime.exclude_commands || []).length) {
665
+ const named = runtime.exclude_commands.map((c) => runtime.command.invoke.replace('<name>', c));
666
+ console.log(` not installed here: ${named.join(', ')}${runtime.exclude_reason ? ` — ${runtime.exclude_reason}` : ''}`);
667
+ }
668
+ if (runtime.scopes[scope].commands_scope === 'global' && scope === 'project') {
669
+ console.log(` note: ${runtime.label} reads commands only from your home dir, so they were`);
670
+ console.log(' installed there even though the core is bundled in this repo.');
671
+ }
672
+ return;
673
+ }
384
674
  if (scope === 'global') {
385
675
  console.log(mode === 'install'
386
676
  ? `→ installing pipeline core GLOBALLY into ${dest}`
387
677
  : `→ updating pipeline core GLOBALLY in ${dest} (keeping global settings.json)`);
388
- copyFixedAgents();
678
+ renderSurfaces();
389
679
  copyCore();
390
680
  // Register on UPDATE too — install.sh and install.ps1 always have, and this
391
681
  // port skipping it is why a duplicated or stale-matcher registration could
@@ -417,7 +707,7 @@ Global kanban config, user-scoped — optional:
417
707
  for you: creating + syncing an Obsidian kanban board of the pipeline in your shared vault.`);
418
708
  } else if (mode === 'install') {
419
709
  console.log(`→ installing pipeline core into ${dest}`);
420
- copyFixedAgents();
710
+ renderSurfaces();
421
711
  copyCore();
422
712
  await seedConfig();
423
713
  fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
@@ -439,11 +729,11 @@ Prefer one shared core across all your repos? Re-run with --global.`);
439
729
  } else {
440
730
  console.log(`→ updating pipeline core in ${dest} (keeping your PIPELINE.md + rendered agents)`);
441
731
  copyCore();
442
- try { copyFixedAgents(); } catch { /* best-effort, as in install.sh */ }
732
+ try { renderSurfaces(); } catch { /* best-effort, as in install.sh */ }
443
733
  await seedConfig();
444
734
  bumpPointerVersion(path.join(dest, 'pipeline.json'));
445
735
  console.log(`
446
736
  ✓ core refreshed to ${VERSION}. Your PIPELINE.md, rendered surface agents, gate-config.json and
447
737
  settings.json were left as-is. Re-run /cohorte-init-pipeline if your stack changed.`);
448
738
  }
449
- })();
739
+ }