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
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ // Behavioural tests for the runtime adapter — core/adapter/render.js + core/runtimes/*.json.
3
+ //
4
+ // The adapter is where a single set of source prompts becomes N runtime-specific ones. Its
5
+ // failure mode is silent and expensive: a dropped conditional ships a Claude-only instruction
6
+ // to a runtime that cannot follow it, a leaked marker turns doctrine into visible noise, and a
7
+ // wrong frontmatter key is read by the model as prose. None of that raises an error anywhere —
8
+ // it just makes the pipeline quietly wrong on four runtimes out of five.
9
+ //
10
+ // node scripts/test-adapter.mjs
11
+
12
+ import { readFileSync, readdirSync, mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createRequire } from "node:module";
18
+
19
+ const root = fileURLToPath(new URL("..", import.meta.url));
20
+ const require = createRequire(import.meta.url);
21
+ const adapter = require(join(root, "core", "adapter", "render.js"));
22
+
23
+ let failures = 0;
24
+ const check = (name, cond, detail = "") => {
25
+ if (cond) console.log(` ✓ ${name}`);
26
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
27
+ };
28
+ const group = (name) => console.log(name);
29
+ const throws = (fn) => { try { fn(); return false; } catch { return true; } };
30
+
31
+ const RUNTIMES = adapter.listRuntimes();
32
+ const tmps = [];
33
+
34
+ // ---------------------------------------------------------------- registry ---
35
+ group("registry — every runtime declares what the renderer reads");
36
+
37
+ check("at least the five supported runtimes ship", RUNTIMES.length >= 5, RUNTIMES.join(","));
38
+ for (const id of RUNTIMES) {
39
+ const rt = adapter.loadRuntime(id);
40
+ const ok = rt.id === id
41
+ && typeof rt.label === "string"
42
+ && rt.scopes && rt.scopes.global && rt.scopes.project
43
+ && rt.command && ["md", "toml", "skill"].includes(rt.command.format)
44
+ && Array.isArray(rt.command.frontmatter)
45
+ && rt.capabilities && typeof rt.capabilities.subagents === "boolean"
46
+ && typeof rt.capabilities.hooks === "boolean"
47
+ && typeof rt.capabilities.workflows === "boolean"
48
+ && typeof rt.capabilities.tool_restriction === "boolean";
49
+ check(`${id}: complete and well-typed`, ok);
50
+ }
51
+ check("unknown runtime is an error, not a silent default",
52
+ throws(() => adapter.loadRuntime("nope")));
53
+
54
+ // A runtime that claims a capability it cannot back is the one mistake the whole design rests
55
+ // on: every `cohorte:if` branch trusts these booleans literally, and an over-claim ships the
56
+ // strict doctrine to a runtime that cannot enforce it. Pinned against the vendor docs — see
57
+ // each runtime's `docs` field; revisit these three lines whenever one of them is re-read.
58
+ const withCap = (c) => RUNTIMES.filter((id) => adapter.loadRuntime(id).capabilities[c]).sort().join();
59
+ check("hooks claimed everywhere but OpenCode (plugins are not a blocking hook)",
60
+ withCap("hooks") === "claude,codex,cursor,gemini", withCap("hooks"));
61
+ check("only Claude Code claims workflows", withCap("workflows") === "claude");
62
+ // Not a capability to branch on: a HARD requirement. The pipeline's isolation guarantee is the
63
+ // subagent boundary, so a runtime without them cannot be supported — and must be refused loudly
64
+ // rather than rendered into a pipeline whose central promise is silently absent.
65
+ check("every target runtime has real subagents",
66
+ withCap("subagents") === RUNTIMES.slice().sort().join(), withCap("subagents"));
67
+ check("a runtime declaring no subagents is refused, not degraded",
68
+ throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: false } })));
69
+ check("…and one that has them passes the same guard",
70
+ !throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: true } })));
71
+
72
+ // A hook runtime must declare how to talk to it, and a no-ask runtime must be flagged: gate.py
73
+ // escalates ask→deny there, and getting this backwards silently lets a gated command run.
74
+ for (const id of RUNTIMES) {
75
+ const rt = adapter.loadRuntime(id);
76
+ if (!rt.capabilities.hooks) { check(`${id}: declares no hook contract`, !rt.hook); continue; }
77
+ check(`${id}: hook contract is complete`, !!rt.hook && !!rt.hook.event
78
+ && ["claude", "cursor", "gemini"].includes(rt.hook.format)
79
+ && typeof rt.hook.supports_ask === "boolean"
80
+ && !!rt.scopes.project.hooks_config);
81
+ }
82
+ check("the ask tier is claimed only where the runtime honours it",
83
+ RUNTIMES.filter((id) => (adapter.loadRuntime(id).hook || {}).supports_ask).sort().join()
84
+ === "claude,cursor");
85
+
86
+ // ------------------------------------------------------------ conditionals ---
87
+ group("conditionals — the branch that survives is the branch that is true");
88
+
89
+ // Synthetic capability sets, not real runtimes: the branch logic must stay correct however the
90
+ // vendors' feature matrix moves, and a unit test of the parser should not depend on which
91
+ // runtimes happen to ship today.
92
+ const rich = { id: "rich", capabilities: { subagents: true, hooks: true, workflows: true, tool_restriction: true } };
93
+ const bare = { id: "bare", capabilities: { subagents: false, hooks: false, workflows: false, tool_restriction: false } };
94
+
95
+ const basic = ["<!-- cohorte:if hooks -->", "H", "<!-- cohorte:else -->", "NOH", "<!-- cohorte:endif -->"].join("\n");
96
+ check("if/else keeps the taken branch", adapter.applyConditionals(basic, rich).trim() === "H");
97
+ check("if/else keeps the else branch", adapter.applyConditionals(basic, bare).trim() === "NOH");
98
+
99
+ const neg = ["<!-- cohorte:if !hooks -->", "ADVISORY", "<!-- cohorte:endif -->"].join("\n");
100
+ check("negation works", adapter.applyConditionals(neg, bare).trim() === "ADVISORY"
101
+ && adapter.applyConditionals(neg, rich).trim() === "");
102
+
103
+ const byId = ["<!-- cohorte:if runtime:claude -->", "CC", "<!-- cohorte:endif -->"].join("\n");
104
+ check("runtime:<id> targets one runtime",
105
+ adapter.applyConditionals(byId, adapter.loadRuntime("claude")).trim() === "CC"
106
+ && adapter.applyConditionals(byId, adapter.loadRuntime("cursor")).trim() === "");
107
+
108
+ const or = ["<!-- cohorte:if hooks workflows -->", "X", "<!-- cohorte:endif -->"].join("\n");
109
+ check("a multi-term condition is an OR", adapter.applyConditionals(or, rich).trim() === "X");
110
+
111
+ const nested = [
112
+ "<!-- cohorte:if subagents -->", "A",
113
+ "<!-- cohorte:if hooks -->", "B", "<!-- cohorte:else -->", "C", "<!-- cohorte:endif -->",
114
+ "<!-- cohorte:endif -->",
115
+ ].join("\n");
116
+ check("nesting resolves inner branches inside a taken outer one",
117
+ adapter.applyConditionals(nested, rich).trim().split("\n").join() === "A,B");
118
+ check("a dropped outer branch drops its inner branches whole",
119
+ adapter.applyConditionals(nested, bare).trim() === "");
120
+
121
+ check("an unknown capability is an error, not a silently-false branch",
122
+ throws(() => adapter.applyConditionals("<!-- cohorte:if telepathy -->\nx\n<!-- cohorte:endif -->", rich)));
123
+ check("an unclosed if is an error", throws(() => adapter.applyConditionals("<!-- cohorte:if hooks -->\nx", rich)));
124
+ check("a stray endif is an error", throws(() => adapter.applyConditionals("<!-- cohorte:endif -->", rich)));
125
+ check("two elses in one if is an error", throws(() => adapter.applyConditionals(
126
+ ["<!-- cohorte:if hooks -->", "<!-- cohorte:else -->", "<!-- cohorte:else -->", "<!-- cohorte:endif -->"].join("\n"), rich)));
127
+
128
+ // Every marker in the real source must be resolvable for EVERY runtime — an unknown term in
129
+ // a command nobody rendered yet would surface as an install-time crash for one runtime only.
130
+ group("source prompts — every marker resolves for every runtime");
131
+ const sources = [
132
+ ...readdirSync(join(root, "core", "commands")).map((f) => ["commands", f]),
133
+ ...readdirSync(join(root, "core", "agents")).map((f) => ["agents", f]),
134
+ ].filter(([, f]) => f.endsWith(".md"));
135
+ for (const id of RUNTIMES) {
136
+ const rt = adapter.loadRuntime(id);
137
+ let bad = null;
138
+ for (const [dir, f] of sources) {
139
+ const src = readFileSync(join(root, "core", dir, f), "utf8");
140
+ try { adapter.applyConditionals(adapter.parseFrontmatter(src).body, rt); }
141
+ catch (e) { bad = `${dir}/${f}: ${e.message}`; break; }
142
+ }
143
+ check(`${id}: all ${sources.length} source files render`, !bad, bad || "");
144
+ }
145
+
146
+ // ------------------------------------------------------------ full install ---
147
+ group("install — what each runtime actually gets on disk");
148
+
149
+ const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
150
+ const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
151
+ tmps.push(home, proj);
152
+ spawnSync("git", ["init", "-q", "."], { cwd: proj });
153
+ const run = spawnSync(process.execPath, [join(root, "bin", "cli.js"), "install",
154
+ `--runtime=${RUNTIMES.join(",")}`], {
155
+ cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: "" }, encoding: "utf8",
156
+ });
157
+ check("the installer exits clean for every runtime at once", run.status === 0,
158
+ (run.stderr || "").slice(0, 400));
159
+
160
+ for (const id of RUNTIMES) {
161
+ const rt = adapter.loadRuntime(id);
162
+ // resolvePaths reads ~ from the real homedir, so re-point it at the sandbox.
163
+ const p = adapter.resolvePaths(rt, "project", proj);
164
+ const fix = (s) => s && s.replace(process.env.HOME, home);
165
+ const cmdDir = fix(p.commands);
166
+ const buildFile = join(cmdDir, `cohorte-build${rt.command.ext}`);
167
+ check(`${id}: commands landed in ${rt.scopes.project.commands}`, existsSync(buildFile));
168
+ if (!existsSync(buildFile)) continue;
169
+ const build = readFileSync(buildFile, "utf8");
170
+
171
+ check(`${id}: no unresolved marker leaked into the output`, !/cohorte:(if|else|endif)/.test(build));
172
+ check(`${id}: the runtime preamble is present`, build.includes(`**Runtime: ${rt.label}.**`));
173
+
174
+ // Parallel dispatch is the doctrine on every runtime now; the sequential-persona fallback was
175
+ // removed in 2.2.0 along with any suggestion that a lead can simulate the boundary by hand.
176
+ check(`${id}: dispatches surfaces in parallel`, build.includes("IN PARALLEL"));
177
+ check(`${id}: no trace of the removed persona fallback`,
178
+ !build.includes("ONE PERSONA AT A TIME") && !build.includes("adopt it verbatim"));
179
+
180
+ // A path the runtime does not have is a path the model will fail to read, silently.
181
+ if (id !== "claude") {
182
+ check(`${id}: no hardcoded .claude path left in the prose`,
183
+ !/(?<![\w/.-])~?\/?\.claude\//.test(build), (build.match(/.{0,60}\.claude\/.{0,40}/) || [""])[0]);
184
+ }
185
+
186
+ // The preamble must describe the gate the way it actually works here — a blocking hook that
187
+ // fires regardless, or an advisory check the agent has to call. Getting this backwards is the
188
+ // worst single error the adapter can make: it tells the model a safety property holds when it
189
+ // does not.
190
+ if (rt.capabilities.hooks) {
191
+ check(`${id}: the gate is described as a blocking ${rt.hook.event} hook`,
192
+ build.includes("is registered as a blocking") && build.includes(rt.hook.event));
193
+ check(`${id}: the missing confirmation tier is stated`,
194
+ build.includes("no confirmation tier") === !rt.hook.supports_ask);
195
+ } else {
196
+ check(`${id}: the gate is described as an explicit check`, build.includes("gate.py --check"));
197
+ }
198
+
199
+ // Frontmatter the runtime does not understand is prose the model reads as instruction.
200
+ if (rt.command.format === "md" || rt.command.format === "skill") {
201
+ const fm = adapter.parseFrontmatter(build).keys.map(([k]) => k);
202
+ check(`${id}: only supported frontmatter keys survive`,
203
+ fm.every((k) => rt.command.frontmatter.includes(k)), fm.join(","));
204
+ if (rt.command.format === "skill") {
205
+ // A skill is matched on its frontmatter `name`, both for explicit invocation and for
206
+ // implicit selection. Without it the file installs and is simply never reachable.
207
+ check(`${id}: the skill carries the name it is invoked by`,
208
+ fm.includes("name") && /^name: cohorte-build$/m.test(build));
209
+ check(`${id}: skills are repo-scoped, so a clone gets the commands`,
210
+ rt.scopes.project.commands.startsWith(".agents/"));
211
+ }
212
+ } else {
213
+ check(`${id}: emitted as ${rt.command.format}, not markdown frontmatter`,
214
+ !build.startsWith("---\n") && /^description = "/m.test(build) && /^prompt = '''/m.test(build));
215
+ }
216
+
217
+ // Placeholder substitution: a token the runtime never expands must be explained, not left
218
+ // to look like it works.
219
+ if (rt.command.args && rt.command.args !== "$ARGUMENTS") {
220
+ check(`${id}: $ARGUMENTS rewritten to ${rt.command.args}`,
221
+ build.includes(rt.command.args) && !build.includes("$ARGUMENTS"));
222
+ } else if (!rt.command.args) {
223
+ check(`${id}: the unsubstituted placeholder is explained in the preamble`,
224
+ build.includes("does not substitute placeholders"));
225
+ }
226
+
227
+ for (const excluded of rt.exclude_commands || []) {
228
+ check(`${id}: ${excluded} is not installed (it cannot run here)`,
229
+ !existsSync(join(cmdDir, `${excluded}${rt.command.ext}`)));
230
+ }
231
+
232
+ const agentsDir = fix(p.agents) || join(fix(p.core), "agents");
233
+ const reviewFile = join(agentsDir, `review${rt.agent.ext || ".md"}`);
234
+ check(`${id}: the review agent exists as ${rt.agent.format}`, existsSync(reviewFile));
235
+ if (existsSync(reviewFile)) {
236
+ const review = readFileSync(reviewFile, "utf8");
237
+ // The reviewer must never be able to fix what it reports. Where the runtime can enforce
238
+ // that, the rendered file must carry the restriction; where it cannot, the body must say
239
+ // so — a reviewer that silently gains write access destroys the fix loop's evidence.
240
+ if (rt.agent.readonly_key) {
241
+ check(`${id}: the reviewer is pinned read-only (${rt.agent.readonly_key})`,
242
+ review.includes(rt.agent.readonly_key) && review.includes(rt.agent.readonly_value));
243
+ } else if (rt.capabilities.tool_restriction) {
244
+ // Claude expresses it as the absence of write tools in the `tools:` list.
245
+ check(`${id}: the reviewer's tool list carries no write tool`,
246
+ /^tools:.*$/m.test(review) && !/^tools:.*(Write|Edit|Bash)/m.test(review));
247
+ } else {
248
+ check(`${id}: the reviewer is told read-only is on it`,
249
+ review.includes("read-only **by discipline**") || review.includes("read-only by discipline"));
250
+ }
251
+ // An Anthropic model alias in another vendor's agent file either errors or is ignored.
252
+ check(`${id}: no Anthropic model alias leaked into the agent file`,
253
+ id === "claude" || !/^\s*model\s*[:=]/m.test(review), (review.match(/^.*model.*$/m) || [""])[0]);
254
+ }
255
+ // Every non-Claude runtime shares one `.cohorte` core, so this registry must ACCUMULATE.
256
+ // A single-record file let each install erase the previous runtime's entry.
257
+ const rtJson = join(fix(p.core), "pipeline", "runtimes.json");
258
+ check(`${id}: survives in runtimes.json after the other installs`, existsSync(rtJson)
259
+ && !!JSON.parse(readFileSync(rtJson, "utf8"))[id]);
260
+ check(`${id}: the gate script ships with the core`, existsSync(join(fix(p.core), "hooks", "gate.py")));
261
+ check(`${id}: workflows ship only where a workflow engine exists`,
262
+ existsSync(join(fix(p.core), "workflows")) === rt.capabilities.workflows);
263
+
264
+ // Templates are resolved in place at install time, so a shared core would let the LAST
265
+ // runtime installed decide what every other one reads. Each core is its own directory
266
+ // precisely to prevent that; assert the resolution actually matches this runtime.
267
+ const step = join(fix(p.core), "templates", "steps", "init-pipeline", "04-write-render.md");
268
+ if (existsSync(step)) {
269
+ const text = readFileSync(step, "utf8");
270
+ check(`${id}: templates carry no unresolved marker`, !/cohorte:(if|else|endif)/.test(text));
271
+ check(`${id}: the settings/hook step matches this runtime`,
272
+ text.includes("Write `.claude/settings.json`") === rt.capabilities.hooks);
273
+ }
274
+ }
275
+
276
+ // The project state — gate config, preflight stamp, metrics — describes the repo, not the
277
+ // agent driving it, and must NOT fork per runtime.
278
+ group("state — one project, one gate config");
279
+ const stateDirs = new Set(RUNTIMES.map((id) => adapter.stateDir(adapter.loadRuntime(id))));
280
+ check("every non-Claude runtime shares one state dir", stateDirs.size === 2
281
+ && stateDirs.has(".claude") && stateDirs.has(".cohorte"));
282
+ check("but each keeps its own rendered core",
283
+ new Set(RUNTIMES.map((id) => adapter.loadRuntime(id).scopes.project.core)).size === RUNTIMES.length);
284
+
285
+ // A config dir with a space in it is not exotic: a desktop host puts CLAUDE_CONFIG_DIR under
286
+ // `~/Library/Application Support/…`. An unquoted path there splits in the shell, python reports
287
+ // `can't open file '/Users/x/Library/Application'`, and EVERY tool call in the session fails —
288
+ // including the ones the human would need to undo it. Assert the registration is quoted, per
289
+ // runtime, and that the installer still recognises its own entry (or a re-install duplicates it).
290
+ group("hook registration — paths with spaces");
291
+ {
292
+ const spacedHome = mkdtempSync(join(tmpdir(), "cohorte home-"));
293
+ const spacedProj = mkdtempSync(join(tmpdir(), "cohorte proj-"));
294
+ tmps.push(spacedHome, spacedProj);
295
+ spawnSync("git", ["init", "-q", "."], { cwd: spacedProj });
296
+ const env = { ...process.env, HOME: spacedHome, CLAUDE_CONFIG_DIR: join(spacedHome, ".claude") };
297
+ const args = [join(root, "bin", "cli.js"), "install", `--runtime=${RUNTIMES.join(",")}`];
298
+ const first = spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
299
+ check("installs into a path containing a space", first.status === 0,
300
+ (first.stderr || "").slice(0, 300));
301
+ // Claude registers its hook only on a GLOBAL install — project-scope settings.json is
302
+ // /cohorte-init-pipeline's job — so exercise that scope too.
303
+ const gargs = [join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"];
304
+ spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
305
+ // Re-install BOTH: the reconcile must match its own quoted entry, or every run stacks another.
306
+ spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
307
+ spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
308
+
309
+ for (const id of RUNTIMES) {
310
+ const rt = adapter.loadRuntime(id);
311
+ if (!rt.capabilities.hooks) continue;
312
+ const cfgSpec = (id === "claude" ? rt.scopes.global : rt.scopes.project).hooks_config;
313
+ const cfgPath = cfgSpec.startsWith("~/")
314
+ ? join(spacedHome, cfgSpec.slice(2)) : join(spacedProj, cfgSpec);
315
+ if (!existsSync(cfgPath)) { check(`${id}: hook config written`, false, cfgPath); continue; }
316
+ const hooks = JSON.parse(readFileSync(cfgPath, "utf8")).hooks || {};
317
+ const entries = hooks[rt.hook.event] || [];
318
+ const cmds = entries.flatMap(e => e.command ? [e.command] : (e.hooks || []).map(h => h.command));
319
+ const ours = cmds.filter(c => /gate\.py/.test(c));
320
+ check(`${id}: the gate path is quoted`, ours.length > 0 && ours.every(c => /"[^"]*gate\.py"/.test(c)),
321
+ ours.join(" | "));
322
+ check(`${id}: re-installing does not stack a second registration`, ours.length === 1,
323
+ `${ours.length} entries`);
324
+ }
325
+ }
326
+
327
+ // CLAUDE_CONFIG_DIR moves Claude Code's whole tree — a desktop host points it at
328
+ // `~/Library/Application Support/…`. The registry declares those paths as `~/.claude`, and
329
+ // resolving them from the homedir instead of the override split the install in half: the core
330
+ // went to the REAL `~/.claude` while the hook was registered in the override. A scratch install
331
+ // therefore wrote into the user's actual global core, silently.
332
+ group("CLAUDE_CONFIG_DIR is honoured, not half-honoured");
333
+ {
334
+ const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
335
+ const cfg = mkdtempSync(join(tmpdir(), "cohorte-cfg-")); // deliberately NOT under home
336
+ const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
337
+ tmps.push(home, cfg, proj);
338
+ spawnSync("git", ["init", "-q", "."], { cwd: proj });
339
+ const r = spawnSync(process.execPath,
340
+ [join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"],
341
+ { cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: cfg }, encoding: "utf8" });
342
+
343
+ check("the global install succeeds under an overridden config dir", r.status === 0,
344
+ (r.stderr || "").slice(0, 300));
345
+ check("the core lands in the override", existsSync(join(cfg, "pipeline", "VERSION")));
346
+ check("the commands land in the override", existsSync(join(cfg, "commands", "cohorte-build.md")));
347
+ check("the hook is registered in the override", existsSync(join(cfg, "settings.json")));
348
+ check("nothing is written to the home default",
349
+ !existsSync(join(home, ".claude", "pipeline", "VERSION")));
350
+ }
351
+
352
+ // Claude Code must not regress: it is the runtime everyone is already on.
353
+ group("no regression — the Claude install keeps its shape");
354
+ check("commands still in .claude/commands", existsSync(join(proj, ".claude", "commands", "cohorte-build.md")));
355
+ check("agents still in .claude/agents", existsSync(join(proj, ".claude", "agents", "review.md")));
356
+ check("the model pin survives the render",
357
+ /^model: sonnet$/m.test(readFileSync(join(proj, ".claude", "commands", "cohorte-build.md"), "utf8")));
358
+ check("the subagent name survives the render",
359
+ /^name: review$/m.test(readFileSync(join(proj, ".claude", "agents", "review.md"), "utf8")));
360
+ // 2.2.0 retired /cohorte-loop. Copy-over never deletes, so the scrub is the only thing standing
361
+ // between an upgrade and a decoy command the model can still fire — assert it on the layout that
362
+ // actually had one installed.
363
+ check("the retired /cohorte-loop is not installed",
364
+ !existsSync(join(proj, ".claude", "commands", "cohorte-loop.md")));
365
+
366
+ for (const d of tmps) rmSync(d, { recursive: true, force: true });
367
+ console.log(failures ? `\ntest-adapter: ${failures} FAILED` : "\ntest-adapter: OK");
368
+ process.exit(failures ? 1 : 0);
@@ -402,6 +402,76 @@ console.log("index.js — HTTP guards");
402
402
  eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
403
403
  }
404
404
 
405
+ // ── runtime.js + a non-Claude layout ────────────────────────────────────────
406
+ // Every path-dependent check used to assume `.claude/`. On a repo driven from Cursor that
407
+ // reported a healthy install as three ❌ and a ⚠️ — no core, no rendered agent, artifacts not
408
+ // ignored, hook not registered — and every one was wrong. A false red is worse than no check:
409
+ // it sends a human fixing something that is not broken.
410
+ console.log("doctor.js — a non-Claude runtime layout");
411
+ {
412
+ const d = scratch();
413
+ const g = join(d, "global-claude");
414
+ mkdirSync(g, { recursive: true });
415
+
416
+ const core = join(d, ".cohorte", "cursor");
417
+ mkdirSync(join(core, "pipeline"), { recursive: true });
418
+ writeFileSync(join(core, "pipeline", "VERSION"), "9.9.9\n");
419
+ writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
420
+ cursor: {
421
+ label: "Cursor", scope: "project", core_version: "9.9.9",
422
+ capabilities: { subagents: true, hooks: true, workflows: false, tool_restriction: true },
423
+ paths: {
424
+ core, commands: join(d, ".cursor", "commands"), agents: join(d, ".cursor", "agents"),
425
+ hooks_config: join(d, ".cursor", "hooks.json"), state: ".cohorte",
426
+ },
427
+ },
428
+ }));
429
+ mkdirSync(join(d, ".cursor", "agents"), { recursive: true });
430
+ writeFileSync(join(d, ".cursor", "agents", "api.md"), "---\nname: api\n---\n");
431
+ writeFileSync(join(d, ".cursor", "hooks.json"), JSON.stringify({
432
+ version: 1,
433
+ hooks: { beforeShellExecution: [{ command: `python3 ${core}/hooks/gate.py --runtime cursor` }] },
434
+ }));
435
+ mkdirSync(join(d, ".cohorte"), { recursive: true });
436
+ const gate = { deny: ["rm -rf /"], ask: [], ask_on_default_branch: [], default_branch: "main" };
437
+ writeFileSync(join(d, ".cohorte", "gate-config.json"),
438
+ JSON.stringify({ ...gate, preflight: { enabled: false } }));
439
+ writeFileSync(join(d, ".gitignore"),
440
+ ".cohorte/preflight.ok\n.cohorte/pipeline-metrics.jsonl\nspecs/reports/\n");
441
+ writeFileSync(join(d, "PIPELINE.md"), [
442
+ "```yaml pipeline-profile", "name: demo",
443
+ "surfaces:", " - key: api", " path: src/api", " agent: api",
444
+ "gate:", " deny:", " - rm -rf /", " default_branch: main",
445
+ " preflight:", " enabled: false", "```",
446
+ ].join("\n"));
447
+
448
+ const s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
449
+ const pick = id => s.checks.find(c => c.id === id) || {};
450
+ const st = id => pick(id).status;
451
+ const dt = id => pick(id).detail;
452
+
453
+ eq("the runtime is discovered from runtimes.json", s.runtimes.map(r => r.id), ["cursor"]);
454
+ check("the core in .cohorte/<id>/ counts as installed", st("core") === "ok", dt("core"));
455
+ check("agents are looked for in .cursor/agents", st("agents") === "ok", dt("agents"));
456
+ check("gate-config is read from .cohorte, not .claude", st("gate") === "ok", dt("gate"));
457
+ check("artifact paths are named against the right state dir",
458
+ st("artifacts") === "ok" && !/\.claude/.test(dt("artifacts")), dt("artifacts"));
459
+ // Cursor's registration is a flat {command} under beforeShellExecution — read with Claude's
460
+ // matcher-group shape it looks absent, which is exactly the false red this guards.
461
+ check("the Cursor hook envelope is recognised", st("hooks") === "ok", dt("hooks"));
462
+ check("workflows are skipped, not reported missing",
463
+ st("workflows") === "skip" && /Cursor/.test(dt("workflows")), dt("workflows"));
464
+ check("nothing is reported broken on a healthy non-Claude install",
465
+ s.summary.bad === 0 && s.summary.warn === 0, JSON.stringify(s.summary));
466
+
467
+ // The metrics sink follows `<state>` too.
468
+ writeFileSync(join(d, ".cohorte", "pipeline-metrics.jsonl"),
469
+ JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f", phase: "build", seconds: 10,
470
+ surfaces: { api: "ok" } }) + "\n");
471
+ check("metrics are read from the runtime's state dir",
472
+ metrics({ projectRoot: d, globalDir: g }).batches === 1);
473
+ }
474
+
405
475
  for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
