cohorte 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +41 -32
  3. package/bin/cli.js +316 -26
  4. package/core/adapter/render.js +389 -0
  5. package/core/agents/implementer.template.md +3 -3
  6. package/core/agents/release.md +7 -4
  7. package/core/agents/review.md +10 -2
  8. package/core/commands/cohorte-audit.md +2 -0
  9. package/core/commands/cohorte-brainstorm.md +3 -6
  10. package/core/commands/cohorte-build.md +14 -17
  11. package/core/commands/cohorte-doctor.md +59 -28
  12. package/core/commands/cohorte-fix.md +2 -3
  13. package/core/commands/cohorte-init-pipeline.md +7 -8
  14. package/core/commands/cohorte-refactor.md +5 -2
  15. package/core/commands/cohorte-review.md +20 -16
  16. package/core/commands/cohorte-ship.md +44 -9
  17. package/core/commands/cohorte-spec.md +3 -7
  18. package/core/commands/cohorte-update-pipeline.md +8 -8
  19. package/core/hooks/gate.py +203 -16
  20. package/core/runtimes/claude.json +73 -0
  21. package/core/runtimes/codex.json +82 -0
  22. package/core/runtimes/cursor.json +75 -0
  23. package/core/runtimes/gemini.json +75 -0
  24. package/core/runtimes/opencode.json +72 -0
  25. package/core/templates/spec.template.md +1 -3
  26. package/core/templates/steps/init-pipeline/01-detect-stack.md +7 -3
  27. package/core/templates/steps/init-pipeline/02-interview-gaps.md +8 -2
  28. package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
  29. package/core/templates/steps/init-pipeline/05-report.md +1 -1
  30. package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
  31. package/dashboard/dist/index.html +1 -1
  32. package/dashboard/server/doctor.js +156 -69
  33. package/dashboard/server/index.js +12 -2
  34. package/dashboard/server/metrics.js +13 -6
  35. package/dashboard/server/runtime.js +115 -0
  36. package/dashboard/server/versions.js +12 -1
  37. package/install.ps1 +23 -2
  38. package/install.sh +22 -4
  39. package/package.json +6 -2
  40. package/profile/PIPELINE.template.md +27 -6
  41. package/profile/SCHEMA.md +88 -49
  42. package/scripts/kanban-move.sh +11 -1
  43. package/scripts/metrics/collect.mjs +5 -3
  44. package/scripts/preflight.sh +27 -8
  45. package/scripts/telemetry-send.sh +10 -3
  46. package/scripts/test-adapter.mjs +368 -0
  47. package/scripts/test-dashboard.mjs +70 -0
  48. package/scripts/test-gate.mjs +62 -0
  49. package/scripts/validate-core.mjs +1 -1
  50. package/core/commands/cohorte-loop.md +0 -110
  51. package/scripts/loop-detach.sh +0 -153
  52. package/scripts/loop.sh +0 -399
  53. package/scripts/test-loop.mjs +0 -330
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ // cohorte — runtime adapter.
3
+ //
4
+ // The core prompts (core/commands/*.md, core/agents/*.md) are the SINGLE source of
5
+ // truth and are written runtime-neutral. This module transpiles them into whatever
6
+ // the target coding agent actually reads:
7
+ //
8
+ // commands agents gate hook
9
+ // claude .claude/commands/<n>.md md, $ARGUMENTS md PreToolUse (deny+ask)
10
+ // codex .agents/skills/<n>/SKILL.md md, no substitution toml PreToolUse (deny only)
11
+ // cursor .cursor/commands/<n>.md md, no substitution md beforeShellExecution
12
+ // gemini .gemini/commands/<n>.toml toml, {{args}} md BeforeTool (deny only)
13
+ // opencode .opencode/commands/<n>.md md, $ARGUMENTS md none — advisory --check
14
+ //
15
+ // Three transforms, in order:
16
+ //
17
+ // 1. capability conditionals — `<!-- cohorte:if hooks -->…<!-- cohorte:else -->…
18
+ // <!-- cohorte:endif -->` keeps exactly one branch, so one file can state both "the gate
19
+ // fires whether or not you cooperate" and "you must call the gate yourself".
20
+ // 2. runtime preamble — one block resolving `<core>`, `<agents>`, the dispatch verb and
21
+ // the gate mechanism, so the prose below never hardcodes a runtime's layout.
22
+ // 3. surface encoding — frontmatter filtered to the keys that runtime understands (an
23
+ // unknown key is prose the model reads as an instruction, so dropping is not cosmetic),
24
+ // arg placeholder swapped, and the whole thing re-emitted as md, toml or a skill dir.
25
+ //
26
+ // Subagents are NOT one of the conditionals: they are a precondition (see assertSupported).
27
+ //
28
+ // Claude output is byte-identical to the pre-adapter core when a file has no conditionals:
29
+ // that is the regression test (scripts/test-adapter.mjs).
30
+
31
+ 'use strict';
32
+
33
+ const fs = require('fs');
34
+ const os = require('os');
35
+ const path = require('path');
36
+
37
+ const RUNTIME_DIR = path.join(__dirname, '..', 'runtimes');
38
+
39
+ // --- registry ----------------------------------------------------------------
40
+
41
+ function listRuntimes() {
42
+ return fs.readdirSync(RUNTIME_DIR)
43
+ .filter((f) => f.endsWith('.json'))
44
+ .map((f) => f.slice(0, -5))
45
+ .sort();
46
+ }
47
+
48
+ function loadRuntime(id) {
49
+ const file = path.join(RUNTIME_DIR, `${id}.json`);
50
+ if (!fs.existsSync(file)) {
51
+ throw new Error(`unknown runtime "${id}" (known: ${listRuntimes().join(', ')})`);
52
+ }
53
+ const rt = JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ assertSupported(rt);
55
+ return rt;
56
+ }
57
+
58
+ // Subagents are a HARD requirement, not a capability to branch on. The pipeline's premise is
59
+ // that each surface is built by someone who can only see the frozen contract; without real
60
+ // subagents the lead does every surface in one context and that isolation is simply gone.
61
+ // A sequential-persona fallback existed briefly and was removed: it asked the lead to simulate
62
+ // the boundary by discipline, which is not the same guarantee, and no supported runtime ever
63
+ // took that branch. Refuse loudly rather than render a pipeline whose central promise is absent.
64
+ function assertSupported(rt) {
65
+ if (rt.capabilities && rt.capabilities.subagents === false) {
66
+ throw new Error(`runtime "${rt.id}" declares no subagents — cohorte requires them `
67
+ + '(see docs/reference/runtimes.md §Requirements)');
68
+ }
69
+ return rt;
70
+ }
71
+
72
+ function expandHome(p) {
73
+ if (!p) return p;
74
+ return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
75
+ }
76
+
77
+ // Which runtimes are actually installed on this machine. Detection is by config
78
+ // directory, not by binary on PATH: Cursor ships no CLI, and a runtime the human
79
+ // has configured but not opened this shell for is still a legitimate target.
80
+ function detectRuntimes() {
81
+ return listRuntimes().filter((id) => {
82
+ const rt = loadRuntime(id);
83
+ return (rt.detect || []).some((d) => fs.existsSync(expandHome(d)));
84
+ });
85
+ }
86
+
87
+ // Absolute destinations for one (runtime, scope) pair. `projectRoot` anchors every
88
+ // relative path; `~`-rooted ones ignore it. A scope entry may pin an individual
89
+ // surface to another scope (Codex reads prompts ONLY from ~/.codex, even when the
90
+ // neutral core is bundled in the repo) via `<key>_scope`.
91
+ function resolvePaths(runtime, scope, projectRoot, { overrides } = {}) {
92
+ const spec = runtime.scopes[scope];
93
+ if (!spec) throw new Error(`runtime ${runtime.id} has no "${scope}" scope`);
94
+ // `overrides` re-roots a declared prefix — the one caller is CLAUDE_CONFIG_DIR, which moves
95
+ // Claude Code's whole `~/.claude` tree elsewhere (a desktop host points it at
96
+ // `~/Library/Application Support/…`). Ignoring it split the install in half: the core was
97
+ // written to `~/.claude` while the hook was registered in the overridden dir, so a scratch
98
+ // install silently wrote into the user's real global core.
99
+ const reroot = (p) => {
100
+ for (const [from, to] of Object.entries(overrides || {})) {
101
+ if (p === from) return to;
102
+ if (p.startsWith(from + '/')) return to + p.slice(from.length);
103
+ }
104
+ return p;
105
+ };
106
+ const abs = (p) => {
107
+ if (!p) return null;
108
+ const e = expandHome(reroot(p));
109
+ return path.isAbsolute(e) ? e : path.join(projectRoot, e);
110
+ };
111
+ const out = { scope, effective: {} };
112
+ for (const key of ['root', 'core', 'commands', 'agents', 'hooks', 'hooks_config',
113
+ 'workflows', 'settings']) {
114
+ out[key] = abs(spec[key]);
115
+ out.effective[key] = spec[`${key}_scope`] || scope;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // --- 1. capability conditionals ----------------------------------------------
121
+
122
+ const IF = /^\s*<!--\s*cohorte:if\s+([^>]+?)\s*-->\s*$/;
123
+ const ELSE = /^\s*<!--\s*cohorte:else\s*-->\s*$/;
124
+ const ENDIF = /^\s*<!--\s*cohorte:endif\s*-->\s*$/;
125
+
126
+ // A condition is a space-separated OR of terms; a term is `cap`, `!cap`, `runtime:<id>`
127
+ // or `!runtime:<id>`. OR (not AND) because every real use is "this family of runtimes".
128
+ function testCondition(expr, runtime) {
129
+ return expr.split(/\s+/).filter(Boolean).some((term) => {
130
+ const neg = term.startsWith('!');
131
+ const name = neg ? term.slice(1) : term;
132
+ let value;
133
+ if (name.startsWith('runtime:')) value = runtime.id === name.slice(8);
134
+ else if (name in (runtime.capabilities || {})) value = !!runtime.capabilities[name];
135
+ else throw new Error(`unknown cohorte:if term "${name}"`);
136
+ return neg ? !value : value;
137
+ });
138
+ }
139
+
140
+ function applyConditionals(text, runtime) {
141
+ const lines = text.split('\n');
142
+ const out = [];
143
+ // Each frame: {keep} — whether the branch currently being read survives. Nesting is
144
+ // supported so a hooks-branch can carry a subagents-branch inside it.
145
+ const stack = [];
146
+ const emitting = () => stack.every((f) => f.keep);
147
+ for (const line of lines) {
148
+ let m;
149
+ if ((m = line.match(IF))) {
150
+ const taken = testCondition(m[1], runtime);
151
+ stack.push({ taken, keep: taken, seenElse: false });
152
+ continue;
153
+ }
154
+ if (ELSE.test(line)) {
155
+ const frame = stack[stack.length - 1];
156
+ if (!frame) throw new Error('cohorte:else without a matching cohorte:if');
157
+ if (frame.seenElse) throw new Error('two cohorte:else in one cohorte:if');
158
+ frame.seenElse = true;
159
+ frame.keep = !frame.taken;
160
+ continue;
161
+ }
162
+ if (ENDIF.test(line)) {
163
+ if (!stack.pop()) throw new Error('cohorte:endif without a matching cohorte:if');
164
+ continue;
165
+ }
166
+ if (emitting()) out.push(line);
167
+ }
168
+ if (stack.length) throw new Error('unclosed cohorte:if');
169
+ return out.join('\n');
170
+ }
171
+
172
+ // --- 2. runtime preamble ------------------------------------------------------
173
+
174
+ // Path as the human/model should see it: `~/…` reads better than an expanded homedir,
175
+ // and a project-relative path must stay relative (the agent's cwd is the repo).
176
+ function displayPath(p, projectRoot) {
177
+ if (!p) return null;
178
+ const home = os.homedir();
179
+ if (p.startsWith(home + path.sep)) return '~/' + path.relative(home, p).split(path.sep).join('/');
180
+ if (p.startsWith(projectRoot + path.sep)) return path.relative(projectRoot, p).split(path.sep).join('/');
181
+ return p;
182
+ }
183
+
184
+ // The per-project directory holding everything the pipeline GENERATES for this repo
185
+ // (gate-config.json, preflight.ok, pipeline-metrics.jsonl, pipeline.json). Distinct from
186
+ // `<core>`: the core can be global while this is always in the repo, next to the code it
187
+ // describes. `.claude` on a Claude install — unchanged, so existing repos keep working.
188
+ //
189
+ // Deliberately SHARED across non-Claude runtimes (`.cohorte`), while each of their cores is
190
+ // its own subdirectory (`.cohorte/<id>`). The state describes the PROJECT — one gate config,
191
+ // one preflight stamp, one metrics log — and duplicating it per runtime would let a repo
192
+ // driven from two agents disagree with itself about what is gated and what is verified.
193
+ // The core, by contrast, is rendered per runtime and cannot be shared: the same template
194
+ // resolves differently depending on whether that agent has subagents or hooks.
195
+ function stateDir(runtime) {
196
+ return runtime.id === 'claude' ? '.claude' : '.cohorte';
197
+ }
198
+
199
+ // The user-level config (kanban boards, shared vault, telemetry consent). One file per
200
+ // human, not per project or per runtime; the shipped scripts probe the same two paths.
201
+ function configPath(runtime) {
202
+ return runtime.id === 'claude' ? '~/.claude/cohorte.config.yaml' : '~/.cohorte/cohorte.config.yaml';
203
+ }
204
+
205
+ function preamble(runtime, paths, projectRoot, { kind = 'command' } = {}) {
206
+ const caps = runtime.capabilities || {};
207
+ const core = displayPath(paths.core, projectRoot);
208
+ const agentsDir = paths.agents ? displayPath(paths.agents, projectRoot) : `${core}/agents`;
209
+ const L = [];
210
+ L.push(`> **Runtime: ${runtime.label}.** Generated by the cohorte adapter — do not edit this file;`);
211
+ L.push(`> edit \`core/${kind === 'agent' ? 'agents' : 'commands'}/\` in the cohorte source and re-install.`);
212
+ L.push('>');
213
+ L.push(`> - \`<core>\` = \`${core}\` — the pipeline's shared assets (\`pipeline/scripts/\`, \`pipeline/SCHEMA.md\`, \`templates/\`). Every \`<core>/…\` path below resolves there, and nowhere else.`);
214
+ L.push(`> - \`<state>\` = \`${stateDir(runtime)}/\` in **this repo** — what the pipeline generates for this project (\`gate-config.json\`, \`preflight.ok\`, \`pipeline-metrics.jsonl\`, \`pipeline.json\`). Always project-relative, even when \`<core>\` is global.`);
215
+ L.push(`> - \`<memory>\` = \`${runtime.memory}\` — this runtime's project-instructions file at the repo root, loaded into every session here. Where the doctrine says to reference or extend it, that is the file.`);
216
+ L.push(`> - \`<config>\` = \`${configPath(runtime)}\` — your user-level config (kanban boards, shared vault, telemetry consent). One per human, never committed.`);
217
+
218
+ L.push(`> - \`<agents>\` = \`${agentsDir}\` — real subagents. Dispatch: ${runtime.agent.dispatch}.`);
219
+
220
+ if (caps.hooks) {
221
+ const cfg = displayPath(paths.hooks_config, projectRoot);
222
+ const ask = runtime.hook.supports_ask
223
+ ? 'It can deny outright or ask you to confirm.'
224
+ : 'This runtime has **no confirmation tier**, so a command that would merely be queried elsewhere is **denied** here — re-run it yourself if you meant it.';
225
+ L.push(`> - **Gate** — \`<core>/hooks/gate.py\` is registered as a blocking \`${runtime.hook.event}\` hook in \`${cfg}\`, reading \`<state>/gate-config.json\`. It fires whether or not you cooperate. ${ask}`);
226
+ }
227
+
228
+ if (!caps.hooks) {
229
+ L.push('> - **Gate** — this runtime has no blocking hook, so the gate is not automatic. Before any command listed in the profile\'s `gate` block, and before every phase dispatch, run `<core>/hooks/gate.py --check <command>` yourself and obey its verdict (`deny` ⇒ stop, `ask` ⇒ get the human\'s explicit go-ahead). Skipping this is the one deviation that silently removes a safety property.');
230
+ }
231
+
232
+ if (!caps.workflows) {
233
+ L.push('> - **Workflows** — unavailable on this runtime. The conversational path below is the only path; ignore any mention of a workflow variant.');
234
+ }
235
+
236
+ if (!runtime.command.args) {
237
+ L.push(`> - \`$ARGUMENTS\` — this runtime does not substitute placeholders. It means ${runtime.command.args_note || 'the text typed after the command name'}; expand it yourself everywhere it appears below.`);
238
+ }
239
+
240
+ return L.join('\n') + '\n';
241
+ }
242
+
243
+ // --- 3. surface encoding ------------------------------------------------------
244
+
245
+ function parseFrontmatter(text) {
246
+ if (!text.startsWith('---\n')) return { keys: [], body: text };
247
+ const end = text.indexOf('\n---\n', 3);
248
+ if (end === -1) return { keys: [], body: text };
249
+ const block = text.slice(4, end + 1);
250
+ const body = text.slice(end + 5);
251
+ const keys = [];
252
+ for (const line of block.split('\n')) {
253
+ const m = line.match(/^([A-Za-z][\w-]*):\s?(.*)$/);
254
+ // Continuation lines (a wrapped value) belong to the previous key.
255
+ if (!m) {
256
+ if (line.trim() && keys.length) keys[keys.length - 1][1] += '\n' + line;
257
+ continue;
258
+ }
259
+ keys.push([m[1], m[2]]);
260
+ }
261
+ return { keys, body };
262
+ }
263
+
264
+ function emitFrontmatter(keys) {
265
+ if (!keys.length) return '';
266
+ return '---\n' + keys.map(([k, v]) => `${k}: ${v}`).join('\n') + '\n---\n\n';
267
+ }
268
+
269
+ function substituteArgs(text, runtime) {
270
+ const token = runtime.command.args;
271
+ if (!token || token === '$ARGUMENTS') return text;
272
+ return text.split('$ARGUMENTS').join(token);
273
+ }
274
+
275
+ // TOML basic-string escaping for the multi-line ''' form Gemini expects. A literal
276
+ // ''' inside a prompt would close the string early, so it is the one sequence broken up.
277
+ function tomlMultiline(s) {
278
+ return "'''\n" + s.replace(/'''/g, "''\\'") + "\n'''";
279
+ }
280
+ function tomlBasic(s) {
281
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ') + '"';
282
+ }
283
+
284
+ /**
285
+ * Render one core command for one runtime.
286
+ * @returns {{filename: string, content: string, dir: 'commands'}}
287
+ */
288
+ function renderCommand({ source, name, runtime, paths, projectRoot }) {
289
+ const { keys, body } = parseFrontmatter(source);
290
+ let out = applyConditionals(body, runtime);
291
+ out = preamble(runtime, paths, projectRoot) + '\n' + out.replace(/^\n+/, '');
292
+ out = substituteArgs(out, runtime);
293
+
294
+ const kept = keys.filter(([k]) => runtime.command.frontmatter.includes(k));
295
+ const dropped = keys.filter(([k]) => !runtime.command.frontmatter.includes(k)).map(([k]) => k);
296
+
297
+ // A skill is a DIRECTORY — `<name>/SKILL.md` — whose frontmatter must carry `name`, since
298
+ // that is what the runtime matches on (both for `$<name>` and for implicit selection from
299
+ // `description`). Command sources have no `name` key, so synthesise it from the filename.
300
+ if (runtime.command.format === 'skill') {
301
+ if (!kept.some(([k]) => k === 'name')) kept.unshift(['name', name]);
302
+ return { filename: `${name}${runtime.command.ext}`, content: emitFrontmatter(kept) + out, dropped };
303
+ }
304
+
305
+ if (runtime.command.format === 'toml') {
306
+ const desc = (keys.find(([k]) => k === 'description') || [null, ''])[1];
307
+ const lines = [`# cohorte — generated for ${runtime.label}. Do not edit; edit core/commands/${name}.md.`];
308
+ if (dropped.length) lines.push(`# frontmatter not supported here, dropped: ${dropped.join(', ')}`);
309
+ if (desc) lines.push(`description = ${tomlBasic(desc)}`);
310
+ lines.push(`prompt = ${tomlMultiline(substituteArgs(out, runtime))}`);
311
+ return { filename: `${name}${runtime.command.ext}`, content: lines.join('\n') + '\n', dropped };
312
+ }
313
+
314
+ const header = emitFrontmatter(kept);
315
+ return { filename: `${name}${runtime.command.ext}`, content: header + out, dropped };
316
+ }
317
+
318
+ // Is this agent read-only? Derived from the source's Claude-style `tools:` list rather than
319
+ // declared twice: an agent that was never given Write, Edit or Bash is read-only by intent,
320
+ // and each runtime spells that its own way (`readonly: true`, `sandbox_mode = "read-only"`).
321
+ // Losing it silently would hand the reviewer the ability to fix what it is meant to report.
322
+ function isReadOnly(keys) {
323
+ const tools = (keys.find(([k]) => k === 'tools') || [])[1];
324
+ if (!tools) return false;
325
+ return !/\b(Write|Edit|MultiEdit|Bash|NotebookEdit)\b/.test(tools);
326
+ }
327
+
328
+ /**
329
+ * Render one core agent for one runtime.
330
+ *
331
+ * Every runtime cohorte targets has real subagents, but they disagree on the file: markdown +
332
+ * frontmatter for Claude Code, Cursor, Gemini CLI and OpenCode; TOML with the body under
333
+ * `developer_instructions` for Codex. `format: "persona"` remains for a runtime with no
334
+ * subagents at all — the file is then read and adopted by the lead in sequence.
335
+ */
336
+ function renderAgent({ source, name, runtime, paths, projectRoot }) {
337
+ const { keys, body } = parseFrontmatter(source);
338
+ const out = preamble(runtime, paths, projectRoot, { kind: 'agent' }) + '\n'
339
+ + applyConditionals(body, runtime).replace(/^\n+/, '');
340
+ const spec = runtime.agent;
341
+ const readonly = isReadOnly(keys);
342
+
343
+ if (spec.format === 'toml') {
344
+ const get = (k) => (keys.find(([kk]) => kk === k) || [null, ''])[1];
345
+ const lines = [`# cohorte — generated for ${runtime.label}. Do not edit; edit core/agents/${name}.md.`];
346
+ lines.push(`name = ${tomlBasic(get('name') || name)}`);
347
+ if (get('description')) lines.push(`description = ${tomlBasic(get('description'))}`);
348
+ if (spec.frontmatter.includes('model') && get('model')) lines.push(`model = ${tomlBasic(get('model'))}`);
349
+ if (readonly && spec.readonly_key) {
350
+ lines.push(`${spec.readonly_key} = ${tomlBasic(spec.readonly_value)}`);
351
+ }
352
+ lines.push(`${spec.body_key} = ${tomlMultiline(out)}`);
353
+ return { filename: `${name}${spec.ext}`, content: lines.join('\n') + '\n', native: true, readonly };
354
+ }
355
+
356
+ if (spec.format !== 'md') {
357
+ return { filename: `${name}.md`, content: out, native: false, dropped: keys.map(([k]) => k) };
358
+ }
359
+
360
+ const allowed = spec.frontmatter;
361
+ const kept = keys.filter(([k]) => allowed.includes(k));
362
+ for (const [k, v] of Object.entries(spec.defaults || {})) {
363
+ if (!kept.some(([kk]) => kk === k)) kept.push([k, v]);
364
+ }
365
+ if (readonly && spec.readonly_key && !kept.some(([k]) => k === spec.readonly_key)) {
366
+ kept.push([spec.readonly_key, spec.readonly_value]);
367
+ }
368
+ const dropped = keys.filter(([k]) => !allowed.includes(k)).map(([k]) => k);
369
+ return { filename: `${name}${spec.ext || '.md'}`, content: emitFrontmatter(kept) + out, native: true, dropped, readonly };
370
+ }
371
+
372
+ module.exports = {
373
+ assertSupported,
374
+ stateDir,
375
+ configPath,
376
+ listRuntimes,
377
+ loadRuntime,
378
+ detectRuntimes,
379
+ resolvePaths,
380
+ expandHome,
381
+ displayPath,
382
+ applyConditionals,
383
+ testCondition,
384
+ parseFrontmatter,
385
+ emitFrontmatter,
386
+ preamble,
387
+ renderCommand,
388
+ renderAgent,
389
+ };
@@ -72,9 +72,9 @@ tools are unavailable or come up empty.
72
72
  2. Implement until green, following your baked conventions.
73
73
  3. Refactor to the conventions. Keep tests green.
74
74
  4. **Lint + format before handoff:** run your surface's lint and fix every issue. If the project
75
- registers a PostToolUse format hook (see `.claude/settings.json`), your files are already
76
- formatted on every write — skip `format_cmd`; otherwise run it too. Code you hand off must be
77
- lint-clean and formatted.
75
+ registers a format-on-write hook (Claude Code: `PostToolUse` in `settings.json`), your files are
76
+ already formatted on every write — skip `format_cmd`; otherwise run it too. Code you hand off must
77
+ be lint-clean and formatted.
78
78
 
79
79
  **Run commands bridled — always.** Your surface's `test_quiet_cmd`/`lint_quiet_cmd` in `PIPELINE.md`
80
80
  are the forms you execute (dot reporter / failures-only); when a quiet variant is empty or absent,
@@ -11,9 +11,6 @@ committing, pushing, and opening the PR. You do **not** write features.
11
11
 
12
12
  > **First action, always:** read `PIPELINE.md` §`pipeline-profile` → `vcs` (host, remote,
13
13
  > default_branch, feature_branch_prefix) and `name`. Those drive the branch, PR base, and remote URL.
14
- >
15
- > The PR-body template path (`.claude/templates/pr-body.md`) resolves to `~/.claude/templates/pr-body.md`
16
- > when the core is installed globally — read whichever exists.
17
14
 
18
15
  ## You must NEVER
19
16
 
@@ -23,11 +20,17 @@ committing, pushing, and opening the PR. You do **not** write features.
23
20
  on pushed commits), or delete branches.
24
21
  - Run anything in `PIPELINE.md` §`gate.deny` (destructive DB/history).
25
22
  - Commit secrets — inspect `git status`/`git diff` and refuse if `.env` or credentials are staged.
23
+ - **Author or edit a release note** (`.changeset/*.md` or whatever `PIPELINE.md` §`release_notes`
24
+ declares). The lead writes it before dispatching you; picking a bump level is project policy, not a
25
+ git ritual. You only **stage** it. If `release_notes.enabled` and the file is absent, say so in your
26
+ report instead of inventing one — the lead fixes it.
26
27
 
27
28
  ## Your inputs
28
29
 
29
30
  1. The spec path `specs/<id>.md` (title, goal, contract — for the PR body).
30
31
  2. `feature_id` and the branch `<vcs.feature_branch_prefix><id>`.
32
+ 3. If `PIPELINE.md` §`release_notes.enabled`, the already-written note at
33
+ `<release_notes.dir>/<release_notes.filename>` — stage it with everything else.
31
34
 
32
35
  ## Steps
33
36
 
@@ -39,7 +42,7 @@ committing, pushing, and opening the PR. You do **not** write features.
39
42
  3. `git push -u origin <branch>` (plain push, no force).
40
43
  4. Open the PR against `vcs.default_branch`:
41
44
  - `vcs.host: github` and `gh` available → `gh pr create --base <default_branch> --head <branch>` with a
42
- title + body filled from `.claude/templates/pr-body.md`.
45
+ title + body filled from `<core>/templates/pr-body.md`.
43
46
  - Otherwise (no `gh`, or `host: gitlab/none`) → do NOT fail: push, then emit the compare URL
44
47
  (`https://github.com/<vcs.remote>/compare/<default_branch>...<branch>?expand=1`, or the host's
45
48
  equivalent) and print the drafted PR title + body for the human to open.
@@ -5,8 +5,16 @@ tools: Read, Grep, Glob, mcp__serena
5
5
  model: sonnet
6
6
  ---
7
7
 
8
- You are the **review** agent for one feature. You are **read-only by construction** — no Write, Edit,
9
- or Bash. You never fix anything; you only report. Your output drives the human's fix loop, so it must
8
+ You are the **review** agent for one feature.
9
+ <!-- cohorte:if tool_restriction -->
10
+ You are **read-only by construction** — no Write, Edit, or Bash.
11
+ <!-- cohorte:else -->
12
+ You are read-only **by discipline**: this runtime does not take your write tools away, so the
13
+ constraint holds only because you hold it. For the whole of this review you do not edit a single file,
14
+ run a single fix, or stage anything — a reviewer who fixes what they find destroys the evidence the
15
+ fix loop runs on, and silently converts a finding into an unreviewed change.
16
+ <!-- cohorte:endif -->
17
+ You never fix anything; you only report. Your output drives the human's fix loop, so it must
10
18
  be precise and self-contained.
11
19
 
12
20
  > **First action, always:** read `PIPELINE.md` — the machine block for the `surfaces`, `contract`,
@@ -11,9 +11,11 @@ analyze only — no fixes (those go through `/cohorte-refactor`).
11
11
  > `specs/_decisions.md` §Live if it exists (SCHEMA.md §Decisions): those standing decisions are part
12
12
  > of the rulebook you audit against, and code that contradicts one is a finding like any other.
13
13
  >
14
+ <!-- cohorte:if workflows -->
14
15
  > **Workflow variant** (opt-in — SCHEMA.md §Workflows): on Claude Code ≥ 2.1.154 with workflows
15
16
  > enabled, the human can ask to "run the audit workflow" (`<core>/workflows/audit.js` — one auditor
16
17
  > per domain, concurrent). This conversational path stays the default and the fallback.
18
+ <!-- cohorte:endif -->
17
19
 
18
20
  ## 1. Mechanical gates (you run these — Bash)
19
21
 
@@ -17,13 +17,10 @@ at Finish, when a board is configured.
17
17
  > needs to overturn one must say which line, out loud, so the human decides it here rather than
18
18
  > discovering the contradiction at `/cohorte-spec`.
19
19
  >
20
- > Template paths below (`.claude/templates/…`) resolve to `~/.claude/templates/…` when the core is
21
- > installed globally — read whichever exists.
22
- >
23
20
  > **Kanban** (SCHEMA.md §Kanban): every card move below is one call —
24
21
  > `<core>/pipeline/scripts/kanban-move.sh auto <feature_id> <stage> [--title "<human title>"]`, with
25
- > `<core>` = `.claude` bundled / `~/.claude` global (probe with `test -x`). `auto` resolves the
26
- > board from `~/.claude/cohorte.config.yaml` itself and exits 0 with a `kanban: <reason>` line when
22
+ > `auto` resolves the
23
+ > board from `<config>` itself and exits 0 with a `kanban: <reason>` line when
27
24
  > none resolves — so **never decide "no board is configured" without running it**. Reading the Ideas
28
25
  > column at Start still needs the board path: get it from a `kanban-move.sh` run, or grep the config
29
26
  > for `boards[<PIPELINE name>]`.
@@ -51,7 +48,7 @@ screens, risks, and what's explicitly out.
51
48
  ## Finish
52
49
 
53
50
  When the human is satisfied, produce the **brainstorm return** by filling
54
- `.claude/templates/brainstorm-return.md` and **staging it to
51
+ `<core>/templates/brainstorm-return.md` and **staging it to
55
52
  `specs/reports/<feature_id>-brainstorm.md`** (the gitignored buffer dir — `/cohorte-spec` reads it from there
56
53
  when invoked with no paste). In chat print only a 3-line summary + the path. Tell them to run `/cohorte-spec`
57
54
  — **recommend a `/clear` first**, the return is staged on disk (pasting it remains a fallback).
@@ -11,8 +11,7 @@ You are the **lead**. Build feature **$ARGUMENTS** from its frozen spec.
11
11
  > re-read if it's already in your context this session and unmodified since._
12
12
  >
13
13
  > **Kanban** (SCHEMA.md §Kanban): once §1 confirms the frozen spec, run
14
- > `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS building` (`<core>` = `.claude` bundled /
15
- > `~/.claude` global — probe with `test -x`). `auto` resolves the board from the config itself and
14
+ > `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS building`. `auto` resolves the board from the config itself and
16
15
  > exits 0 with a `kanban: <reason>` line when there is none — so **never decide "no board is
17
16
  > configured" without running it**. That inference, not a missing board, is what used to freeze
18
17
  > cards mid-pipeline.
@@ -20,9 +19,9 @@ You are the **lead**. Build feature **$ARGUMENTS** from its frozen spec.
20
19
  ## 1. Load & check
21
20
 
22
21
  - Check the spec front-matter FIRST — `grep '^status:' specs/$ARGUMENTS.md` (or Read with a ~15-line
23
- limit) — before any full read. Buildable statuses are `frozen`, `in-review` and `in-progress` (the
24
- last one means a `/cohorte-loop` is or was driving this spec — SCHEMA.md §Spec status). `blocked` means a
25
- loop gave up here: say so, and route by the spec's `## Remediation` — open items ⇒ `/cohorte-fix`, none ⇒
22
+ limit) — before any full read. Buildable statuses are `frozen`, `in-review` and `in-progress`
23
+ (SCHEMA.md §Spec status). `blocked` means a previous round gave up here: say so, and route by the
24
+ spec's `## Remediation` — open items ⇒ `/cohorte-fix`, none ⇒
26
25
  continue this build. Anything else (`draft`, missing, `shipped`) ⇒ stop and tell the human to run
27
26
  `/cohorte-spec` first. Only then read the body, selectively: front-matter, §5 contract, the surface
28
27
  task sections, and `## Remediation` (fall back to a full read if the spec doesn't follow the
@@ -58,13 +57,13 @@ Map every area the spec touches (§5 contract + each surface's tasks + touched p
58
57
  For each surface to add: infer its `key`, `path`, `label`, `agent`, `tools`, `model`, `*_cmd`s, and
59
58
  `uses_design` (mirror a sibling surface), show the human a one-line proposal, and on go-ahead **render it now** per
60
59
  SCHEMA.md §"Rendering / reconciling a surface agent" — write the `surfaces[]` entry + §Conventions/§Testing
61
- stanza into `PIPELINE.md`, render `.claude/agents/<agent>.md` from the implementer template, applying the
60
+ stanza into `PIPELINE.md`, render `<agents>/<agent>.md` from the implementer template, applying the
62
61
  shared-code rule (shared trees get a single-owner surface; cross-slice shapes go through the contract).
63
62
  This is the automatic path: you don't send the human back to `/cohorte-init-pipeline`. If nothing new is needed,
64
63
  say so and continue. Dispatch (§3) then covers the reconciled surface list.
65
64
 
66
65
  **Adding or splitting a surface is an architectural decision** — append ONE line for it to
67
- `specs/_decisions.md` §Live (SCHEMA.md §Decisions; create from `.claude/templates/decisions.template.md`
66
+ `specs/_decisions.md` §Live (SCHEMA.md §Decisions; create from `<core>/templates/decisions.template.md`
68
67
  if absent), area `surfaces`, e.g.
69
68
  `- <date> · surfaces · <key> owns <path>, single owner of <what> — because <the boundary reason> · $ARGUMENTS`.
70
69
  One `>>` in the Bash call you're already making. Nothing added ⇒ nothing to append.
@@ -94,8 +93,8 @@ good idea (that was `/cohorte-brainstorm`), never by re-reading files you don't
94
93
 
95
94
  Write the machine-readable verdict to `specs/reports/$ARGUMENTS.readiness.json` (overwrite,
96
95
  `mkdir -p specs/reports` first — the same gitignored buffer dir `/cohorte-review` stages into, which may not
97
- exist yet on a first build) — on **every** build, including `READY`. It is the only channel between this gate and a driver (`/cohorte-loop`),
98
- which parses no prose:
96
+ exist yet on a first build) — on **every** build, including `READY`. It is the only channel between
97
+ this gate and any automated driver, which parses no prose:
99
98
 
100
99
  ```json
101
100
  { "id": "$ARGUMENTS", "phase": "readiness", "ts": "<ISO>", "verdict": "RESERVATIONS",
@@ -129,7 +128,7 @@ wall-clock start — no separate timing call).
129
128
 
130
129
  ## 3. Dispatch one implementer per surface — IN PARALLEL
131
130
 
132
- Spawn every surface's agent in a **single message** (one Task call each) so they run concurrently —
131
+ Spawn every surface's agent in a **single message** (one dispatch each) so they run concurrently —
133
132
  NEVER serially: build wall-clock must be the slowest surface, not the sum. Use
134
133
  the reconciled `surfaces` list from §1.5 (existing + any just-rendered). Give EACH only what a stateless
135
134
  agent needs — re-supply everything every time, as **exact file paths** (spec, contract, the surface's
@@ -153,11 +152,11 @@ tree. For each surface in `surfaces`:
153
152
 
154
153
  ## 3.5 Roll call — account for EVERY dispatch before integrating
155
154
 
156
- A subagent can die: a rate limit mid-run, a transport error after retries, its own context exhausted.
155
+ A surface's work can die: a rate limit mid-run, a transport error after retries, context exhausted.
157
156
  When it does, it returns **nothing** — and nothing is byte-identical to "a clean surface with nothing
158
157
  to report". Silence is not a green light; treat it as the failure it is (SCHEMA.md §Dead agents).
159
158
 
160
- - **Roll call.** Every surface you dispatched in §3 must come back with a handoff in the format its
159
+ - **Roll call.** Every surface handled in §3 must come back with a handoff in the format its
161
160
  agent instructions define. Missing, empty, or truncated mid-sentence ⇒ that surface is **dead**.
162
161
  - **Never infer success from silence,** and never speak for a dead agent — you did not see its work.
163
162
  - **Retry that surface ONCE, alone.** Re-dispatch it with the byte-identical §3 prompt. The other
@@ -174,9 +173,9 @@ to report". Silence is not a green light; treat it as the failure it is (SCHEMA.
174
173
  When all return, flag any contract mismatch or failing test from the handoffs; otherwise print one
175
174
  status line per surface (`<key> · tests pass/fail · <n> TODOs`) — do not restate handoff content.
176
175
  A dead surface (§3.5) prints `<key> · DEAD — unverified` and **the batch is never reported as ok**.
177
- Append **ONE line for the batch** to the **main checkout's** `.claude/pipeline-metrics.jsonl` —
176
+ Append **ONE line for the batch** to the **main checkout's** `<state>/pipeline-metrics.jsonl` —
178
177
  NOT the worktree's, which dies at teardown while metrics must accumulate across features. Resolve
179
- it from anywhere: `$(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl`
178
+ it from anywhere: `$(dirname "$(git rev-parse --git-common-dir)")/<state>/pipeline-metrics.jsonl`
180
179
  (in the main checkout this resolves to itself). Create it if absent; it must be gitignored.
181
180
  Compute the elapsed time in the same Bash call
182
181
  (`echo "{...\"seconds\":$(($(date +%s)-<start epoch from §2>)),...}" >> …`):
@@ -189,9 +188,7 @@ never sees your chat:
189
188
  `{"id":"$ARGUMENTS","phase":"build","ts":"<ISO>","surfaces":{"<key>":"ok|error|dead",…},"dead":["<key>",…]}`
190
189
  — this is the evidence SCHEMA.md §Specialization asks for before splitting a surface. In the same
191
190
  Bash call, chain the opt-in usage ping — **the shared form every phase command reuses**:
192
- `<core>/pipeline/scripts/telemetry-send.sh <phase> "$ARGUMENTS" <seconds> "<results>" || true`
193
- (`<core>` = `~/.claude` global / `.claude` bundled; here `<phase>` = `build`, `<results>` =
194
- `<ok,ok|error,…>`) — a silent no-op unless the human explicitly consented (SCHEMA.md §Telemetry);
191
+ `<core>/pipeline/scripts/telemetry-send.sh <phase> "$ARGUMENTS" <seconds> "<results>" || true` — a silent no-op unless the human explicitly consented (SCHEMA.md §Telemetry);
195
192
  never ask about consent here. `/cohorte-review` and `/cohorte-fix` chain the same line with their own
196
193
  phase + results. The `|| true` swallows a **missing** script too, so a half-copied core goes
197
194
  silent rather than loud — `/cohorte-doctor` check 1 is what catches that.