cohorte 2.1.0 → 2.3.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 (57) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +49 -41
  3. package/bin/cli.js +324 -28
  4. package/core/adapter/render.js +389 -0
  5. package/core/agents/implementer.template.md +3 -3
  6. package/core/agents/release.md +7 -6
  7. package/core/agents/review.md +17 -2
  8. package/core/commands/cohorte-audit.md +2 -0
  9. package/core/commands/cohorte-brainstorm.md +3 -11
  10. package/core/commands/cohorte-build.md +37 -23
  11. package/core/commands/cohorte-doctor.md +61 -36
  12. package/core/commands/cohorte-fix.md +3 -5
  13. package/core/commands/cohorte-init-pipeline.md +7 -8
  14. package/core/commands/cohorte-patch.md +113 -0
  15. package/core/commands/cohorte-refactor.md +5 -2
  16. package/core/commands/cohorte-review.md +20 -18
  17. package/core/commands/cohorte-ship.md +13 -14
  18. package/core/commands/cohorte-spec.md +4 -13
  19. package/core/commands/cohorte-update-pipeline.md +13 -12
  20. package/core/hooks/gate.py +203 -16
  21. package/core/runtimes/claude.json +73 -0
  22. package/core/runtimes/codex.json +82 -0
  23. package/core/runtimes/cursor.json +75 -0
  24. package/core/runtimes/gemini.json +75 -0
  25. package/core/runtimes/opencode.json +72 -0
  26. package/core/templates/patch.template.md +86 -0
  27. package/core/templates/spec.template.md +1 -3
  28. package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
  29. package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -11
  30. package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
  31. package/core/templates/steps/init-pipeline/05-report.md +1 -1
  32. package/core/workflows/review.js +1 -3
  33. package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
  34. package/dashboard/dist/index.html +1 -1
  35. package/dashboard/server/doctor.js +156 -69
  36. package/dashboard/server/index.js +12 -2
  37. package/dashboard/server/metrics.js +13 -6
  38. package/dashboard/server/runtime.js +115 -0
  39. package/dashboard/server/versions.js +12 -1
  40. package/install.ps1 +26 -3
  41. package/install.sh +28 -6
  42. package/package.json +6 -2
  43. package/profile/PIPELINE.template.md +8 -6
  44. package/profile/SCHEMA.md +70 -108
  45. package/profile/cohorte.config.template.yaml +0 -16
  46. package/scripts/kanban-move.sh +11 -1
  47. package/scripts/metrics/collect.mjs +5 -3
  48. package/scripts/preflight.sh +27 -8
  49. package/scripts/test-adapter.mjs +368 -0
  50. package/scripts/test-dashboard.mjs +70 -0
  51. package/scripts/test-gate.mjs +62 -0
  52. package/scripts/validate-core.mjs +26 -24
  53. package/core/commands/cohorte-loop.md +0 -110
  54. package/scripts/loop-detach.sh +0 -153
  55. package/scripts/loop.sh +0 -399
  56. package/scripts/telemetry-send.sh +0 -77
  57. 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,19 +219,28 @@ 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
- // telemetry-send.sh). Until 1.2.4 this loop took only `.template`, so every
240
+ // preflight.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
163
242
  // every caller chains them with `|| true`, the result was silent: no kanban card
164
- // moves, no telemetry pings, no error. The shell installers named them explicitly
243
+ // moves, no error. The shell installers named them explicitly
165
244
  // and this port drifted. The rule below needs no list to keep in sync: a `<x>.sh`
166
245
  // with an `<x>.sh.template` sibling is rendered per-project by /cohorte-init-pipeline, so
167
246
  // only the template ships; every other `.sh` is a shipped executable.