406
476
  console.log("");
407
477
  if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
@@ -353,6 +353,68 @@ console.log("gate.py — worktree awareness");
353
353
  }
354
354
  }
355
355
 
356
+ // ---------------------------------------------------------------------------
357
+ // Runtime dialects. Four runtimes host this hook and none of them agree on the
358
+ // envelope. A verdict emitted in the wrong shape is read as "allow" by every one of
359
+ // them — the gate would look installed, print JSON, and block nothing. Two of them
360
+ // also have no confirmation tier, where an honest `ask` must become a `deny` rather
361
+ // than fall through.
362
+ console.log("gate.py — runtime dialects");
363
+ {
364
+ const d = scratch();
365
+ gitRepo(d, "main");
366
+ writeConfig(d, {
367
+ deny: ["rm -rf /"], ask: ["git push"], ask_on_default_branch: [],
368
+ default_branch: "main", preflight: { enabled: true, agents: ["review"] },
369
+ });
370
+ const raw = (payload, ...args) => {
371
+ const r = spawnSync(python, [GATE, ...args], {
372
+ input: JSON.stringify(payload), encoding: "utf8",
373
+ env: { ...process.env, CLAUDE_PROJECT_DIR: d },
374
+ });
375
+ let json = null;
376
+ try { json = JSON.parse((r.stdout || "").trim()); } catch { /* no verdict */ }
377
+ return { json, status: r.status };
378
+ };
379
+ const push = { tool_name: "Bash", tool_input: { command: "git push" }, cwd: d };
380
+
381
+ const cc = raw(push, "--runtime", "claude");
382
+ check("claude: ask stays an ask, in the PreToolUse envelope",
383
+ cc.json?.hookSpecificOutput?.permissionDecision === "ask");
384
+ check("no --runtime flag behaves exactly as claude (pre-adapter registrations)",
385
+ raw(push).json?.hookSpecificOutput?.permissionDecision === "ask");
386
+
387
+ const cx = raw(push, "--runtime", "codex");
388
+ check("codex: ask escalates to deny (its `ask` is parsed but never honoured)",
389
+ cx.json?.hookSpecificOutput?.permissionDecision === "deny");
390
+ check("…and the reason says why it was refused rather than queried",
391
+ /no confirmation tier/.test(cx.json?.hookSpecificOutput?.permissionDecisionReason || ""));
392
+
393
+ // Cursor sends the command at the top level and names no tool.
394
+ const cu = raw({ hook_event_name: "beforeShellExecution", command: "git push", cwd: d },
395
+ "--runtime", "cursor");
396
+ check("cursor: its own envelope, and the top-level command is found",
397
+ cu.json?.permission === "ask" && typeof cu.json?.user_message === "string");
398
+ const cuDeny = raw({ hook_event_name: "beforeShellExecution", command: "rm -rf /", cwd: d },
399
+ "--runtime", "cursor");
400
+ check("cursor: a deny also exits 2 (its documented blocking code)",
401
+ cuDeny.json?.permission === "deny" && cuDeny.status === 2);
402
+
403
+ const ge = raw({ tool_name: "run_shell_command", tool_input: { command: "rm -rf /" }, cwd: d },
404
+ "--runtime", "gemini");
405
+ check("gemini: BeforeTool envelope, and run_shell_command is recognised as the shell",
406
+ ge.json?.decision === "deny" && typeof ge.json?.reason === "string");
407
+
408
+ // Gemini exposes each subagent as a tool of its own name, so the phase gate has to fire
409
+ // on `tool_name: review` — not only on Claude's `Task` + subagent_type shape.
410
+ const geDispatch = raw({ tool_name: "review", tool_input: {}, cwd: d }, "--runtime", "gemini");
411
+ check("gemini: a subagent-as-tool dispatch still hits the preflight phase gate",
412
+ geDispatch.json?.decision === "deny"
413
+ && /preflight/i.test(geDispatch.json?.reason || ""));
414
+ check("an unrelated tool is never gated on any runtime",
415
+ raw({ tool_name: "read_file", tool_input: {}, cwd: d }, "--runtime", "gemini").json === null);
416
+ }
417
+
356
418
  for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
