cohorte 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/README.md +41 -32
- package/bin/cli.js +316 -26
- package/core/adapter/render.js +389 -0
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +1 -4
- package/core/agents/review.md +10 -2
- package/core/commands/cohorte-audit.md +2 -0
- package/core/commands/cohorte-brainstorm.md +3 -6
- package/core/commands/cohorte-build.md +14 -17
- package/core/commands/cohorte-doctor.md +59 -28
- package/core/commands/cohorte-fix.md +2 -3
- package/core/commands/cohorte-init-pipeline.md +7 -8
- package/core/commands/cohorte-refactor.md +5 -2
- package/core/commands/cohorte-review.md +20 -16
- package/core/commands/cohorte-ship.md +5 -5
- package/core/commands/cohorte-spec.md +3 -7
- package/core/commands/cohorte-update-pipeline.md +8 -8
- package/core/hooks/gate.py +203 -16
- package/core/runtimes/claude.json +73 -0
- package/core/runtimes/codex.json +82 -0
- package/core/runtimes/cursor.json +75 -0
- package/core/runtimes/gemini.json +75 -0
- package/core/runtimes/opencode.json +72 -0
- package/core/templates/spec.template.md +1 -3
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +2 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
- package/core/templates/steps/init-pipeline/05-report.md +1 -1
- package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +156 -69
- package/dashboard/server/index.js +12 -2
- package/dashboard/server/metrics.js +13 -6
- package/dashboard/server/runtime.js +115 -0
- package/dashboard/server/versions.js +12 -1
- package/install.ps1 +23 -2
- package/install.sh +22 -4
- package/package.json +6 -2
- package/profile/PIPELINE.template.md +7 -6
- package/profile/SCHEMA.md +45 -48
- package/scripts/kanban-move.sh +11 -1
- package/scripts/metrics/collect.mjs +5 -3
- package/scripts/preflight.sh +27 -8
- package/scripts/telemetry-send.sh +10 -3
- package/scripts/test-adapter.mjs +368 -0
- package/scripts/test-dashboard.mjs +70 -0
- package/scripts/test-gate.mjs +62 -0
- package/scripts/validate-core.mjs +1 -1
- package/core/commands/cohorte-loop.md +0 -110
- package/scripts/loop-detach.sh +0 -153
- package/scripts/loop.sh +0 -399
- package/scripts/test-loop.mjs +0 -330
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
|
-
|
|
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
|
-
// `
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
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(
|
|
155
|
-
const pipelineDir = path.join(
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
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
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|