@@ -176,17 +255,53 @@ 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
+ // 2.3.0 removed telemetry. The copy loop above is by-rule, so it simply stops shipping the
259
+ // sender — but copy-over never deletes, and an existing install would keep an executable
260
+ // that still POSTs to the collector. Scrub it. The dead `telemetry:` block in the user's
261
+ // config is not this installer's to parse: /cohorte-update-pipeline deletes it, where an
262
+ // agent can edit the YAML surgically (SCHEMA.md §Reconcile step 5).
263
+ fs.rmSync(path.join(pipelineDir, 'scripts', 'telemetry-send.sh'), { force: true });
264
+ // The per-surface implementer template is rendered per SURFACE later, by
265
+ // /cohorte-init-pipeline inside the target repo — but its runtime shape (frontmatter keys,
266
+ // capability branches, the preamble) is fixed here, at install time. Run it through the
267
+ // adapter with its <SURFACE_*> placeholders untouched, so init only fills the blanks.
268
+ fs.writeFileSync(path.join(pipelineDir, 'implementer.template.md'), adapter.renderAgent({
269
+ source: fs.readFileSync(path.join(src, 'core', 'agents', 'implementer.template.md'), 'utf8'),
270
+ name: 'implementer.template', runtime, paths, projectRoot: target,
271
+ }).content);
272
+ // What the runtimes ARE, for /cohorte-doctor and anything else that must branch on them at
273
+ // run time. A MAP, merged across installs, not one record: every non-Claude runtime shares
274
+ // the same `.cohorte` core, so a single-record file made each install silently erase the
275
+ // previous one and left /cohorte-doctor diagnosing the wrong agent. Each rendered command
276
+ // already names its own runtime in its preamble; this file supplies the details.
277
+ const registry = path.join(pipelineDir, 'runtimes.json');
278
+ let installed = {};
279
+ try { installed = JSON.parse(fs.readFileSync(registry, 'utf8')) || {}; } catch { /* first install */ }
280
+ installed[runtime.id] = {
281
+ label: runtime.label, scope, core_version: VERSION,
282
+ capabilities: runtime.capabilities,
283
+ excluded_commands: runtime.exclude_commands || [],
284
+ paths: {
285
+ core: paths.core, commands: paths.commands,
286
+ agents: paths.agents || path.join(paths.core, 'agents'),
287
+ // Where the gate registration lives. The dashboard needs it to tell a registered hook
288
+ // from a missing one without re-deriving each runtime's config layout itself.
289
+ hooks_config: paths.hooks_config,
290
+ state: adapter.stateDir(runtime), config: adapter.configPath(runtime),
291
+ },
292
+ };
293
+ fs.writeFileSync(registry, JSON.stringify(installed, null, 2) + '\n');
294
+ fs.rmSync(path.join(pipelineDir, 'runtime.json'), { force: true }); // 2.2.0-dev single-record form
181
295
  // /cohorte-doctor reads this to tell the human what they're missing; the shell installers
182
296
  // have always copied it, this port never did.
183
297
  const changelog = path.join(src, 'CHANGELOG.md');
184
298
  if (fs.existsSync(changelog)) fs.copyFileSync(changelog, path.join(pipelineDir, 'CHANGELOG.md'));
185
299
  fs.writeFileSync(path.join(pipelineDir, 'VERSION'), VERSION + '\n');
