cohorte 2.10.1 → 3.0.0-dev.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 (211) hide show
  1. package/README.md +7 -0
  2. package/assets/manifest.json +193 -0
  3. package/assets/migrations/config/README.md +12 -0
  4. package/assets/migrations/state/0001_init.sql +154 -0
  5. package/assets/prompts/README.md +11 -0
  6. package/assets/prompts/agents/fixer.md +7 -0
  7. package/assets/prompts/agents/implementer.md +12 -0
  8. package/assets/prompts/agents/reviewer.md +8 -0
  9. package/assets/prompts/agents/security-reviewer.md +7 -0
  10. package/assets/prompts/phases/build.md +5 -0
  11. package/assets/prompts/phases/review.md +5 -0
  12. package/assets/prompts/phases/test.md +5 -0
  13. package/assets/prompts/system.md +11 -0
  14. package/assets/schemas/agent-output.schema.json +225 -0
  15. package/assets/schemas/auth-status.schema.json +87 -0
  16. package/assets/schemas/command-result.schema.json +192 -0
  17. package/assets/schemas/commands.schema.json +952 -0
  18. package/assets/schemas/config.schema.json +2259 -0
  19. package/assets/schemas/doctor-report.schema.json +104 -0
  20. package/assets/schemas/events.schema.json +8514 -0
  21. package/assets/schemas/fake-script.schema.json +549 -0
  22. package/assets/schemas/inspect.schema.json +3133 -0
  23. package/assets/schemas/manifest.schema.json +79 -0
  24. package/assets/schemas/ownership.schema.json +52 -0
  25. package/assets/schemas/policy-verdict.schema.json +282 -0
  26. package/assets/schemas/project-model.schema.json +783 -0
  27. package/assets/schemas/project-status.schema.json +217 -0
  28. package/assets/schemas/reconcile-plan.schema.json +155 -0
  29. package/assets/schemas/run-diff.schema.json +177 -0
  30. package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
  31. package/assets/schemas/run-state.schema.json +1755 -0
  32. package/assets/schemas/runtime-capabilities.schema.json +1545 -0
  33. package/assets/schemas/sandbox-capabilities.schema.json +116 -0
  34. package/assets/schemas/skill.schema.json +87 -0
  35. package/assets/schemas/spec.schema.json +202 -0
  36. package/assets/schemas/tool-catalogue.schema.json +655 -0
  37. package/assets/schemas/trust-record.schema.json +38 -0
  38. package/assets/skills/README.md +9 -0
  39. package/assets/skills/inspect-surface.md +4 -0
  40. package/dist/agent-host.mjs +50 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +102 -0
  43. package/dist/chunks/__host-DgGPyco9.mjs +18 -0
  44. package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
  45. package/dist/chunks/approve-Cv135UqI.mjs +19 -0
  46. package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
  47. package/dist/chunks/auth-BT5oVp76.mjs +132 -0
  48. package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
  49. package/dist/chunks/auth-D8rCVY33.mjs +84 -0
  50. package/dist/chunks/auth-D8rCVY33.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-CU10T1tm.mjs +7 -0
  52. package/dist/chunks/brainstorm-CU10T1tm.mjs.map +1 -0
  53. package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
  54. package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
  55. package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
  56. package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
  57. package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
  58. package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
  59. package/dist/chunks/compose-Cr0hP0qm.mjs +7303 -0
  60. package/dist/chunks/compose-Cr0hP0qm.mjs.map +1 -0
  61. package/dist/chunks/config-DefHA2Lw.mjs +96 -0
  62. package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
  63. package/dist/chunks/contract-B0ra2-bm.mjs +295 -0
  64. package/dist/chunks/contract-B0ra2-bm.mjs.map +1 -0
  65. package/dist/chunks/contract-vYbwYr0Y.mjs +123 -0
  66. package/dist/chunks/contract-vYbwYr0Y.mjs.map +1 -0
  67. package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
  68. package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
  69. package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
  70. package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
  71. package/dist/chunks/discover-30Ciya_0.mjs +20 -0
  72. package/dist/chunks/discover-30Ciya_0.mjs.map +1 -0
  73. package/dist/chunks/doctor-DfCrKpfT.mjs +222 -0
  74. package/dist/chunks/doctor-DfCrKpfT.mjs.map +1 -0
  75. package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
  76. package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
  77. package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
  78. package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
  79. package/dist/chunks/init-Okz4mlhW.mjs +30 -0
  80. package/dist/chunks/init-Okz4mlhW.mjs.map +1 -0
  81. package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
  82. package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
  83. package/dist/chunks/logs-Giq5sUau.mjs +25 -0
  84. package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
  85. package/dist/chunks/migrate-DRoFUg53.mjs +19 -0
  86. package/dist/chunks/migrate-DRoFUg53.mjs.map +1 -0
  87. package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
  88. package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
  89. package/dist/chunks/pause-BASBfKLO.mjs +16 -0
  90. package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
  91. package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
  92. package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
  93. package/dist/chunks/providers-Dx78imKj.mjs +19 -0
  94. package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
  95. package/dist/chunks/reconcile-BfD785gd.mjs +26 -0
  96. package/dist/chunks/reconcile-BfD785gd.mjs.map +1 -0
  97. package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
  98. package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
  99. package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
  100. package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
  101. package/dist/chunks/retry-CLrUem1H.mjs +15 -0
  102. package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
  103. package/dist/chunks/review-BDdjb6kB.mjs +17 -0
  104. package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
  105. package/dist/chunks/run-CgTWYlcd.mjs +82 -0
  106. package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
  107. package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
  108. package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
  109. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  110. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  111. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  112. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  113. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  114. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  115. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  116. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  117. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  118. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  119. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  120. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  121. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  122. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  123. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  124. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  125. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  126. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  127. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  128. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  129. package/dist/chunks/src-JxawBQBn.mjs +446 -0
  130. package/dist/chunks/src-JxawBQBn.mjs.map +1 -0
  131. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  132. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  133. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  134. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  135. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  136. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  137. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  138. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  139. package/dist/cli.mjs +175 -0
  140. package/dist/cli.mjs.map +1 -0
  141. package/package.json +17 -32
  142. package/CHANGELOG.md +0 -1461
  143. package/bin/cli.js +0 -817
  144. package/bin/report.js +0 -141
  145. package/core/adapter/render.js +0 -415
  146. package/core/agents/implementer.template.md +0 -142
  147. package/core/agents/profile-reader.md +0 -50
  148. package/core/agents/release.md +0 -58
  149. package/core/agents/review.md +0 -179
  150. package/core/commands/cohorte-align-ds.md +0 -33
  151. package/core/commands/cohorte-audit.md +0 -58
  152. package/core/commands/cohorte-brainstorm.md +0 -70
  153. package/core/commands/cohorte-build.md +0 -219
  154. package/core/commands/cohorte-doctor.md +0 -194
  155. package/core/commands/cohorte-fix.md +0 -80
  156. package/core/commands/cohorte-fleet.md +0 -103
  157. package/core/commands/cohorte-init-pipeline.md +0 -31
  158. package/core/commands/cohorte-intake.md +0 -92
  159. package/core/commands/cohorte-patch.md +0 -118
  160. package/core/commands/cohorte-refactor.md +0 -59
  161. package/core/commands/cohorte-retro.md +0 -85
  162. package/core/commands/cohorte-review.md +0 -270
  163. package/core/commands/cohorte-ship.md +0 -122
  164. package/core/commands/cohorte-spec.md +0 -101
  165. package/core/commands/cohorte-update-pipeline.md +0 -165
  166. package/core/hooks/gate.py +0 -588
  167. package/core/runtimes/claude.json +0 -74
  168. package/core/runtimes/codex.json +0 -85
  169. package/core/runtimes/cursor.json +0 -76
  170. package/core/runtimes/gemini.json +0 -76
  171. package/core/runtimes/opencode.json +0 -73
  172. package/core/templates/agent-handoff.md +0 -34
  173. package/core/templates/brainstorm-return.md +0 -36
  174. package/core/templates/decisions.template.md +0 -42
  175. package/core/templates/design-brief.md +0 -44
  176. package/core/templates/patch.template.md +0 -86
  177. package/core/templates/pr-body.md +0 -25
  178. package/core/templates/review-feedback.md +0 -38
  179. package/core/templates/spec.template.md +0 -87
  180. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  181. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  182. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  183. package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
  184. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  185. package/core/workflows/audit.js +0 -240
  186. package/core/workflows/loop.js +0 -641
  187. package/core/workflows/refactor.js +0 -276
  188. package/core/workflows/review.js +0 -456
  189. package/install.ps1 +0 -103
  190. package/install.sh +0 -114
  191. package/lib/doctor.js +0 -534
  192. package/lib/runtime.js +0 -139
  193. package/lib/versions.js +0 -149
  194. package/lib/yaml.js +0 -129
  195. package/profile/PIPELINE.template.md +0 -259
  196. package/profile/SCHEMA.md +0 -735
  197. package/profile/cohorte.config.template.yaml +0 -43
  198. package/scripts/assert-gate-hook.mjs +0 -75
  199. package/scripts/kanban-move.sh +0 -313
  200. package/scripts/metrics/collect.mjs +0 -531
  201. package/scripts/metrics/prices.json +0 -42
  202. package/scripts/new-feature.sh.template +0 -98
  203. package/scripts/preflight.sh +0 -124
  204. package/scripts/remove-feature.sh.template +0 -56
  205. package/scripts/test-adapter.mjs +0 -437
  206. package/scripts/test-gate.mjs +0 -443
  207. package/scripts/test-kanban.mjs +0 -304
  208. package/scripts/test-lib.mjs +0 -356
  209. package/scripts/test-metrics.mjs +0 -156
  210. package/scripts/test-workflows.mjs +0 -758
  211. package/scripts/validate-core.mjs +0 -325