357
419
 
358
420
  console.log("");
@@ -24,8 +24,8 @@ const frontmatter = (text) => {
24
24
  // Interactive commands must stay unpinned (they inherit on purpose).
25
25
  const PINNED = ["cohorte-build", "cohorte-review", "cohorte-fix", "cohorte-ship",
26
26
  "cohorte-audit", "cohorte-refactor", "cohorte-doctor", "cohorte-align-ds",
27
- "cohorte-update-pipeline", "cohorte-loop"];
28
- const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline"];
27
+ "cohorte-update-pipeline"];
28
+ const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline", "cohorte-patch"];
29
29
 
30
30
  // Every command must carry the `cohorte-` prefix. This replaces the old RESERVED
31
31
  // blocklist, which chased collisions one name at a time and always lagged: a command
@@ -129,26 +129,28 @@ const steps = join(root, "core/templates/steps/init-pipeline");
129
129
  if (!existsSync(steps) || readdirSync(steps).length === 0)
130
130
  fail("core/templates/steps/init-pipeline", "router step files missing/empty");
131
131
 
132
- // ── telemetry coverage ──────────────────────────────────────────────────────
133
- // The funnel is only readable if every one of its stages pings — a single missing
134
- // one silently truncates it (that is how /cohorte-review and /cohorte-fix went unreported
135
- // until 1.2.3). The phase list here must match SCHEMA.md §Telemetry's table.
136
- // These are telemetry PHASE names, not command names they stay unprefixed even though
137
- // the commands that emit them are now `/cohorte-*`. The phase is a wire field allowlisted
138
- // in telemetry-send.sh and keyed on by the collector's existing dataset; prefixing it would
139
- // orphan every ping ever sent. Command file = PREFIX + phase.
140
- const FUNNEL = ["brainstorm", "spec", "build", "review", "fix", "ship"];
141
- for (const c of FUNNEL)
142
- if (!/usage ping/i.test(read(`core/commands/${PREFIX}${c}.md`)))
143
- fail(`core/commands/${PREFIX}${c}.md`, "funnel command with no usage ping breaks the telemetry funnel");
144
- // …and nothing outside the funnel may ping (consent text scopes it to the funnel).
145
- for (const f of readdirSync(join(root, "core/commands"))) {
146
- const c = f.replace(/\.md$/, "").replace(new RegExp(`^${PREFIX}`), "");
147
- // `telemetry-send.sh` + an argument = a call site; the bare filename (e.g. /cohorte-doctor
148
- // listing the scripts it checks for) is a mention, not a ping.
149
- if (!FUNNEL.includes(c) && /telemetry-send\.sh +\S|usage ping/i.test(read(`core/commands/${f}`)))
150
- fail(`core/commands/${f}`, "non-funnel command pings telemetry — outside the consented scope");
151
- }
132
+ // ── no telemetry ────────────────────────────────────────────────────────────
133
+ // Telemetry was removed wholesale in 2.3.0: the shipped `telemetry-send.sh`, the
134
+ // per-phase pings, the consent question, the `telemetry:` config block, the collector
135
+ // endpoint. This check is the ratchet it fails if any of it creeps back into the
136
+ // core, which is easy to do by copying an old command file that still chains a ping.
137
+ // Deliberately broad: the whole point is that there is nothing left to send with.
138
+ const NO_TELEMETRY = /telemetry|usage ping/i;
139
+ // One exemption, and it is the opposite of a regression: /cohorte-update-pipeline is what
140
+ // DELETES the leftover `telemetry:` block from configs seeded before 2.3.0, so it is the one
141
+ // file that must still name the thing. Narrow on purpose — a filename, not a pattern.
142
+ const TELEMETRY_SCRUBBER = "core/commands/cohorte-update-pipeline.md";
143
+ const walk = (dir) => readdirSync(join(root, dir), { withFileTypes: true }).flatMap((e) =>
144
+ e.isDirectory() ? walk(`${dir}/${e.name}`) : [`${dir}/${e.name}`]);
145
+ for (const dir of ["core/commands", "core/agents", "core/templates", "core/workflows"])
146
+ for (const rel of walk(dir)) {
147
+ if (!/\.(md|js)$/.test(rel) || rel === TELEMETRY_SCRUBBER) continue;
148
+ if (NO_TELEMETRY.test(read(rel)))
149
+ fail(rel, "mentions telemetry it was removed in 2.3.0; nothing may ping or ask for consent");
150
+ }
151
+ // …and the exempt file may only REMOVE it: naming a send/ping/consent path there is still a bug.
152
+ if (/usage ping|telemetry-send|consent/i.test(read(TELEMETRY_SCRUBBER)))
153
+ fail(TELEMETRY_SCRUBBER, "may reference the retired telemetry block only to delete it — no ping, sender or consent flow");
152
154
 