186
300
  if (process.platform !== 'win32') {
187
- try { fs.chmodSync(path.join(dest, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
301
+ try { fs.chmodSync(path.join(paths.core, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
188
302
  }
189
303
  scrubTddGate();
304
+ scrubLoop();
190
305
  }
191
306
 
192
307
  // The TDD gate was removed in 0.1.6. Older installs have hooks/tdd_gate.py on disk and
@@ -208,9 +323,14 @@ function scrubTddGate() {
208
323
  }
209
324
  }
210
325
 
211
- // the fixed (non-rendered) agents: the dev review/release pipeline agents
212
- function copyFixedAgents() {
213
- fs.mkdirSync(path.join(dest, 'agents'), { recursive: true });
326
+ // The fixed (non-rendered-per-surface) agents review, release, profile-reader — plus every
327
+ // command. Both go through the adapter, so the same source text lands as a native subagent +
328
+ // slash command on Claude/OpenCode and as a persona file + prompt file everywhere else.
329
+ function renderSurfaces() {
330
+ const agentsOut = paths.agents || path.join(paths.core, 'agents');
331
+ fs.mkdirSync(agentsOut, { recursive: true });
332
+ fs.mkdirSync(paths.commands, { recursive: true });
333
+
214
334
  // Every agent in core/agents/ EXCEPT the *.template.md ones, which /cohorte-init-pipeline renders
215
335
  // per-surface. Until 1.2.6 this was a hardcoded ['review.md', 'release.md'] that never grew
216
336
  // the agents the shell installers copy, so `npx cohorte install` shipped a command with no
@@ -219,8 +339,35 @@ function copyFixedAgents() {
219
339
  const agentDir = path.join(src, 'core', 'agents');
220
340
  for (const f of fs.readdirSync(agentDir)) {
221
341
  if (!f.endsWith('.md') || f.endsWith('.template.md')) continue;
222
- fs.copyFileSync(path.join(agentDir, f), path.join(dest, 'agents', f));
342
+ const out = adapter.renderAgent({
343
+ source: fs.readFileSync(path.join(agentDir, f), 'utf8'),
344
+ name: f.slice(0, -3), runtime, paths, projectRoot: target,
345
+ });
346
+ fs.writeFileSync(path.join(agentsOut, out.filename), out.content);
223
347
  }
348
+
349
+ const cmdDir = path.join(src, 'core', 'commands');
350
+ const excluded = runtime.exclude_commands || [];
351
+ for (const f of fs.readdirSync(cmdDir)) {
352
+ if (!f.endsWith('.md')) continue;
353
+ // A command the runtime cannot actually execute is not installed at all. Shipping it
354
+ // would put a working-looking entry in the slash menu that fails on first use — and the
355
+ // human would reasonably read that as the pipeline being broken.
356
+ if (excluded.includes(f.slice(0, -3))) continue;
357
+ const out = adapter.renderCommand({
358
+ source: fs.readFileSync(path.join(cmdDir, f), 'utf8'),
359
+ name: f.slice(0, -3), runtime, paths, projectRoot: target,
360
+ });
361
+ // A Codex skill is `<name>/SKILL.md`, so the filename can carry a directory.
362
+ const dest = path.join(paths.commands, out.filename);
363
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
364
+ fs.writeFileSync(dest, out.content);
365
+ }
366
+
367
+ // The scrubs below repair Claude-shaped installs that predate the adapter. They are a
368
+ // no-op anywhere else — a fresh non-Claude runtime has no history to clean up — so guard
369
+ // them rather than rm-ing paths that never existed.
370
+ if (runtime.id !== 'claude') return;
224
371
  // 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
225
372
  // copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
226
373
  fs.rmSync(path.join(dest, 'agents', 'questionnaire-researcher.md'), { force: true });
@@ -286,6 +433,59 @@ function setCfg(text, cfgKey, value) {
286
433
  }).join('\n');
287
434
  }
288
435
 
436
+ // Register gate.py as this runtime's blocking pre-command hook. Four of the five can do it —
437
+ // only OpenCode has no hook contract (plugins are a different thing), and there the rendered
438
+ // commands call `gate.py --check` explicitly instead.
439
+ //
440
+ // Three config shapes, all reconciled rather than appended: drop every existing cohorte gate
441
+ // entry, then add exactly one. Idempotent, collapses duplicates an older installer left, and
442
+ // repairs a stale matcher in place — an append-if-absent would find the stale entry and skip.
443
+ function registerRuntimeHook() {
444
+ const spec = runtime.hook;
445
+ const cfgPath = runtime.scopes[scope].hooks_config;
446
+ if (!spec || !cfgPath) return 'n/a (this runtime has no hook contract — the gate runs as an explicit --check)';
447
+ const python = findPython();
448
+ if (!python) return 'skipped (no python found — register the gate hook manually)';
449
+
450
+ const file = path.join(paths.core, 'hooks', 'gate.py');
451
+ // Quoted on every platform — see registerGlobalHook: an unquoted path with a space breaks
452
+ // every tool call in the session, and the config dirs these runtimes use live under the
453
+ // user's home, which on macOS routinely contains one.
454
+ const cmd = `${python} "${file}" --runtime ${runtime.id}`;
455
+ const isOurs = (s) => typeof s === 'string' && s.includes('gate.py');
456
+
457
+ const dest = adapter.expandHome(cfgPath);
458
+ const abs = path.isAbsolute(dest) ? dest : path.join(target, dest);
459
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
460
+ let data = {};
461
+ try {
462
+ const parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
463
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed;
464
+ } catch { /* absent or invalid → start fresh */ }
465
+ if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {};
466
+
467
+ if (spec.format === 'cursor') {
468
+ // { "version": 1, "hooks": { "beforeShellExecution": [ { "command": "…" } ] } }
469
+ if (!data.version) data.version = 1;
470
+ const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
471
+ data.hooks[spec.event] = list.filter((e) => !isOurs(e && e.command));
472
+ data.hooks[spec.event].push({ command: cmd });
473
+ } else {
474
+ // Claude/Codex PreToolUse and Gemini BeforeTool share the matcher-group shape.
475
+ const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
476
+ data.hooks[spec.event] = list.filter(
477
+ (e) => !((e && e.hooks) || []).some((h) => isOurs(h && h.command)));
478
+ data.hooks[spec.event].push({
479
+ matcher: spec.matcher, hooks: [{ type: 'command', command: cmd }],
480
+ });
481
+ }
482
+ fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
483
+ const where = adapter.displayPath(abs, target);
484
+ return spec.supports_ask
485
+ ? `registered in ${where} (${spec.event})`
486
+ : `registered in ${where} (${spec.event}) — no confirmation tier here, so a gated command is denied rather than queried`;
487
+ }
488
+
289
489
  // Fill the seeded config from a short TTY interview (shared Obsidian vault for the kanban mirror).
290
490
  // Kanban is per-project, so it is wired later by /cohorte-init-pipeline — not asked here.
291
491
  async function promptConfig(text) {
@@ -300,11 +500,24 @@ async function promptConfig(text) {
300
500
  // ~/.claude regardless of install scope. Seed it only if the user has no copy (consolidated OR
301
501
  // legacy). On a TTY, offer a quick interview to fill it; otherwise seed disabled defaults.
302
502
  async function seedConfig() {
303
- const cfg = path.join(globalDir, 'cohorte.config.yaml');
503
+ // One config per human, but it has to live somewhere the running agent can find without
504
+ // being told: `~/.claude` for Claude Code (unchanged — existing files stay authoritative),
505
+ // `~/.cohorte` for every other runtime. The shipped scripts probe both, in that order, so
506
+ // a human who drives one repo from two agents still has a single board and a single consent.
507
+ const cfg = adapter.expandHome(adapter.configPath(runtime));
508
+ fs.mkdirSync(path.dirname(cfg), { recursive: true });
304
509
  // Pre-rename names, newest first — read as a fallback so upgrades don't lose the config.
305
510
  const legacy = ['thebidouille.config.yaml']
306
511
  .map((n) => path.join(globalDir, n)).find(fs.existsSync);
307
512
  if (fs.existsSync(cfg)) { console.log(` · kept your existing ${cfg}`); return; }
513
+ // A second runtime must not fork the config: two files means two boards and two consent
514
+ // records, and the human edits whichever one they happen to open. The scripts read
515
+ // `~/.cohorte` first, so seeding it here would SHADOW a filled `~/.claude` copy.
516
+ const claudeCfg = adapter.expandHome(adapter.configPath({ id: 'claude' }));
517
+ if (cfg !== claudeCfg && fs.existsSync(claudeCfg)) {
518
+ console.log(` · reusing your existing ${claudeCfg} (the scripts read it as a fallback)`);
519
+ return;
520
+ }
308
521
  if (legacy) {
309
522
  console.log(` · found legacy ${legacy} — kept as-is (still read as a fallback).`);
310
523
  console.log(' Run /cohorte-update-pipeline to migrate it into cohorte.config.yaml + wire the kanban.');
@@ -354,7 +567,13 @@ function registerGlobalHook() {
354
567
  // once per copy on every Bash call).
355
568
  const isGate = entry => (entry.hooks || []).some(
356
569
  h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith(base));
357
- const cmd = process.platform === 'win32' ? `${python} "${file}"` : `${python} ${file}`;
570
+ // ALWAYS quote the path. The hook command is handed to a shell, so a config dir containing a
571
+ // space — `~/Library/Application Support/…`, which is exactly where a desktop host puts it —
572
+ // splits into two arguments and python reports `can't open file '/Users/x/Library/Application'`
573
+ // on EVERY tool call, in a session the human cannot easily un-break. Only the Windows form was
574
+ // quoted, so this failed silently on precisely the platform where the path is most likely to
575
+ // contain a space.
576
+ const cmd = `${python} "${file}"`;
358
577
 
359
578
  // Reconcile rather than append-if-absent: drop every existing gate.py
360
579
  // registration, then add exactly one. Idempotent, collapses duplicates older
@@ -379,13 +598,90 @@ function bumpPointerVersion(ptr) {
379
598
  }
380
599
  }
381
600
 
601
+ // --- runtime selection -------------------------------------------------------
602
+
603
+ // Which coding agents this install targets. Explicit flags win. Otherwise: detect what is
604
+ // configured on this machine, and — on a TTY — let the human confirm, because installing
605
+ // into a runtime they don't use litters a config dir they never asked us to touch. With no
606
+ // TTY and no flag we install for Claude Code alone: the behaviour of every version before
607
+ // the adapter, so a scripted `npx cohorte install` keeps doing exactly what it did.
608
+ async function selectRuntimes() {
609
+ if (wantRuntimes.length) return wantRuntimes;
610
+ const found = adapter.detectRuntimes();
611
+ if (found.length <= 1) return found.length ? found : ['claude'];
612
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
613
+ console.log(` · several runtimes detected (${found.join(', ')}) — installing for claude only.`);
614
+ console.log(' Pass --runtime=a,b or --all-runtimes to cover the others.');
615
+ return ['claude'];
616
+ }
617
+ console.log('\n Coding agents detected on this machine:');
618
+ found.forEach((id, i) => console.log(` ${i + 1}. ${adapter.loadRuntime(id).label}`));
619
+ const a = await ask(' Install for which? (Enter = all, or e.g. 1,3): ');
620
+ if (!a) return found;
621
+ const picked = a.split(/[,\s]+/).map((n) => found[parseInt(n, 10) - 1]).filter(Boolean);
622
+ return picked.length ? [...new Set(picked)] : found;
623
+ }
624
+
382
625
  // --- run ---------------------------------------------------------------------
383
626
  (async () => {
627
+ const selected = await selectRuntimes();
628
+ for (const id of selected) {
629
+ runtime = adapter.loadRuntime(id);
630
+ paths = adapter.resolvePaths(runtime, scope, target, { overrides: PATH_OVERRIDES });
631
+ dest = runtime.id === 'claude'
632
+ ? (scope === 'global' ? globalDir : path.join(target, '.claude'))
633
+ : paths.core;
634
+ fs.mkdirSync(paths.core, { recursive: true });
635
+ if (selected.length > 1) console.log(`\n── ${runtime.label} ──`);
636
+ await installOne();
637
+ }
638
+ if (selected.length > 1) {
639
+ console.log(`\n✓ installed for ${selected.length} runtimes: ${selected.join(', ')}.`);
640
+ console.log(' The doctrine is identical; what differs is enforcement — run /cohorte-doctor in');
641
+ console.log(' each one to see what it can and cannot guarantee.');
642
+ }
643
+ })();
644
+
645
+ async function installOne() {
646
+ if (runtime.id !== 'claude') {
647
+ // Everything below the Claude branch assumes Claude's own layout (settings.json hook
648
+ // registration, the `.claude` pointer). A non-Claude runtime gets the shared core, the
649
+ // rendered commands/personas, and nothing that would only pretend to be wired.
650
+ copyCore();
651
+ renderSurfaces();
652
+ const hookState = registerRuntimeHook();
653
+ await seedConfig();
654
+ if (mode === 'install' && scope === 'project') {
655
+ fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
656
+ const specTemplate = path.join(target, 'specs', '_template.md');
657
+ if (!fs.existsSync(specTemplate)) {
658
+ fs.copyFileSync(path.join(src, 'core', 'templates', 'spec.template.md'), specTemplate);
659
+ }
660
+ }
661
+ const cmds = adapter.displayPath(paths.commands, target);
662
+ const agentsWhere = paths.agents
663
+ ? `${adapter.displayPath(paths.agents, target)} (${runtime.agent.format})`
664
+ : 'sequential personas — this runtime has no subagents';
665
+ console.log(`
666
+ ✓ ${runtime.label}: core in ${adapter.displayPath(paths.core, target)}, commands in ${cmds} (version ${VERSION})
667
+ invoke: ${runtime.command.invoke.replace('<name>', 'cohorte-init-pipeline')}
668
+ agents: ${agentsWhere}
669
+ gate: ${hookState}`);
670
+ if ((runtime.exclude_commands || []).length) {
671
+ const named = runtime.exclude_commands.map((c) => runtime.command.invoke.replace('<name>', c));
672
+ console.log(` not installed here: ${named.join(', ')}${runtime.exclude_reason ? ` — ${runtime.exclude_reason}` : ''}`);
673
+ }
674
+ if (runtime.scopes[scope].commands_scope === 'global' && scope === 'project') {
675
+ console.log(` note: ${runtime.label} reads commands only from your home dir, so they were`);
676
+ console.log(' installed there even though the core is bundled in this repo.');
677
+ }
678
+ return;
679
+ }
384
680
  if (scope === 'global') {
385
681
  console.log(mode === 'install'
386
682
  ? `→ installing pipeline core GLOBALLY into ${dest}`
387
683
  : `→ updating pipeline core GLOBALLY in ${dest} (keeping global settings.json)`);
388
- copyFixedAgents();
684
+ renderSurfaces();
389
685
  copyCore();
390
686
  // Register on UPDATE too — install.sh and install.ps1 always have, and this
391
687
  // port skipping it is why a duplicated or stale-matcher registration could
@@ -417,7 +713,7 @@ Global kanban config, user-scoped — optional:
417
713
  for you: creating + syncing an Obsidian kanban board of the pipeline in your shared vault.`);
418
714
  } else if (mode === 'install') {
419
715
  console.log(`→ installing pipeline core into ${dest}`);
420
- copyFixedAgents();
716
+ renderSurfaces();
421
717
  copyCore();
422
718
  await seedConfig();
423
719
  fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
@@ -439,11 +735,11 @@ Prefer one shared core across all your repos? Re-run with --global.`);
439
735
  } else {
440
736
  console.log(`→ updating pipeline core in ${dest} (keeping your PIPELINE.md + rendered agents)`);
441
737
  copyCore();
442
- try { copyFixedAgents(); } catch { /* best-effort, as in install.sh */ }
738
+ try { renderSurfaces(); } catch { /* best-effort, as in install.sh */ }
443
739
  await seedConfig();
444
740
  bumpPointerVersion(path.join(dest, 'pipeline.json'));
445
741
  console.log(`
446
742
  ✓ core refreshed to ${VERSION}. Your PIPELINE.md, rendered surface agents, gate-config.json and
447
743
  settings.json were left as-is. Re-run /cohorte-init-pipeline if your stack changed.`);
448
744
  }
449
- })();
745
+ }