package/bin/cli.js DELETED
@@ -1,817 +0,0 @@
1
- #!/usr/bin/env node
2
- // cohorte — installer CLI for the portable multi-agent pipeline.
3
- // Cross-platform, dependency-free port of install.sh / install.ps1.
4
- //
5
- // npm i -g cohorte # once — the CLI, and the `cohorte` binary on PATH
6
- // cohorte install # bundle the core into <cwd>/.claude (committable)
7
- // cohorte install [target] # same, into another project
8
- // cohorte install --global # one shared core in ~/.claude
9
- // cohorte update [--global] # refresh the core, keep every generated file
10
- // cohorte version
11
- //
12
- // `npx cohorte <verb>` runs any of these without installing anything — it is the
13
- // escape hatch, not the documented path: a Francois extension panel can only spawn
14
- // a bare binary on PATH, and a globally installed CLI is what puts one there.
15
-
16
- 'use strict';
17
-
18
- const fs = require('fs');
19
- const os = require('os');
20
- const path = require('path');
21
- const { spawnSync } = require('child_process');
22
- const adapter = require('../core/adapter/render.js');
23
-
24
- const pkgRoot = path.resolve(__dirname, '..');
25
- const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
26
- const VERSION = pkg.version;
27
-
28
- const REPO_URL = 'https://github.com/TheBidouilleAgency/cohorte';
29
-
30
- // PreToolUse matcher for the gate hook. MUST cover Task as well as Bash:
31
- // gate.py's preflight phase gate keys off tool_name === "Task" (the `preflight`
32
- // block in a repo's gate-config.json). A Bash-only matcher never delivers a Task
33
- // dispatch to the hook, so that gate silently never fires — it was dead code
34
- // from 1.3.0 to 1.3.1. Keep in lockstep with install.sh and install.ps1.
35
- const GATE_MATCHER = 'Bash|Task';
36
-
37
- function usage(code) {
38
- console.log(`cohorte v${VERSION}
39
-
40
- Usage:
41
- cohorte install [target] [--global] [--runtime=a,b | --all-runtimes]
42
- cohorte update [target] [--global] [--runtime=a,b | --all-runtimes]
43
- cohorte metrics [target] [--days=N] [--since=ISO] [--runs] [--json]
44
- cohorte specs [target] [--porcelain | --json | --panel]
45
- cohorte doctor [target] [--porcelain | --json | --panel]
46
- cohorte version
47
-
48
- Commands:
49
- install Fresh install. Default: bundle the core into <target>/.claude
50
- (committed with the repo). --global: one shared core in ~/.claude,
51
- available to every project on this machine.
52
- update Refresh the stack-agnostic core only. PIPELINE.md, rendered surface
53
- agents, gate-config.json, settings.json and your filled
54
- ~/.claude/cohorte.config.yaml are never touched.
55
- metrics Cost and runtime per command, reconstructed from Claude Code's own
56
- transcripts (tokens, USD, wall/active time, subagent count). Reads
57
- ~/.claude/projects — nothing to enable, and it covers runs that
58
- already happened. Worktree-aware, so a feature adds up. --json for
59
- the raw rollup, --runs to include every individual invocation.
60
- specs The spec board of <target>: id, status, branch, title, read from the
61
- frontmatter of specs/*.md. --porcelain for one record per line with
62
- U+001F between fields, --json for the list.
63
- doctor The same checks /cohorte-doctor runs (core, pointer, profile, agents,
64
- hook, gate, retrieval, design, isolation, specs), without a coding
65
- agent in the loop. Exits 1 when any check is bad, 0 otherwise, so it
66
- drops into CI. --porcelain / --json as above.
67
- version Print the installed CLI version.
68
-
69
- --panel on specs, doctor and metrics emits the payload a Francois extension
70
- panel expects (github.com/TheBidouilleAgency/francois-plugin-cohorte).
71
-
72
- Runtimes (--runtime=): ${adapter.listRuntimes().join(', ')}
73
- The pipeline's doctrine is one set of source prompts; the installer renders them into
74
- whatever each coding agent reads (markdown + frontmatter, plain markdown, or TOML) and
75
- branches the text on what that agent can actually enforce — where there is no blocking
76
- hook the gate becomes an explicit check the commands call. Real subagents are required.
77
- Omit the flag and the installer detects what you have and asks; with no TTY it installs
78
- for Claude Code alone.`);
79
- process.exit(code);
80
- }
81
-
82
- // --- arg parsing -------------------------------------------------------------
83
- const args = process.argv.slice(2);
84
- let mode = null;
85
- let scope = 'project';
86
- let target = process.cwd();
87
-
88
- // Flags that belong to `metrics` and are forwarded verbatim to the collector. They are
89
- // listed here rather than parsed, so the collector stays the single source of truth for
90
- // its own options — but the generic "unknown flag" guard below must not reject them.
91
- const metricsFlags = [];
92
- const isMetricsFlag = (a) =>
93
- a === '--json' || a === '--runs' || a.startsWith('--days=') || a.startsWith('--since=');
94
-
95
- // How `specs`, `doctor` and `metrics` render. One variable, not one flag per command,
96
- // so `--porcelain` never means two different things depending on where it sits.
97
- let format = 'human';
98
-
99
- // Which coding agents to install for. Empty ⇒ resolved later (detect, then ask on a TTY,
100
- // then fall back to claude — the only behaviour that existed before 2.2.0).
101
- let wantRuntimes = [];
102
-
103
- for (const a of args) {
104
- if (a === 'install' || a === 'update' || a === 'metrics'
105
- || a === 'specs' || a === 'doctor') mode = a;
106
- else if (a === '--porcelain' || a === '--panel') format = a.slice(2);
107
- else if (isMetricsFlag(a)) { if (a === '--json') format = 'json'; metricsFlags.push(a); }
108
- else if (a === '--all-runtimes') wantRuntimes = adapter.listRuntimes();
109
- else if (a === '--runtimes' || a === '--runtime') {
110
- console.error('error: --runtime needs a value, e.g. --runtime=codex,cursor'); process.exit(2);
111
- }
112
- else if (a.startsWith('--runtime=') || a.startsWith('--runtimes=')) {
113
- for (const id of a.slice(a.indexOf('=') + 1).split(',').map((s) => s.trim()).filter(Boolean)) {
114
- if (!adapter.listRuntimes().includes(id)) {
115
- console.error(`error: unknown runtime "${id}" (known: ${adapter.listRuntimes().join(', ')})`);
116
- process.exit(2);
117
- }
118
- if (!wantRuntimes.includes(id)) wantRuntimes.push(id);
119
- }
120
- }
121
- else if (a === 'version' || a === '--version' || a === '-v') { console.log(VERSION); process.exit(0); }
122
- else if (a === '--global' || a === '-g') scope = 'global';
123
- else if (a === 'help' || a === '--help' || a === '-h') usage(0);
124
- else if (a.startsWith('-')) { console.error(`error: unknown flag: ${a}`); usage(2); }
125
- else target = path.resolve(a);
126
- }
127
- if (!mode) usage(args.length ? 2 : 0);
128
-
129
- // --- specs / doctor: read-only reports on <target> ---------------------------
130
- // Both read through lib/, so every consumer of the pipeline's state — the CLI, a
131
- // Francois panel — answers from one implementation. Nothing here writes or spawns.
132
- if (mode === 'specs' || mode === 'doctor') {
133
- const report = require('./report.js');
134
- const { state, scanSpecs } = require('../lib/doctor.js');
135
- const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
136
-
137
- if (mode === 'specs') {
138
- const records = report.specRecords(target, scanSpecs);
139
- if (format === 'json') console.log(JSON.stringify({ project: target, specs: records }, null, 2));
140
- else if (format === 'porcelain') { if (records.length) console.log(report.specsPorcelain(records)); }
141
- else if (format === 'panel') console.log(report.specsPanel(records));
142
- else console.log(report.specsHuman(records));
143
- process.exit(0);
144
- }
145
-
146
- // `state` is async (it probes versions), so this branch owns the whole tail.
147
- state({ projectRoot: target, globalDir, cliVersion: VERSION }).then((s) => {
148
- const records = report.checkRecords(s);
149
- if (format === 'json') console.log(JSON.stringify(s, null, 2));
150
- else if (format === 'porcelain') { if (records.length) console.log(report.doctorPorcelain(records)); }
151
- else if (format === 'panel') console.log(report.doctorPanel(records));
152
- else console.log(report.doctorHuman(s, records));
153
- // A bad check is a failure the shell can branch on; warn and skip are not.
154
- process.exit(s.summary.bad > 0 && format !== 'panel' ? 1 : 0);
155
- }).catch((e) => {
156
- console.error(`error: ${e && e.message ? e.message : e}`);
157
- process.exit(1);
158
- });
159
- return;
160
- }
161
-
162
- // --- metrics: cost + runtime per command ------------------------------------
163
- // The collector is ESM and this CLI is CommonJS, so it runs as a child process rather
164
- // than being required. stdio is inherited so --json stays pipeable.
165
- if (mode === 'metrics') {
166
- // `--panel` is not in isMetricsFlag (it is not metrics-specific), so forward it here.
167
- if (format === 'panel') metricsFlags.push('--panel');
168
- const script = path.join(pkgRoot, 'scripts', 'metrics', 'collect.mjs');
169
- if (!fs.existsSync(script)) {
170
- console.error(`error: metrics collector not found at ${script}`);
171
- process.exit(1);
172
- }
173
- const r = spawnSync(process.execPath, [script, target, ...metricsFlags], { stdio: 'inherit' });
174
- process.exit(r.status == null ? 1 : r.status);
175
- }
176
-
177
- // --- paths -------------------------------------------------------------------
178
- const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
179
- const src = pkgRoot;
180
-
181
- if (!fs.existsSync(path.join(src, 'core'))) {
182
- console.error(`error: pipeline source not found (no core/ in ${src})`);
183
- process.exit(1);
184
- }
185
-
186
- // Filled per runtime by the install loop. `dest` stays the Claude-shaped root (`.claude` /
187
- // `~/.claude`) so every helper below — hook registration, the legacy scrubs — keeps working
188
- // unchanged; non-Claude runtimes put their shared assets under `paths.core` instead.
189
- // CLAUDE_CONFIG_DIR moves Claude Code's whole tree; the registry declares it as `~/.claude`,
190
- // so every resolution has to be re-rooted or the install lands half here, half there.
191
- const PATH_OVERRIDES = { '~/.claude': globalDir };
192
-
193
- let runtime = adapter.loadRuntime('claude');
194
- let paths = adapter.resolvePaths(runtime, scope, target, { overrides: PATH_OVERRIDES });
195
- let dest = scope === 'global' ? globalDir : path.join(target, '.claude');
196
-
197
- // 2.2.0 retired /cohorte-loop — the autonomous build→review→fix driver. Copy-over never
198
- // deletes, so on an upgrade its command file and its two scripts would survive as a decoy the
199
- // model can still fire: a command that spawns headless children against a core that no longer
200
- // documents them. Scrub the command in every shape this runtime could have installed it, plus
201
- // the scripts, for every runtime rather than only the Claude layout.
202
- function scrubLoop() {
203
- const ext = runtime.command.ext || '.md';
204
- fs.rmSync(path.join(paths.commands, `cohorte-loop${ext}`), { recursive: true, force: true });
205
- // Skills are a directory (`<name>/SKILL.md`), so the parent has to go too.
206
- if (runtime.command.format === 'skill') {
207
- fs.rmSync(path.join(paths.commands, 'cohorte-loop'), { recursive: true, force: true });
208
- }
209
- for (const f of ['loop.sh', 'loop-detach.sh']) {
210
- fs.rmSync(path.join(paths.core, 'pipeline', 'scripts', f), { force: true });
211
- }
212
- }
213
-
214
- // Resolve every capability conditional in the copied templates, in place. Unlike commands
215
- // and agents these get no frontmatter and no preamble of their own: a template is always
216
- // read from within a command that already established the runtime.
217
- function resolveTemplateConditionals(dir) {
218
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
219
- const p = path.join(dir, e.name);
220
- if (e.isDirectory()) { resolveTemplateConditionals(p); continue; }
221
- if (!p.endsWith('.md')) continue;
222
- const src = fs.readFileSync(p, 'utf8');
223
- fs.writeFileSync(p, adapter.adaptInstructions(src, runtime));
224
- }
225
- }
226
-
227
- // --- helpers (mirror install.sh) --------------------------------------------
228
- function copyCore() {
229
- // The runtime-neutral assets. `commands` is NOT among them any more: it is rendered per
230
- // runtime by renderSurfaces() into whatever directory that agent actually reads.
231
- // `workflows` = the deterministic orchestration scripts (review/audit/refactor), copied
232
- // only where a Workflow engine exists — shipping them elsewhere would advertise a path
233
- // the rendered commands have already conditionalled out.
234
- const dirs = ['hooks', 'templates'];
235
- if (runtime.capabilities.workflows) dirs.push('workflows');
236
- for (const d of dirs) {
237
- fs.cpSync(path.join(src, 'core', d), path.join(paths.core, d), {
238
- recursive: true,
239
- force: true,
240
- // Never carry a Python bytecode cache into a user's .claude. It appears in a
241
- // source checkout the moment anyone compiles or imports gate.py (CI does), it
242
- // is machine- and interpreter-specific, and copy-over never deletes it later.
243
- filter: s => !s.split(/[\\/]/).includes('__pycache__') && !s.endsWith('.pyc'),
244
- });
245
- }
246
- fs.rmSync(path.join(paths.core, 'hooks', '__pycache__'), { recursive: true, force: true });
247
- // Templates are read at run time BY a command, so they inherit that command's preamble and
248
- // its `<core>`/`<state>` tokens — but their capability conditionals are still theirs to
249
- // resolve, and copying them raw would leave `cohorte:if` markers in the model's context.
250
- resolveTemplateConditionals(path.join(paths.core, 'templates'));
251
- // 0.1.19 renamed questionnaire-domain-brief.md → research-brief.md; drop the stale copy.
252
- fs.rmSync(path.join(paths.core, 'templates', 'questionnaire-domain-brief.md'), { force: true });
253
- const pipelineDir = path.join(paths.core, 'pipeline');
254
- fs.mkdirSync(path.join(pipelineDir, 'scripts'), { recursive: true });
255
- for (const f of ['PIPELINE.template.md', 'SCHEMA.md', 'cohorte.config.template.yaml']) {
256
- fs.copyFileSync(path.join(src, 'profile', f), path.join(pipelineDir, f));
257
- }
258
- // SCHEMA.md is the agents' rulebook, read at RUN time from `<core>/pipeline/`, so its
259
- // capability conditionals have to be resolved here like any other prompt — a `cohorte:if`
260
- // left in it would reach the model as visible noise, and the wrong branch would tell it to
261
- // write where this runtime does not look.
262
- resolveTemplateConditionals(pipelineDir);
263
- // Copy the *.template files AND the shipped executables (kanban-move.sh,
264
- // preflight.sh). Until 1.2.4 this loop took only `.template`, so every
265
- // `cohorte install/update` produced a core missing both scripts — and since
266
- // every caller chains them with `|| true`, the result was silent: no kanban card
267
- // moves, no error. The shell installers named them explicitly
268
- // and this port drifted. The rule below needs no list to keep in sync: a `<x>.sh`
269
- // with an `<x>.sh.template` sibling is rendered per-project by /cohorte-init-pipeline, so
270
- // only the template ships; every other `.sh` is a shipped executable.
271
- const scriptFiles = fs.readdirSync(path.join(src, 'scripts'));
272
- for (const f of scriptFiles) {
273
- const isTemplate = f.endsWith('.template');
274
- const isShipped = f.endsWith('.sh') && !scriptFiles.includes(`${f}.template`);
275
- if (!isTemplate && !isShipped) continue;
276
- const target = path.join(pipelineDir, 'scripts', f);
277
- fs.copyFileSync(path.join(src, 'scripts', f), target);
278
- if (isShipped && process.platform !== 'win32') {
279
- try { fs.chmodSync(target, 0o755); } catch { /* optional */ }
280
- }
281
- }
282
- // 2.3.0 removed telemetry. The copy loop above is by-rule, so it simply stops shipping the
283
- // sender — but copy-over never deletes, and an existing install would keep an executable
284
- // that still POSTs to the collector. Scrub it. The dead `telemetry:` block in the user's
285
- // config is not this installer's to parse: /cohorte-update-pipeline deletes it, where an
286
- // agent can edit the YAML surgically (SCHEMA.md §Reconcile step 5).
287
- fs.rmSync(path.join(pipelineDir, 'scripts', 'telemetry-send.sh'), { force: true });
288
- // The per-surface implementer template is rendered per SURFACE later, by
289
- // /cohorte-init-pipeline inside the target repo — but its runtime shape (frontmatter keys,
290
- // capability branches, the preamble) is fixed here, at install time. Run it through the
291
- // adapter with its <SURFACE_*> placeholders untouched, so init only fills the blanks.
292
- fs.writeFileSync(path.join(pipelineDir, 'implementer.template.md'), adapter.renderAgent({
293
- source: fs.readFileSync(path.join(src, 'core', 'agents', 'implementer.template.md'), 'utf8'),
294
- name: 'implementer.template', runtime, paths, projectRoot: target,
295
- }).content);
296
- // What the runtimes ARE, for /cohorte-doctor and anything else that must branch on them at
297
- // run time. A MAP, merged across installs, not one record: every non-Claude runtime shares
298
- // the same `.cohorte` core, so a single-record file made each install silently erase the
299
- // previous one and left /cohorte-doctor diagnosing the wrong agent. Each rendered command
300
- // already names its own runtime in its preamble; this file supplies the details.
301
- const registry = path.join(pipelineDir, 'runtimes.json');
302
- let installed = {};
303
- try { installed = JSON.parse(fs.readFileSync(registry, 'utf8')) || {}; } catch { /* first install */ }
304
- installed[runtime.id] = {
305
- label: runtime.label, scope, core_version: VERSION,
306
- capabilities: runtime.capabilities,
307
- excluded_commands: runtime.exclude_commands || [],
308
- paths: {
309
- core: paths.core, commands: paths.commands,
310
- agents: paths.agents || path.join(paths.core, 'agents'),
311
- surface_agents: runtime.id === 'codex' ? '.codex/agents' : undefined,
312
- agent_ext: runtime.agent.ext,
313
- // Where the gate registration lives. `cohorte doctor` needs it to tell a registered
314
- // hook from a missing one without re-deriving each runtime's config layout itself.
315
- hooks_config: paths.hooks_config,
316
- state: adapter.stateDir(runtime), config: adapter.configPath(runtime),
317
- },
318
- };
319
- fs.writeFileSync(registry, JSON.stringify(installed, null, 2) + '\n');
320
- fs.rmSync(path.join(pipelineDir, 'runtime.json'), { force: true }); // 2.2.0-dev single-record form
321
- // /cohorte-doctor reads this to tell the human what they're missing; the shell installers
322
- // have always copied it, this port never did.
323
- const changelog = path.join(src, 'CHANGELOG.md');
324
- if (fs.existsSync(changelog)) fs.copyFileSync(changelog, path.join(pipelineDir, 'CHANGELOG.md'));
325
- fs.writeFileSync(path.join(pipelineDir, 'VERSION'), VERSION + '\n');
326
- if (process.platform !== 'win32') {
327
- try { fs.chmodSync(path.join(paths.core, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
328
- }
329
- scrubTddGate();
330
- scrubLoop();
331
- }
332
-
333
- // The TDD gate was removed in 0.1.6. Older installs have hooks/tdd_gate.py on disk and
334
- // registered in settings.json — copy-over never deletes, and a registered hook whose file
335
- // is gone errors on every Write/Edit, so scrub both.
336
- function scrubTddGate() {
337
- fs.rmSync(path.join(dest, 'hooks', 'tdd_gate.py'), { force: true });
338
- const settingsPath = path.join(dest, 'settings.json');
339
- let data;
340
- try { data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return; }
341
- const pre = data && data.hooks && Array.isArray(data.hooks.PreToolUse) ? data.hooks.PreToolUse : null;
342
- if (!pre) return;
343
- const kept = pre.filter(entry => !(entry.hooks || []).some(
344
- h => typeof h.command === 'string' && h.command.trim().endsWith('tdd_gate.py')));
345
- if (kept.length !== pre.length) {
346
- data.hooks.PreToolUse = kept;
347
- fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n');
348
- console.log(' · removed the retired tdd_gate.py hook (file + settings registration)');
349
- }
350
- }
351
-
352
- // The fixed (non-rendered-per-surface) agents — review, release, profile-reader — plus every
353
- // command. Both go through the adapter, so the same source text lands as a native subagent +
354
- // slash command on Claude/OpenCode and as a persona file + prompt file everywhere else.
355
- function renderSurfaces() {
356
- const agentsOut = paths.agents || path.join(paths.core, 'agents');
357
- fs.mkdirSync(agentsOut, { recursive: true });
358
- fs.mkdirSync(paths.commands, { recursive: true });
359
-
360
- // Every agent in core/agents/ EXCEPT the *.template.md ones, which /cohorte-init-pipeline renders
361
- // per-surface. Until 1.2.6 this was a hardcoded ['review.md', 'release.md'] that never grew
362
- // the agents the shell installers copy, so `cohorte install` shipped a command with no
363
- // agent to dispatch — the run reported the command as not installed.
364
- // Reading the directory needs no list to keep in sync with the shell installers.
365
- const agentDir = path.join(src, 'core', 'agents');
366
- for (const f of fs.readdirSync(agentDir)) {
367
- if (!f.endsWith('.md') || f.endsWith('.template.md')) continue;
368
- const out = adapter.renderAgent({
369
- source: fs.readFileSync(path.join(agentDir, f), 'utf8'),
370
- name: f.slice(0, -3), runtime, paths, projectRoot: target,
371
- });
372
- fs.writeFileSync(path.join(agentsOut, out.filename), out.content);
373
- }
374
-
375
- const cmdDir = path.join(src, 'core', 'commands');
376
- const excluded = runtime.exclude_commands || [];
377
- for (const f of fs.readdirSync(cmdDir)) {
378
- if (!f.endsWith('.md')) continue;
379
- // A command the runtime cannot actually execute is not installed at all. Shipping it
380
- // would put a working-looking entry in the slash menu that fails on first use — and the
381
- // human would reasonably read that as the pipeline being broken.
382
- if (excluded.includes(f.slice(0, -3))) continue;
383
- const out = adapter.renderCommand({
384
- source: fs.readFileSync(path.join(cmdDir, f), 'utf8'),
385
- name: f.slice(0, -3), runtime, paths, projectRoot: target,
386
- });
387
- // A Codex skill is `<name>/SKILL.md`, so the filename can carry a directory.
388
- const dest = path.join(paths.commands, out.filename);
389
- fs.mkdirSync(path.dirname(dest), { recursive: true });
390
- fs.writeFileSync(dest, out.content);
391
- }
392
-
393
- // The scrubs below repair Claude-shaped installs that predate the adapter. They are a
394
- // no-op anywhere else — a fresh non-Claude runtime has no history to clean up — so guard
395
- // them rather than rm-ing paths that never existed.
396
- if (runtime.id !== 'claude') return;
397
- // 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
398
- // copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
399
- fs.rmSync(path.join(dest, 'agents', 'questionnaire-researcher.md'), { force: true });
400
- // 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent+command.
401
- fs.rmSync(path.join(dest, 'agents', 'smoke.md'), { force: true });
402
- fs.rmSync(path.join(dest, 'commands', 'smoke.md'), { force: true });
403
- // 1.4.0 removed /cycle and its workflow — and no installer ever scrubbed them, so every
404
- // install since has kept offering a command that dispatches a workflow whose phases were
405
- // later deleted. A dead command is worse than a missing one: the model can still fire it.
406
- fs.rmSync(path.join(dest, 'commands', 'cycle.md'), { force: true });
407
- fs.rmSync(path.join(dest, 'workflows', 'cycle.js'), { force: true });
408
- // 1.6.0 renamed /loop → /drive: Claude Code's own built-in /loop shadowed ours, so a leftover
409
- // commands/loop.md is a command the user can never reach — scrub it rather than leave a decoy.
410
- fs.rmSync(path.join(dest, 'commands', 'loop.md'), { force: true });
411
- // 2.0.0 prefixed every command with `cohorte-`, which ends the shadowing problem for good.
412
- // Copy-over never deletes, so all 13 bare names would survive an upgrade as decoys — and a
413
- // stale /build is the worst kind: it still dispatches implementers, from a 1.x command file
414
- // that knows nothing of this core's contract. /drive goes too (it became /cohorte-loop).
415
- for (const c of ['align-ds', 'audit', 'brainstorm', 'build', 'doctor', 'drive', 'fix',
416
- 'init-pipeline', 'refactor', 'review', 'ship', 'spec', 'update-pipeline']) {
417
- fs.rmSync(path.join(dest, 'commands', `${c}.md`), { force: true });
418
- }
419
- scrubResearchQuestionnaire();
420
- }
421
-
422
- // The research + questionnaire capability was removed. Older installs have its agents, commands,
423
- // templates and template-step dirs on disk; copy-over never deletes, so scrub every orphan.
424
- function scrubResearchQuestionnaire() {
425
- for (const f of ['research-agent.md', 'questionnaire-architect.md',
426
- 'questionnaire-writer.md', 'questionnaire-validator.md']) {
427
- fs.rmSync(path.join(dest, 'agents', f), { force: true });
428
- }
429
- for (const f of ['research.md', 'questionnaire.md']) {
430
- fs.rmSync(path.join(dest, 'commands', f), { force: true });
431
- }
432
- for (const f of ['research-brief.md', 'questionnaire-blueprint.md',
433
- 'questionnaire-declaration.md', 'questionnaire-verdict.md']) {
434
- fs.rmSync(path.join(dest, 'templates', f), { force: true });
435
- }
436
- for (const d of ['research', 'questionnaire']) {
437
- fs.rmSync(path.join(dest, 'templates', 'steps', d), { recursive: true, force: true });
438
- }
439
- }
440
-
441
- // --- interactive config helpers ---------------------------------------------
442
- // Ask one question on the TTY. Resolves to the trimmed answer (or '' on EOF).
443
- function ask(question) {
444
- const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
445
- return new Promise(res => rl.question(question, a => { rl.close(); res((a || '').trim()); }));
446
- }
447
- function yes(a) { return /^(y|yes|o|oui)$/i.test(a); }
448
-
449
- // Set the value on the line carrying `# cfg:<cfgKey>`, preserving the yaml key + the comment.
450
- // The config template anchors every interactive field this way, so we never parse YAML.
451
- // Line-scoped on purpose (a multiline regex would let \s span newlines and mangle keys).
452
- function setCfg(text, cfgKey, value) {
453
- const marker = `# cfg:${cfgKey}`;
454
- return text.split('\n').map(line => {
455
- const idx = line.indexOf(marker);
456
- if (idx === -1) return line;
457
- const m = line.slice(0, idx).match(/^(\s*[\w.]+:\s*)/); // " key: "
458
- return m ? `${m[1]}${value} ${line.slice(idx)}` : line;
459
- }).join('\n');
460
- }
461
-
462
- // Register gate.py as this runtime's blocking pre-command hook. Four of the five can do it —
463
- // only OpenCode has no hook contract (plugins are a different thing), and there the rendered
464
- // commands call `gate.py --check` explicitly instead.
465
- //
466
- // Three config shapes, all reconciled rather than appended: drop every existing cohorte gate
467
- // entry, then add exactly one. Idempotent, collapses duplicates an older installer left, and
468
- // repairs a stale matcher in place — an append-if-absent would find the stale entry and skip.
469
- function registerRuntimeHook() {
470
- const spec = runtime.hook;
471
- const cfgPath = runtime.scopes[scope].hooks_config;
472
- if (!spec || !cfgPath) return 'n/a (this runtime has no hook contract — the gate runs as an explicit --check)';
473
- const python = findPython();
474
- if (!python) return 'skipped (no python found — register the gate hook manually)';
475
-
476
- const file = path.join(paths.core, 'hooks', 'gate.py');
477
- // Quoted on every platform — see registerGlobalHook: an unquoted path with a space breaks
478
- // every tool call in the session, and the config dirs these runtimes use live under the
479
- // user's home, which on macOS routinely contains one.
480
- const cmd = `${python} "${file}" --runtime ${runtime.id}`;
481
- const isOurs = (s) => typeof s === 'string' && s.includes('gate.py');
482
-
483
- const dest = adapter.expandHome(cfgPath);
484
- const abs = path.isAbsolute(dest) ? dest : path.join(target, dest);
485
- fs.mkdirSync(path.dirname(abs), { recursive: true });
486
- let data = {};
487
- try {
488
- const parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
489
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed;
490
- } catch { /* absent or invalid → start fresh */ }
491
- if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {};
492
-
493
- if (spec.format === 'cursor') {
494
- // { "version": 1, "hooks": { "beforeShellExecution": [ { "command": "…" } ] } }
495
- if (!data.version) data.version = 1;
496
- const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
497
- data.hooks[spec.event] = list.filter((e) => !isOurs(e && e.command));
498
- data.hooks[spec.event].push({ command: cmd });
499
- } else {
500
- // Claude/Codex PreToolUse and Gemini BeforeTool share the matcher-group shape.
501
- const list = Array.isArray(data.hooks[spec.event]) ? data.hooks[spec.event] : [];
502
- data.hooks[spec.event] = list.filter(
503
- (e) => !((e && e.hooks) || []).some((h) => isOurs(h && h.command)));
504
- data.hooks[spec.event].push({
505
- matcher: spec.matcher, hooks: [{ type: 'command', command: cmd }],
506
- });
507
- }
508
- fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
509
- const where = adapter.displayPath(abs, target);
510
- return spec.supports_ask
511
- ? `registered in ${where} (${spec.event})`
512
- : `registered in ${where} (${spec.event}) — no confirmation tier here, so a gated command is denied rather than queried`;
513
- }
514
-
515
- // Fill the seeded config from a short TTY interview (shared Obsidian vault for the kanban mirror).
516
- // Kanban is per-project, so it is wired later by /cohorte-init-pipeline — not asked here.
517
- async function promptConfig(text) {
518
- console.log('\n Quick setup (Enter to skip — you can also wire this later via');
519
- console.log(' /cohorte-init-pipeline or /cohorte-update-pipeline):');
520
- const vault = await ask(' · absolute path to your shared Obsidian vault (for the kanban mirror): ');
521
- if (vault) text = setCfg(text, 'vault_path', `"${vault}"`);
522
- return text;
523
- }
524
-
525
- // The pipeline capability config is USER-level (vault, Notion DB, kanban boards) — it lives in
526
- // ~/.claude regardless of install scope. Seed it only if the user has no copy (consolidated OR
527
- // legacy). On a TTY, offer a quick interview to fill it; otherwise seed disabled defaults.
528
- async function seedConfig() {
529
- // One config per human, but it has to live somewhere the running agent can find without
530
- // being told: `~/.claude` for Claude Code (unchanged — existing files stay authoritative),
531
- // `~/.cohorte` for every other runtime. The shipped scripts probe both, in that order, so
532
- // a human who drives one repo from two agents still has a single board and a single consent.
533
- //
534
- // CLAUDE_CONFIG_DIR moves the whole `~/.claude` tree (globalDir already follows it), but
535
- // adapter.configPath() speaks in literal `~/.claude/…` — expanding through HOME alone
536
- // seeded a file at a path no reader probes (kanban-move.sh follows
537
- // CLAUDE_CONFIG_DIR), missed a filled config there (re-seeding disabled defaults beside
538
- // it — the exact two-file fork this function's comments forbid), and printed a banner
539
- // path that did not exist. Re-root the claude-shaped path onto globalDir so the seed,
540
- // the existing-file check, the banner and every reader agree on one file.
541
- const homeClaude = path.join(os.homedir(), '.claude');
542
- const rerootClaude = (p) =>
543
- p === homeClaude || p.startsWith(homeClaude + path.sep)
544
- ? path.join(globalDir, p.slice(homeClaude.length + 1) || '.')
545
- : p;
546
- const cfg = rerootClaude(adapter.expandHome(adapter.configPath(runtime)));
547
- fs.mkdirSync(path.dirname(cfg), { recursive: true });
548
- // Pre-rename names, newest first — read as a fallback so upgrades don't lose the config.
549
- const legacy = ['thebidouille.config.yaml']
550
- .map((n) => path.join(globalDir, n)).find(fs.existsSync);
551
- if (fs.existsSync(cfg)) { console.log(` · kept your existing ${cfg}`); return; }
552
- // A second runtime must not fork the config: two files means two boards and two consent
553
- // records, and the human edits whichever one they happen to open. The scripts read
554
- // `~/.cohorte` first, so seeding it here would SHADOW a filled `~/.claude` copy.
555
- const claudeCfg = rerootClaude(adapter.expandHome(adapter.configPath({ id: 'claude' })));
556
- if (cfg !== claudeCfg && fs.existsSync(claudeCfg)) {
557
- console.log(` · reusing your existing ${claudeCfg} (the scripts read it as a fallback)`);
558
- return;
559
- }
560
- if (legacy) {
561
- console.log(` · found legacy ${legacy} — kept as-is (still read as a fallback).`);
562
- console.log(' Run /cohorte-update-pipeline to migrate it into cohorte.config.yaml + wire the kanban.');
563
- return;
564
- }
565
- fs.mkdirSync(path.dirname(cfg), { recursive: true });
566
- let text = fs.readFileSync(path.join(src, 'profile', 'cohorte.config.template.yaml'), 'utf8');
567
- if (process.stdin.isTTY && process.stdout.isTTY) {
568
- text = await promptConfig(text);
569
- fs.writeFileSync(cfg, text);
570
- console.log(` · seeded ${cfg} from your answers`);
571
- } else {
572
- fs.writeFileSync(cfg, text);
573
- console.log(` · seeded ${cfg} (disabled defaults — enable via /cohorte-init-pipeline or /cohorte-update-pipeline)`);
574
- }
575
- }
576
-
577
- function findPython() {
578
- const candidates = process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python'];
579
- for (const c of candidates) {
580
- const r = spawnSync(c, ['--version'], { stdio: 'ignore', shell: false });
581
- if (r.status === 0) return c;
582
- }
583
- return null;
584
- }
585
-
586
- // Register the profile-driven gate hook in the GLOBAL settings.json. Idempotent: the
587
- // hook reads each repo's own .claude/gate-config.json (and no-ops where absent),
588
- // so one registration serves every project.
589
- function registerGlobalHook() {
590
- const python = findPython();
591
- if (!python) return 'skipped (no python found — register the gate hook manually)';
592
- const settingsPath = path.join(dest, 'settings.json');
593
- let data = {};
594
- try {
595
- const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
596
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed;
597
- } catch { /* absent or invalid → start fresh */ }
598
- if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {};
599
- if (!Array.isArray(data.hooks.PreToolUse)) data.hooks.PreToolUse = [];
600
-
601
- const file = path.join(dest, 'hooks', 'gate.py');
602
- const base = path.basename(file);
603
- // Trailing-quote tolerant: the Windows form is `py "C:\...\gate.py"`, and a
604
- // bare .endsWith() missed it — which is how repeat `cohorte install`
605
- // runs accumulated a duplicate registration every time (gate.py then ran
606
- // once per copy on every Bash call).
607
- const isGate = entry => (entry.hooks || []).some(
608
- h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith(base));
609
- // ALWAYS quote the path. The hook command is handed to a shell, so a config dir containing a
610
- // space — `~/Library/Application Support/…`, which is exactly where a desktop host puts it —
611
- // splits into two arguments and python reports `can't open file '/Users/x/Library/Application'`
612
- // on EVERY tool call, in a session the human cannot easily un-break. Only the Windows form was
613
- // quoted, so this failed silently on precisely the platform where the path is most likely to
614
- // contain a space.
615
- const cmd = `${python} "${file}"`;
616
-
617
- // Reconcile rather than append-if-absent: drop every existing gate.py
618
- // registration, then add exactly one. Idempotent, collapses duplicates older
619
- // installers left behind, and upgrades a stale "Bash"-only matcher in place —
620
- // an append-if-absent would find the stale entry and skip, pinning the bug.
621
- data.hooks.PreToolUse = data.hooks.PreToolUse.filter(e => !isGate(e));
622
- data.hooks.PreToolUse.push({ matcher: GATE_MATCHER, hooks: [{ type: 'command', command: cmd }] });
623
-
624
- fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n');
625
- return 'ok';
626
- }
627
-
628
- // Bump only the core_version in a repo's committed .claude/pipeline.json (bundled mode).
629
- // Leaves every other field intact; no-ops if the pointer is absent or has no core_version.
630
- function bumpPointerVersion(ptr) {
631
- if (!fs.existsSync(ptr)) return;
632
- let data;
633
- try { data = JSON.parse(fs.readFileSync(ptr, 'utf8')); } catch { return; }
634
- if (data && typeof data === 'object' && 'core_version' in data) {
635
- data.core_version = VERSION;
636
- fs.writeFileSync(ptr, JSON.stringify(data, null, 2) + '\n');
637
- }
638
- }
639
-
640
- // --- runtime selection -------------------------------------------------------
641
-
642
- // Which coding agents this install targets. Explicit flags win. Otherwise: detect what is
643
- // configured on this machine, and — on a TTY — let the human confirm, because installing
644
- // into a runtime they don't use litters a config dir they never asked us to touch. With no
645
- // TTY and no flag we install for Claude Code alone: the behaviour of every version before
646
- // the adapter, so a scripted `cohorte install` keeps doing exactly what it did.
647
- async function selectRuntimes() {
648
- if (wantRuntimes.length) return wantRuntimes;
649
- const found = adapter.detectRuntimes();
650
- if (found.length <= 1) return found.length ? found : ['claude'];
651
- if (!(process.stdin.isTTY && process.stdout.isTTY)) {
652
- console.log(` · several runtimes detected (${found.join(', ')}) — installing for claude only.`);
653
- console.log(' Pass --runtime=a,b or --all-runtimes to cover the others.');
654
- return ['claude'];
655
- }
656
- console.log('\n Coding agents detected on this machine:');
657
- found.forEach((id, i) => console.log(` ${i + 1}. ${adapter.loadRuntime(id).label}`));
658
- const a = await ask(' Install for which? (Enter = all, or e.g. 1,3): ');
659
- if (!a) return found;
660
- const picked = a.split(/[,\s]+/).map((n) => found[parseInt(n, 10) - 1]).filter(Boolean);
661
- return picked.length ? [...new Set(picked)] : found;
662
- }
663
-
664
- // --- staleness notice --------------------------------------------------------
665
- // `install` and `update` write a core taken from THIS package, so the core is only
666
- // as fresh as the CLI that ran. `npx cohorte@latest` made that self-correcting; a
667
- // global install does not — `cohorte update` on a pinned 2.5.0 would re-lay the
668
- // 2.5.0 core forever and report success, which is an update that updates nothing.
669
- // So the CLI checks its own version against the registry and says what to run.
670
- // Never fatal, never blocking a successful install: a short fetch, no `npm view`
671
- // fallback, and silence on any failure. COHORTE_NO_VERSION_CHECK / CI opt out.
672
- async function staleVersionNotice() {
673
- if (process.env.COHORTE_NO_VERSION_CHECK || process.env.CI) return;
674
- let latest = null;
675
- try {
676
- const v = require('../lib/versions.js');
677
- latest = await v.latestNpm({ timeoutMs: 2500, fallback: false });
678
- if (!latest || v.cmpSemver(VERSION, latest) >= 0) return;
679
- } catch { return; }
680
- // How you got here decides the fix: under npx the package is transient (and may
681
- // have come from the cache rather than the registry), so the answer is to pin
682
- // @latest; with a global install the answer is to upgrade the global.
683
- const viaNpx = /[\\/]_npx[\\/]/.test(pkgRoot);
684
- console.log(`
685
- ! You ran cohorte ${VERSION}, but ${latest} is published — the core just written is ${VERSION}.
686
- ${viaNpx
687
- ? 'Re-run as npx cohorte@latest <same command> to lay down the current core.'
688
- : 'Upgrade with npm i -g cohorte@latest and re-run the same command.'}`);
689
- }
690
-
691
- // --- run ---------------------------------------------------------------------
692
- (async () => {
693
- const selected = await selectRuntimes();
694
- for (const id of selected) {
695
- runtime = adapter.loadRuntime(id);
696
- paths = adapter.resolvePaths(runtime, scope, target, { overrides: PATH_OVERRIDES });
697
- dest = runtime.id === 'claude'
698
- ? (scope === 'global' ? globalDir : path.join(target, '.claude'))
699
- : paths.core;
700
- fs.mkdirSync(paths.core, { recursive: true });
701
- if (selected.length > 1) console.log(`\n── ${runtime.label} ──`);
702
- await installOne();
703
- }
704
- if (selected.length > 1) {
705
- console.log(`\n✓ installed for ${selected.length} runtimes: ${selected.join(', ')}.`);
706
- console.log(' The doctrine is identical; what differs is enforcement — run /cohorte-doctor in');
707
- console.log(' each one to see what it can and cannot guarantee.');
708
- }
709
- // Last, so a stale CLI is the final thing on screen rather than scrolled past.
710
- await staleVersionNotice();
711
- })();
712
-
713
- async function installOne() {
714
- if (runtime.id !== 'claude') {
715
- // Everything below the Claude branch assumes Claude's own layout (settings.json hook
716
- // registration, the `.claude` pointer). A non-Claude runtime gets the shared core, the
717
- // rendered commands/personas, and nothing that would only pretend to be wired.
718
- copyCore();
719
- renderSurfaces();
720
- const hookState = registerRuntimeHook();
721
- await seedConfig();
722
- if (mode === 'install' && scope === 'project') {
723
- fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
724
- const specTemplate = path.join(target, 'specs', '_template.md');
725
- if (!fs.existsSync(specTemplate)) {
726
- fs.copyFileSync(path.join(src, 'core', 'templates', 'spec.template.md'), specTemplate);
727
- }
728
- }
729
- const cmds = adapter.displayPath(paths.commands, target);
730
- const agentsWhere = paths.agents
731
- ? `${adapter.displayPath(paths.agents, target)} (${runtime.agent.format})`
732
- : 'sequential personas — this runtime has no subagents';
733
- console.log(`
734
- ✓ ${runtime.label}: core in ${adapter.displayPath(paths.core, target)}, commands in ${cmds} (version ${VERSION})
735
- invoke: ${runtime.command.invoke.replace('<name>', 'cohorte-init-pipeline')}
736
- agents: ${agentsWhere}
737
- gate: ${hookState}`);
738
- if (runtime.id === 'codex') {
739
- console.log(' surface agents: .codex/agents/*.toml in each project (generated by init/reconcile)');
740
- console.log(' keep your normal CODEX_HOME; no project launcher or auth symlink is needed');
741
- }
742
- if ((runtime.exclude_commands || []).length) {
743
- const named = runtime.exclude_commands.map((c) => runtime.command.invoke.replace('<name>', c));
744
- console.log(` not installed here: ${named.join(', ')}${runtime.exclude_reason ? ` — ${runtime.exclude_reason}` : ''}`);
745
- }
746
- if (runtime.scopes[scope].commands_scope === 'global' && scope === 'project') {
747
- console.log(` note: ${runtime.label} reads commands only from your home dir, so they were`);
748
- console.log(' installed there even though the core is bundled in this repo.');
749
- }
750
- return;
751
- }
752
- if (scope === 'global') {
753
- console.log(mode === 'install'
754
- ? `→ installing pipeline core GLOBALLY into ${dest}`
755
- : `→ updating pipeline core GLOBALLY in ${dest} (keeping global settings.json)`);
756
- renderSurfaces();
757
- copyCore();
758
- // Register on UPDATE too — install.sh and install.ps1 always have, and this
759
- // port skipping it is why a duplicated or stale-matcher registration could
760
- // never be repaired by `cohorte update`: the only route that rewrites it
761
- // was a full re-install, which is not what anyone runs to get a fix. Safe to
762
- // run every time — registration reconciles only gate.py entries and leaves
763
- // every other hook and settings key untouched.
764
- const hookState = registerGlobalHook();
765
- await seedConfig();
766
- console.log(`
767
- ✓ pipeline core installed globally into ${dest} (version ${VERSION})
768
- gate hook: ${hookState} (reads each repo's .claude/gate-config.json; silent where absent)
769
-
770
- The commands (/cohorte-init-pipeline, /cohorte-brainstorm, /cohorte-build …) and the review/release agents are now
771
- available in EVERY project on this machine — nothing is copied per repo.
772
-
773
- Per repo:
774
- 1. Open the project in Claude Code.
775
- 2. Run /cohorte-init-pipeline — it generates PIPELINE.md, renders the surface agents, writes
776
- .claude/gate-config.json, and drops a committed .claude/pipeline.json pointer so
777
- teammates know to install the global core (${REPO_URL}).
778
- 3. Commit PIPELINE.md + .claude/, then /cohorte-brainstorm to start a feature.
779
-
780
- Update later with: npm i -g cohorte@latest && cohorte update --global
781
-
782
- Global kanban config, user-scoped — optional:
783
- · One consolidated file: ${path.join(globalDir, 'cohorte.config.yaml')}
784
- · Don't hand-edit it — /cohorte-init-pipeline (new project) and /cohorte-update-pipeline (existing) wire it
785
- for you: creating + syncing an Obsidian kanban board of the pipeline in your shared vault.`);
786
- } else if (mode === 'install') {
787
- console.log(`→ installing pipeline core into ${dest}`);
788
- renderSurfaces();
789
- copyCore();
790
- await seedConfig();
791
- fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
792
- const specTemplate = path.join(target, 'specs', '_template.md');
793
- if (!fs.existsSync(specTemplate)) {
794
- fs.copyFileSync(path.join(src, 'core', 'templates', 'spec.template.md'), specTemplate);
795
- }
796
- console.log(`
797
- ✓ pipeline core installed into ${dest} (version ${VERSION})
798
-
799
- Next:
800
- 1. Open the project in Claude Code.
801
- 2. Run /cohorte-init-pipeline — it detects your stack, asks the gaps, and generates
802
- PIPELINE.md + renders one implementer agent per surface.
803
- 3. Commit PIPELINE.md, then /cohorte-brainstorm to start a feature.
804
-
805
- Update later with: npm i -g cohorte@latest && cohorte update
806
- Prefer one shared core across all your repos? Re-run with --global.`);
807
- } else {
808
- console.log(`→ updating pipeline core in ${dest} (keeping your PIPELINE.md + rendered agents)`);
809
- copyCore();
810
- try { renderSurfaces(); } catch { /* best-effort, as in install.sh */ }
811
- await seedConfig();
812
- bumpPointerVersion(path.join(dest, 'pipeline.json'));
813
- console.log(`
814
- ✓ core refreshed to ${VERSION}. Your PIPELINE.md, rendered surface agents, gate-config.json and
815
- settings.json were left as-is. Re-run /cohorte-init-pipeline if your stack changed.`);
816
- }
817
- }