153
155
  // ── kanban call sites ───────────────────────────────────────────────────────
154
156
  // Every pipeline stage moves a card, and a stage that only *describes* the move
@@ -158,7 +160,7 @@ for (const f of readdirSync(join(root, "core/commands"))) {
158
160
  // having opened neither the config nor PIPELINE.md, and a merged feature's card
159
161
  // stayed in "Ready to build". `kanban-move.sh auto` moved resolution into the
160
162
  // script; this keeps it there. Prose is not a call site — the literal invocation is.
161
- const KANBAN_STAGES = ["brainstorm", "spec", "build", "review", "fix", "ship"];
163
+ const KANBAN_STAGES = ["brainstorm", "spec", "build", "review", "fix", "ship", "patch"];
162
164
  for (const c of KANBAN_STAGES) {
163
165
  const path = `core/commands/${PREFIX}${c}.md`;
164
166
  const text = read(path);
@@ -174,7 +176,7 @@ for (const c of KANBAN_STAGES) {
174
176
  // ── shipped scripts ─────────────────────────────────────────────────────────
175
177
  // Every scripts/*.sh must be copied by BOTH shell installers. Callers chain these
176
178
  // with `|| true`, so one an installer forgets is a silent no-op forever — no kanban
177
- // card moves, no telemetry ping, no error. CI is the only place this is loud.
179
+ // card moves, no error. CI is the only place this is loud.
178
180
  // The third installer, bin/cli.js (what `npx cohorte` runs), copies by rule rather
179
181
  // than by name, so grepping for filenames can't see it — ci.yml dry-runs it into a
180
182
  // scratch HOME and asserts the same postconditions instead. Both are needed: this