cohorte 2.10.1 → 3.0.0-dev.1

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 (213) 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 +154 -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 +2187 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +104 -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-CYOh60KG.mjs +93 -0
  50. package/dist/chunks/auth-CYOh60KG.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-BwKA6yZP.mjs +7 -0
  52. package/dist/chunks/brainstorm-BwKA6yZP.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-Dvxunpto.mjs +7307 -0
  60. package/dist/chunks/compose-Dvxunpto.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-BkMjU1kR.mjs +295 -0
  64. package/dist/chunks/contract-BkMjU1kR.mjs.map +1 -0
  65. package/dist/chunks/contract-bQ9FJl4N.mjs +123 -0
  66. package/dist/chunks/contract-bQ9FJl4N.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-eS01-me1.mjs +21 -0
  72. package/dist/chunks/discover-eS01-me1.mjs.map +1 -0
  73. package/dist/chunks/doctor-B-H2UK5e.mjs +222 -0
  74. package/dist/chunks/doctor-B-H2UK5e.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-BUnzIkeE.mjs +63 -0
  80. package/dist/chunks/init-BUnzIkeE.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-DybihKzZ.mjs +33 -0
  86. package/dist/chunks/migrate-DybihKzZ.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-Bhy3IIYo.mjs +50 -0
  96. package/dist/chunks/reconcile-Bhy3IIYo.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/scan-8Gy_B6kI.mjs +122 -0
  110. package/dist/chunks/scan-8Gy_B6kI.mjs.map +1 -0
  111. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  112. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  113. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  114. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  115. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  116. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  117. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  118. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  119. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  120. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  121. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  122. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  123. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  124. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  125. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  126. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  127. package/dist/chunks/src-CmFjh4M0.mjs +928 -0
  128. package/dist/chunks/src-CmFjh4M0.mjs.map +1 -0
  129. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  130. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  131. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  132. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  133. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  134. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  135. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  136. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  137. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  138. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  139. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  140. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  141. package/dist/cli.mjs +175 -0
  142. package/dist/cli.mjs.map +1 -0
  143. package/package.json +17 -32
  144. package/CHANGELOG.md +0 -1461
  145. package/bin/cli.js +0 -817
  146. package/bin/report.js +0 -141
  147. package/core/adapter/render.js +0 -415
  148. package/core/agents/implementer.template.md +0 -142
  149. package/core/agents/profile-reader.md +0 -50
  150. package/core/agents/release.md +0 -58
  151. package/core/agents/review.md +0 -179
  152. package/core/commands/cohorte-align-ds.md +0 -33
  153. package/core/commands/cohorte-audit.md +0 -58
  154. package/core/commands/cohorte-brainstorm.md +0 -70
  155. package/core/commands/cohorte-build.md +0 -219
  156. package/core/commands/cohorte-doctor.md +0 -194
  157. package/core/commands/cohorte-fix.md +0 -80
  158. package/core/commands/cohorte-fleet.md +0 -103
  159. package/core/commands/cohorte-init-pipeline.md +0 -31
  160. package/core/commands/cohorte-intake.md +0 -92
  161. package/core/commands/cohorte-patch.md +0 -118
  162. package/core/commands/cohorte-refactor.md +0 -59
  163. package/core/commands/cohorte-retro.md +0 -85
  164. package/core/commands/cohorte-review.md +0 -270
  165. package/core/commands/cohorte-ship.md +0 -122
  166. package/core/commands/cohorte-spec.md +0 -101
  167. package/core/commands/cohorte-update-pipeline.md +0 -165
  168. package/core/hooks/gate.py +0 -588
  169. package/core/runtimes/claude.json +0 -74
  170. package/core/runtimes/codex.json +0 -85
  171. package/core/runtimes/cursor.json +0 -76
  172. package/core/runtimes/gemini.json +0 -76
  173. package/core/runtimes/opencode.json +0 -73
  174. package/core/templates/agent-handoff.md +0 -34
  175. package/core/templates/brainstorm-return.md +0 -36
  176. package/core/templates/decisions.template.md +0 -42
  177. package/core/templates/design-brief.md +0 -44
  178. package/core/templates/patch.template.md +0 -86
  179. package/core/templates/pr-body.md +0 -25
  180. package/core/templates/review-feedback.md +0 -38
  181. package/core/templates/spec.template.md +0 -87
  182. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  183. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  184. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  185. package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
  186. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  187. package/core/workflows/audit.js +0 -240
  188. package/core/workflows/loop.js +0 -641
  189. package/core/workflows/refactor.js +0 -276
  190. package/core/workflows/review.js +0 -456
  191. package/install.ps1 +0 -103
  192. package/install.sh +0 -114
  193. package/lib/doctor.js +0 -534
  194. package/lib/runtime.js +0 -139
  195. package/lib/versions.js +0 -149
  196. package/lib/yaml.js +0 -129
  197. package/profile/PIPELINE.template.md +0 -259
  198. package/profile/SCHEMA.md +0 -735
  199. package/profile/cohorte.config.template.yaml +0 -43
  200. package/scripts/assert-gate-hook.mjs +0 -75
  201. package/scripts/kanban-move.sh +0 -313
  202. package/scripts/metrics/collect.mjs +0 -531
  203. package/scripts/metrics/prices.json +0 -42
  204. package/scripts/new-feature.sh.template +0 -98
  205. package/scripts/preflight.sh +0 -124
  206. package/scripts/remove-feature.sh.template +0 -56
  207. package/scripts/test-adapter.mjs +0 -437
  208. package/scripts/test-gate.mjs +0 -443
  209. package/scripts/test-kanban.mjs +0 -304
  210. package/scripts/test-lib.mjs +0 -356
  211. package/scripts/test-metrics.mjs +0 -156
  212. package/scripts/test-workflows.mjs +0 -758
  213. package/scripts/validate-core.mjs +0 -325
@@ -1,531 +0,0 @@
1
- #!/usr/bin/env node
2
- // collect.mjs — reconstruct per-command cost and runtime from Claude Code's own transcripts.
3
- //
4
- // node scripts/metrics/collect.mjs [projectRoot] [--json] [--runs] [--since=ISO] [--days=N]
5
- //
6
- // WHY THIS EXISTS
7
- // `.claude/pipeline-metrics.jsonl` is written by the model itself (each command file tells
8
- // it to append a line). That makes it unreliable — a command that ends early, errors, or
9
- // simply forgets writes nothing, and it can never report tokens because the model does not
10
- // know its own usage. Claude Code, meanwhile, already logs every API response it makes to
11
- // ~/.claude/projects/<slug>/<sessionId>.jsonl with exact `usage` and timestamps, and every
12
- // subagent to <sessionId>/subagents/agent-<id>.jsonl. That is ground truth, it needs no
13
- // cooperation from the model, and it is retroactive: this script works on runs that already
14
- // happened. Nothing here writes to the pipeline — it is a pure reader.
15
- //
16
- // THREE THINGS THAT ARE EASY TO GET WRONG, HANDLED HERE
17
- // 1. One API response is written as SEVERAL transcript lines (one per content block:
18
- // thinking, text, tool_use, tool_use), and EACH line repeats the full `usage` object.
19
- // Summing lines inflates tokens ~1.8x. We dedupe by message.id.
20
- // 2. Feature work happens in git worktrees, whose cwd hashes to a DIFFERENT project slug.
21
- // Scanning only the main checkout's slug silently drops most of a multi-surface run. We match
22
- // sessions by their recorded `cwd` against `git worktree list`.
23
- // 3. Subagent spend lives in a separate file tree and is invisible in the parent
24
- // transcript. For cohorte that is the majority of the cost, so we walk subagents/ and
25
- // attribute each agent to the command segment that spawned it (via meta.toolUseId,
26
- // falling back to timestamp containment for agents spawned by the Workflow runner).
27
-
28
- import fs from 'node:fs';
29
- import path from 'node:path';
30
- import os from 'node:os';
31
- import { execFileSync } from 'node:child_process';
32
- import { fileURLToPath } from 'node:url';
33
-
34
- const HERE = path.dirname(fileURLToPath(import.meta.url));
35
- const PRICES = JSON.parse(fs.readFileSync(path.join(HERE, 'prices.json'), 'utf8'));
36
-
37
- // A gap longer than this between two API responses is the human thinking, reading, or away
38
- // — not the command working. `wall` keeps it, `active` drops it. Without the split, a
39
- // command left open over lunch reports a two-hour runtime and poisons every median.
40
- const IDLE_GAP_S = 120;
41
-
42
- // A prompt this short with no command in it ("continue", "go", "ok next") is the human
43
- // steering a run that is already going, not starting a new one. Without this, a single
44
- // /cohorte-review driven by three "continue"s reports as one /cohorte-review plus three anonymous chat
45
- // runs, and three quarters of its cost lands under (chat).
46
- const CONTINUATION_MAX_CHARS = 40;
47
-
48
- // Commands are recognised two ways. `<command-name>` is emitted only when the whole prompt
49
- // IS the slash command; in practice people write "move on branding-ramp and /cohorte-review", which
50
- // the harness records as ordinary prose. So we also look for an inline mention, checked
51
- // against the real command list rather than any /token — otherwise a file path like
52
- // /usr/bin or a URL fragment would invent commands that were never run.
53
- function knownCommands() {
54
- const names = new Set();
55
- for (const dir of [path.join(HERE, '..', '..', 'core', 'commands'),
56
- path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'pipeline', 'commands')]) {
57
- try {
58
- for (const f of fs.readdirSync(dir)) if (f.endsWith('.md')) names.add(f.slice(0, -3));
59
- } catch {}
60
- }
61
- // Fallback for a collector run outside the package (e.g. copied into a repo on its own).
62
- if (!names.size) {
63
- for (const n of ['cohorte-brainstorm', 'cohorte-spec', 'cohorte-build', 'cohorte-review',
64
- 'cohorte-fix', 'cohorte-ship', 'cohorte-audit', 'cohorte-refactor',
65
- 'cohorte-align-ds', 'cohorte-doctor',
66
- 'cohorte-init-pipeline', 'cohorte-update-pipeline']) names.add(n);
67
- }
68
- // Retired commands. The list above is read from the shipped core, so a command that is
69
- // removed stops being recognised — and every run of it already in the transcripts silently
70
- // reclassifies as (chat), rewriting history and inflating the catch-all bucket. Keep the
71
- // names here so past runs stay attributed to what actually ran.
72
- //
73
- // 2.0.0 prefixed every command with `cohorte-`, which retires all 13 bare names at once:
74
- // months of transcripts say `/build`, and without these they would all reclassify to (chat)
75
- // — the largest instance of exactly the bug this list exists to prevent. `drive`/`loop` are
76
- // both here because the driver was `/loop` → `/drive` (1.6.0) → `/cohorte-loop` (2.0.0), and
77
- // `cohorte-loop` joins them now that 2.2.0 retired the driver outright.
78
- for (const n of ['cycle', 'smoke', 'drive', 'loop', 'cohorte-loop',
79
- 'brainstorm', 'spec', 'build', 'review',
80
- 'fix', 'ship', 'audit', 'refactor', 'align-ds', 'doctor',
81
- 'init-pipeline', 'update-pipeline']) names.add(n);
82
- return names;
83
- }
84
- const COMMANDS = knownCommands();
85
-
86
- // An invocation is a short instruction that is mostly the command ("move on branding-ramp
87
- // and /cohorte-review"). A long prompt that happens to name one is someone TALKING ABOUT the
88
- // command — a bug report, a design discussion, a pasted transcript. Counting those as runs
89
- // inflates a command's run count and cost with conversation that never invoked it, which is
90
- // exactly what happened in cohorte's own repo while this pipeline was being discussed.
91
- // Only inline mentions are length-gated; an explicit <command-name> is always an invocation.
92
- const MENTION_MAX_CHARS = 120;
93
-
94
- function commandIn(text) {
95
- const explicit = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>/.exec(text);
96
- if (explicit) return explicit[1].replace(/^\//, '');
97
- if (text.trim().length > MENTION_MAX_CHARS) return null;
98
- // Last mention wins: "finish /cohorte-build then /cohorte-review" ends on the one being asked for.
99
- let found = null;
100
- for (const m of text.matchAll(/(?:^|\s)\/([a-z][a-z0-9-]{2,})\b/g)) {
101
- if (COMMANDS.has(m[1])) found = m[1];
102
- }
103
- return found;
104
- }
105
-
106
- // ── paths ────────────────────────────────────────────────────────────────────────────────
107
-
108
- const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
109
-
110
- function git(args, cwd) {
111
- try {
112
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
113
- } catch { return ''; }
114
- }
115
-
116
- // Every checkout that belongs to this repo: the main one plus every live feature worktree.
117
- // A cohorte run spreads its surfaces across worktrees, so this set is what makes a feature
118
- // add up instead of reporting only whatever the human happened to type in the main window.
119
- function repoCheckouts(root) {
120
- const out = new Set([norm(root)]);
121
- const common = git(['rev-parse', '--git-common-dir'], root).trim();
122
- if (common) out.add(norm(path.resolve(root, common, '..')));
123
- for (const line of git(['worktree', 'list', '--porcelain'], root).split(/\r?\n/)) {
124
- if (line.startsWith('worktree ')) out.add(norm(line.slice(9)));
125
- }
126
- return out;
127
- }
128
-
129
- const projectsDir = () =>
130
- path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
131
-
132
- // Read only the head of a transcript to decide whether it belongs to this repo. Transcripts
133
- // run to tens of MB and most of them belong to other projects; fully parsing every file to
134
- // find the handful that match turns a 2-second command into a 2-minute one.
135
- function sessionCwd(file) {
136
- let fd;
137
- try {
138
- fd = fs.openSync(file, 'r');
139
- const buf = Buffer.alloc(65536);
140
- const n = fs.readSync(fd, buf, 0, buf.length, 0);
141
- const m = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(buf.subarray(0, n).toString('utf8'));
142
- return m ? JSON.parse(`"${m[1]}"`) : null;
143
- } catch { return null; }
144
- finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
145
- }
146
-
147
- function findSessions(checkouts) {
148
- const root = projectsDir();
149
- let dirs;
150
- try { dirs = fs.readdirSync(root, { withFileTypes: true }); }
151
- catch { return []; }
152
-
153
- const found = [];
154
- for (const d of dirs) {
155
- if (!d.isDirectory()) continue;
156
- const dir = path.join(root, d.name);
157
- let files;
158
- try { files = fs.readdirSync(dir); } catch { continue; }
159
- for (const f of files) {
160
- if (!f.endsWith('.jsonl')) continue;
161
- const file = path.join(dir, f);
162
- const cwd = sessionCwd(file);
163
- if (!cwd) continue;
164
- // A worktree's cwd can be a SUBDIRECTORY of the checkout (an agent cd'd into a
165
- // package), so prefix-match rather than compare for equality.
166
- const c = norm(cwd);
167
- if (![...checkouts].some((k) => c === k || c.startsWith(k + '/'))) continue;
168
- found.push({ file, dir: path.join(dir, path.basename(f, '.jsonl')), cwd });
169
- }
170
- }
171
- return found;
172
- }
173
-
174
- // ── pricing ──────────────────────────────────────────────────────────────────────────────
175
-
176
- function rates(model, speed) {
177
- let best = null;
178
- for (const key of Object.keys(PRICES.models)) {
179
- if (String(model || '').startsWith(key) && (!best || key.length > best.length)) best = key;
180
- }
181
- if (!best) return null;
182
- const entry = PRICES.models[best];
183
- return (speed === 'fast' && entry.fast) ? entry.fast : entry;
184
- }
185
-
186
- const emptyTokens = () => ({ input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 });
187
-
188
- function addUsage(acc, usage, model, speed) {
189
- // `<synthetic>` messages are harness-authored (API error notices, interrupt markers).
190
- // They carry a usage block but cost nothing — billing them invents spend.
191
- if (model === '<synthetic>') return;
192
- const cc = usage.cache_creation || {};
193
- let w5 = cc.ephemeral_5m_input_tokens || 0;
194
- const w1 = cc.ephemeral_1h_input_tokens || 0;
195
- // Older transcripts carry only the flat total with no TTL breakdown. Bill it as 5m —
196
- // the cheaper of the two, so an unknown-TTL run under-reports rather than inflates.
197
- if (!w5 && !w1) w5 = usage.cache_creation_input_tokens || 0;
198
-
199
- const t = { input: usage.input_tokens || 0, output: usage.output_tokens || 0,
200
- cacheWrite5m: w5, cacheWrite1h: w1, cacheRead: usage.cache_read_input_tokens || 0 };
201
- for (const k of Object.keys(t)) acc.tokens[k] += t[k];
202
-
203
- const r = rates(model, speed);
204
- if (!r) { acc.unpriced.add(model || 'unknown'); return; }
205
- const m = PRICES.multipliers;
206
- acc.cost += (
207
- t.input * r.input +
208
- t.output * r.output +
209
- t.cacheWrite5m * r.input * m.cacheWrite5m +
210
- t.cacheWrite1h * r.input * m.cacheWrite1h +
211
- t.cacheRead * r.input * m.cacheRead
212
- ) / 1e6;
213
-
214
- const byModel = acc.models[model] || (acc.models[model] = emptyTokens());
215
- for (const k of Object.keys(t)) byModel[k] += t[k];
216
- }
217
-
218
- // ── transcript parsing ───────────────────────────────────────────────────────────────────
219
-
220
- const userText = (msg) => {
221
- const c = msg && msg.content;
222
- if (typeof c === 'string') return c;
223
- if (!Array.isArray(c)) return '';
224
- return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
225
- };
226
-
227
- const isToolResultTurn = (msg) =>
228
- Array.isArray(msg && msg.content) && msg.content.some((b) => b && b.type === 'tool_result');
229
-
230
- function newSegment(label, ts) {
231
- return {
232
- label, startTs: ts, endTs: ts,
233
- tokens: emptyTokens(), cost: 0, models: {}, unpriced: new Set(),
234
- activeS: 0, lastTs: ts, turns: 0, continuations: 0,
235
- toolUseIds: new Set(), agents: [],
236
- };
237
- }
238
-
239
- // One segment = one command invocation (or one free-form chat turn). Boundaries are user
240
- // turns that are NOT tool results — a tool result is the harness feeding the loop, not the
241
- // human starting something new.
242
- function parseSession(file) {
243
- const segments = [];
244
- let seg = null;
245
- const seenMessageIds = new Set();
246
-
247
- let raw;
248
- try { raw = fs.readFileSync(file, 'utf8'); } catch { return segments; }
249
-
250
- for (const line of raw.split(/\r?\n/)) {
251
- if (!line) continue;
252
- let e;
253
- try { e = JSON.parse(line); } catch { continue; }
254
- const ts = e.timestamp ? Date.parse(e.timestamp) : NaN;
255
-
256
- if (e.type === 'user') {
257
- const msg = e.message || {};
258
- if (isToolResultTurn(msg) || e.isMeta || e.isSidechain) continue;
259
- const text = userText(msg);
260
- const cmd = commandIn(text);
261
- // Not every user-role turn is the human starting something. The harness injects
262
- // turns mid-run — a local-command echo, and (critically for cohorte) a
263
- // <task-notification> when a background agent finishes. Those arrive DURING a
264
- // a /cohorte-build; treating them as boundaries chops one command into several
265
- // cheap-looking fragments and strands the agent spend in the wrong segment.
266
- if (!cmd && /<(local-command-(stdout|stderr)|task-notification|system-reminder)>/.test(text)) continue;
267
- // A short steer with no command keeps the current run open rather than opening a new
268
- // one — see CONTINUATION_MAX_CHARS.
269
- if (!cmd && seg && text.trim().length <= CONTINUATION_MAX_CHARS) { seg.continuations += 1; continue; }
270
- seg = newSegment(cmd ? '/' + cmd : '(chat)', ts);
271
- segments.push(seg);
272
- continue;
273
- }
274
-
275
- if (e.type !== 'assistant' || !seg) continue;
276
- const msg = e.message || {};
277
-
278
- // Dedupe: the same API response is written once per content block, each copy carrying
279
- // the full usage. See header note 1 — this is the single biggest correctness trap.
280
- const id = msg.id;
281
- if (id && seenMessageIds.has(id)) {
282
- for (const b of msg.content || []) if (b && b.type === 'tool_use' && b.id) seg.toolUseIds.add(b.id);
283
- if (!Number.isNaN(ts)) seg.endTs = Math.max(seg.endTs, ts);
284
- continue;
285
- }
286
- if (id) seenMessageIds.add(id);
287
-
288
- if (msg.usage) addUsage(seg, msg.usage, msg.model, msg.usage.speed);
289
- for (const b of msg.content || []) if (b && b.type === 'tool_use' && b.id) seg.toolUseIds.add(b.id);
290
-
291
- seg.turns += 1;
292
- if (!Number.isNaN(ts)) {
293
- const gap = (ts - seg.lastTs) / 1000;
294
- if (gap > 0 && gap <= IDLE_GAP_S) seg.activeS += gap;
295
- seg.lastTs = ts;
296
- seg.endTs = Math.max(seg.endTs, ts);
297
- }
298
- }
299
- return segments;
300
- }
301
-
302
- // ── subagents ────────────────────────────────────────────────────────────────────────────
303
-
304
- function readSubagents(sessionDir) {
305
- const dir = path.join(sessionDir, 'subagents');
306
- let files;
307
- try { files = fs.readdirSync(dir); } catch { return []; }
308
-
309
- const agents = [];
310
- for (const f of files) {
311
- if (!f.endsWith('.jsonl')) continue;
312
- const base = f.slice(0, -'.jsonl'.length);
313
- let meta = {};
314
- try { meta = JSON.parse(fs.readFileSync(path.join(dir, base + '.meta.json'), 'utf8')); } catch {}
315
-
316
- const acc = { tokens: emptyTokens(), cost: 0, models: {}, unpriced: new Set() };
317
- let startTs = Infinity, endTs = -Infinity, turns = 0;
318
- const seen = new Set();
319
-
320
- let raw;
321
- try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
322
- for (const line of raw.split(/\r?\n/)) {
323
- if (!line) continue;
324
- let e;
325
- try { e = JSON.parse(line); } catch { continue; }
326
- if (e.type !== 'assistant') continue;
327
- const msg = e.message || {};
328
- if (msg.id && seen.has(msg.id)) continue;
329
- if (msg.id) seen.add(msg.id);
330
- if (msg.usage) addUsage(acc, msg.usage, msg.model, msg.usage.speed);
331
- turns += 1;
332
- const ts = e.timestamp ? Date.parse(e.timestamp) : NaN;
333
- if (!Number.isNaN(ts)) { startTs = Math.min(startTs, ts); endTs = Math.max(endTs, ts); }
334
- }
335
-
336
- agents.push({
337
- id: base.replace(/^agent-/, ''),
338
- agentType: meta.agentType || 'unknown',
339
- description: meta.description || '',
340
- toolUseId: meta.toolUseId || null,
341
- spawnDepth: meta.spawnDepth ?? null,
342
- turns, startTs, endTs, ...acc,
343
- });
344
- }
345
- return agents;
346
- }
347
-
348
- function attachAgents(segments, agents) {
349
- for (const a of agents) {
350
- // Preferred link: the Task/Agent tool_use that spawned it. Workflow-spawned agents can
351
- // carry a toolUseId the parent transcript never recorded, so fall back to which segment
352
- // was running when the agent started.
353
- let seg = a.toolUseId ? segments.find((s) => s.toolUseIds.has(a.toolUseId)) : null;
354
- if (!seg && Number.isFinite(a.startTs)) {
355
- seg = segments.find((s) => a.startTs >= s.startTs && a.startTs <= s.endTs + 5 * 60_000);
356
- }
357
- if (!seg) continue;
358
- seg.agents.push(a);
359
- for (const k of Object.keys(seg.tokens)) seg.tokens[k] += a.tokens[k];
360
- seg.cost += a.cost;
361
- for (const [m, t] of Object.entries(a.models)) {
362
- const dst = seg.models[m] || (seg.models[m] = emptyTokens());
363
- for (const k of Object.keys(t)) dst[k] += t[k];
364
- }
365
- for (const m of a.unpriced) seg.unpriced.add(m);
366
- if (Number.isFinite(a.endTs)) seg.endTs = Math.max(seg.endTs, a.endTs);
367
- // Agents run in parallel, so their wall time is not additive and cannot be folded into
368
- // the parent's `active`. Segment `wall` already covers them via endTs; agent runtime is
369
- // reported separately per command as agentWallS.
370
- }
371
- }
372
-
373
- // ── rollup ───────────────────────────────────────────────────────────────────────────────
374
-
375
- const pct = (arr, p) => {
376
- if (!arr.length) return 0;
377
- const s = [...arr].sort((a, b) => a - b);
378
- return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
379
- };
380
-
381
- function rollup(segments) {
382
- const by = new Map();
383
- for (const s of segments) {
384
- let g = by.get(s.label);
385
- if (!g) {
386
- g = { command: s.label, runs: 0, continuations: 0, tokens: emptyTokens(), cost: 0, models: {},
387
- wall: [], active: [], agentCounts: [], agentWall: [], turns: [], unpriced: new Set() };
388
- by.set(s.label, g);
389
- }
390
- g.runs += 1;
391
- g.continuations += s.continuations;
392
- for (const k of Object.keys(s.tokens)) g.tokens[k] += s.tokens[k];
393
- g.cost += s.cost;
394
- for (const [m, t] of Object.entries(s.models)) {
395
- const dst = g.models[m] || (g.models[m] = emptyTokens());
396
- for (const k of Object.keys(t)) dst[k] += t[k];
397
- }
398
- for (const m of s.unpriced) g.unpriced.add(m);
399
- g.wall.push(Math.max(0, (s.endTs - s.startTs) / 1000) || 0);
400
- g.active.push(s.activeS);
401
- g.turns.push(s.turns);
402
- g.agentCounts.push(s.agents.length);
403
- g.agentWall.push(s.agents.reduce((n, a) => n + (Number.isFinite(a.endTs) ? (a.endTs - a.startTs) / 1000 : 0), 0));
404
- }
405
-
406
- return [...by.values()]
407
- .map((g) => ({
408
- command: g.command,
409
- runs: g.runs,
410
- continuations: g.continuations,
411
- cost: { total: g.cost, perRun: g.cost / g.runs },
412
- tokens: g.tokens,
413
- tokensPerRun: Object.fromEntries(Object.entries(g.tokens).map(([k, v]) => [k, Math.round(v / g.runs)])),
414
- wallS: { p50: pct(g.wall, 50), p90: pct(g.wall, 90), total: g.wall.reduce((a, b) => a + b, 0) },
415
- activeS: { p50: pct(g.active, 50), p90: pct(g.active, 90) },
416
- agents: { perRunP50: pct(g.agentCounts, 50), total: g.agentCounts.reduce((a, b) => a + b, 0),
417
- serialWallP50S: pct(g.agentWall, 50) },
418
- turnsP50: pct(g.turns, 50),
419
- models: g.models,
420
- unpriced: [...g.unpriced],
421
- }))
422
- .sort((a, b) => b.cost.total - a.cost.total);
423
- }
424
-
425
- // ── output ───────────────────────────────────────────────────────────────────────────────
426
-
427
- const fmtUsd = (n) => (n < 0.01 && n > 0 ? '<$0.01' : '$' + n.toFixed(2));
428
- const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n));
429
- const fmtDur = (s) => (s >= 3600 ? (s / 3600).toFixed(1) + 'h' : s >= 60 ? Math.round(s / 60) + 'm' : Math.round(s) + 's');
430
-
431
- function table(rows) {
432
- const head = ['COMMAND', 'RUNS', '$/RUN', '$ TOTAL', 'TOK/RUN', 'OUT/RUN', 'WALL p50', 'ACTIVE p50', 'AGENTS p50'];
433
- const body = rows.map((r) => [
434
- r.command,
435
- String(r.runs),
436
- fmtUsd(r.cost.perRun),
437
- fmtUsd(r.cost.total),
438
- fmtTok(Object.values(r.tokensPerRun).reduce((a, b) => a + b, 0)),
439
- fmtTok(r.tokensPerRun.output),
440
- fmtDur(r.wallS.p50),
441
- fmtDur(r.activeS.p50),
442
- String(r.agents.perRunP50),
443
- ]);
444
- const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
445
- const line = (cells) => cells.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(' ');
446
- return [line(head), w.map((n) => '-'.repeat(n)).join(' '), ...body.map(line)].join('\n');
447
- }
448
-
449
- // ── main ─────────────────────────────────────────────────────────────────────────────────
450
-
451
- function main(argv) {
452
- const args = argv.slice(2);
453
- const flag = (name) => args.includes('--' + name);
454
- const opt = (name) => {
455
- const hit = args.find((a) => a.startsWith(`--${name}=`));
456
- return hit ? hit.slice(name.length + 3) : null;
457
- };
458
- const root = path.resolve(args.find((a) => !a.startsWith('--')) || process.cwd());
459
-
460
- let since = opt('since') ? Date.parse(opt('since')) : null;
461
- if (opt('days')) since = Date.now() - Number(opt('days')) * 86400_000;
462
-
463
- const checkouts = repoCheckouts(root);
464
- const sessions = findSessions(checkouts);
465
-
466
- let segments = [];
467
- for (const s of sessions) {
468
- const segs = parseSession(s.file);
469
- attachAgents(segs, readSubagents(s.dir));
470
- segments.push(...segs);
471
- }
472
- if (since) segments = segments.filter((s) => s.startTs >= since);
473
- // A segment with no API call is a typo or an interrupted prompt, not a run.
474
- segments = segments.filter((s) => s.turns > 0);
475
-
476
- const rows = rollup(segments);
477
- const totals = {
478
- sessions: sessions.length,
479
- runs: segments.length,
480
- cost: rows.reduce((n, r) => n + r.cost.total, 0),
481
- agents: rows.reduce((n, r) => n + r.agents.total, 0),
482
- };
483
-
484
- // The headline figures as a Francois extension `stat-row` payload — four tiles, no
485
- // table. Deliberately ahead of the --json branch: --panel is a shape, not a filter,
486
- // and passing both should never print two documents.
487
- // See github.com/TheBidouilleAgency/francois-plugin-cohorte.
488
- if (flag('panel')) {
489
- const window = opt('days') ? `last ${opt('days')} days` : since ? 'since ' + new Date(since).toISOString().slice(0, 10) : 'all time';
490
- const top = rows[0];
491
- process.stdout.write(JSON.stringify({ tiles: [
492
- { label: 'Cost', value: fmtUsd(totals.cost), sublabel: window },
493
- { label: 'Runs', value: String(totals.runs), sublabel: `${totals.sessions} sessions` },
494
- { label: 'Subagents', value: String(totals.agents) },
495
- ...(top ? [{ label: 'Priciest', value: top.command, sublabel: fmtUsd(top.cost.total) }] : []),
496
- ] }) + '\n');
497
- return 0;
498
- }
499
-
500
- if (flag('json')) {
501
- const out = { generatedAt: new Date().toISOString(), projectRoot: root,
502
- checkouts: [...checkouts], pricesUpdated: PRICES.updated, totals, commands: rows };
503
- if (flag('runs')) {
504
- out.runs = segments.map((s) => ({
505
- command: s.label, startedAt: new Date(s.startTs).toISOString(),
506
- wallS: Math.max(0, (s.endTs - s.startTs) / 1000), activeS: s.activeS,
507
- turns: s.turns, continuations: s.continuations, cost: s.cost, tokens: s.tokens,
508
- agents: s.agents.map((a) => ({ type: a.agentType, description: a.description,
509
- cost: a.cost, tokens: a.tokens, turns: a.turns })),
510
- }));
511
- }
512
- process.stdout.write(JSON.stringify(out, null, 2) + '\n');
513
- return 0;
514
- }
515
-
516
- if (!sessions.length) {
517
- console.log(`No Claude Code transcripts found for ${root} under ${projectsDir()}.`);
518
- return 0;
519
- }
520
- console.log(`cohorte metrics — ${root}`);
521
- console.log(`${totals.runs} runs across ${totals.sessions} sessions, ${totals.agents} subagents, ${fmtUsd(totals.cost)} total`
522
- + (since ? ` (since ${new Date(since).toISOString().slice(0, 10)})` : ''));
523
- console.log(`prices as of ${PRICES.updated}; wall excludes nothing, active drops gaps > ${IDLE_GAP_S}s\n`);
524
- console.log(table(rows));
525
-
526
- const unpriced = [...new Set(rows.flatMap((r) => r.unpriced))];
527
- if (unpriced.length) console.log(`\nnot in prices.json (counted, not costed): ${unpriced.join(', ')}`);
528
- return 0;
529
- }
530
-
531
- process.exit(main(process.argv));
@@ -1,42 +0,0 @@
1
- {
2
- "_comment": [
3
- "USD per million tokens, Anthropic first-party API rates. Bedrock/Vertex are",
4
- "partner-priced and are NOT covered here — a run on those platforms will be",
5
- "costed at first-party rates and slightly misreported.",
6
- "Cache rates are derived from `input` via `multipliers` (write 1.25x for the 5m",
7
- "TTL, 2x for 1h, read 0.1x) rather than restated per model, so a price change",
8
- "is a one-line edit. `fast` is the fast-mode premium (usage.speed === 'fast').",
9
- "Model lookup is longest-prefix, so dated ids (claude-haiku-4-5-20251001) hit",
10
- "their base entry without needing a row of their own.",
11
- "Sonnet 5 is $2/$10: the introductory rate became the standard rate on 2026-08-20.",
12
- "The $3/$15 increase once scheduled for 2026-09-01 was cancelled and must not",
13
- "be reinstated. Sonnet 4.6 and earlier stay at $3/$15 — same tier, different price."
14
- ],
15
- "updated": "2026-08-22",
16
- "multipliers": {
17
- "cacheWrite5m": 1.25,
18
- "cacheWrite1h": 2.0,
19
- "cacheRead": 0.1
20
- },
21
- "models": {
22
- "claude-fable-5": { "input": 10, "output": 50 },
23
- "claude-mythos-5": { "input": 10, "output": 50 },
24
- "claude-opus-5": {
25
- "input": 5,
26
- "output": 25,
27
- "fast": { "input": 10, "output": 50 }
28
- },
29
- "claude-opus-4-8": {
30
- "input": 5,
31
- "output": 25,
32
- "fast": { "input": 10, "output": 50 }
33
- },
34
- "claude-opus-4-7": { "input": 5, "output": 25 },
35
- "claude-opus-4-6": { "input": 5, "output": 25 },
36
- "claude-opus-4-5": { "input": 5, "output": 25 },
37
- "claude-sonnet-5": { "input": 2, "output": 10 },
38
- "claude-sonnet-4-6": { "input": 3, "output": 15 },
39
- "claude-sonnet-4-5": { "input": 3, "output": 15 },
40
- "claude-haiku-4-5": { "input": 1, "output": 5 }
41
- }
42
- }
@@ -1,98 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # new-feature.sh — spin up an ISOLATED git worktree for one feature so features
4
- # can be built fully in parallel. Rendered from a template by /cohorte-init-pipeline;
5
- # the double-underscore tokens below are substituted from PIPELINE.md §isolation.
6
- # (This comment deliberately avoids spelling the token pattern: /cohorte-doctor flags any
7
- # leftover double-underscore-caps token in the RENDERED script as unrendered.)
8
- #
9
- # Usage: scripts/new-feature.sh <feature_id>
10
- #
11
- # Creates a sibling worktree ../__PROJECT_SLUG__-<id> on branch
12
- # __BRANCH_PREFIX__<id> (off __DEFAULT_BRANCH__) with its own isolation slot:
13
- # • its own database __DB_NAME_PATTERN__ (if DB isolation is enabled)
14
- # • its own API port __API_PORT_BASE__ + slot
15
- # • its own web port __WEB_PORT_BASE__ + slot
16
- # The infra servers stay a SINGLE shared stack — only the logical db + ports
17
- # differ per worktree. Slot 0 = main checkout. New worktrees get slot >= 1.
18
-
19
- set -euo pipefail
20
-
21
- id="${1:-}"
22
- if [[ -z "$id" ]]; then
23
- echo "usage: scripts/new-feature.sh <feature_id>" >&2
24
- exit 1
25
- fi
26
-
27
- repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
28
- branch="__BRANCH_PREFIX__${id}"
29
- worktree_dir="$(dirname "$repo_root")/__PROJECT_SLUG__-${id}"
30
- db_name="__PROJECT_SLUG___$(echo "$id" | tr -c 'a-zA-Z0-9' '_' | sed 's/_*$//')"
31
-
32
- registry_dir="$repo_root/.worktrees"
33
- registry="$registry_dir/slots.tsv"
34
- mkdir -p "$registry_dir"
35
- touch "$registry"
36
-
37
- if [[ -e "$worktree_dir" ]]; then
38
- echo "error: $worktree_dir already exists" >&2
39
- exit 1
40
- fi
41
-
42
- slot="$(awk -F'\t' -v id="$id" '$1==id{print $2}' "$registry" | head -n1)"
43
- if [[ -z "$slot" ]]; then
44
- slot=1
45
- while awk -F'\t' -v s="$slot" '$2==s{found=1} END{exit !found}' "$registry"; do
46
- slot=$((slot + 1))
47
- done
48
- printf '%s\t%s\n' "$id" "$slot" >>"$registry"
49
- fi
50
-
51
- api_port=$((__API_PORT_BASE__ + slot))
52
- web_port=$((__WEB_PORT_BASE__ + slot))
53
-
54
- echo "→ feature '$id' → slot $slot (db $db_name · api :$api_port · web :$web_port)"
55
- echo "→ creating worktree $worktree_dir on $branch (off __DEFAULT_BRANCH__)"
56
- # Branch off the REMOTE ref when it exists: `git fetch origin <branch>` updates
57
- # origin/<branch>, never the local one, so branching off the local ref silently
58
- # started every feature from wherever the local default branch last sat.
59
- git -C "$repo_root" fetch --quiet origin __DEFAULT_BRANCH__ || true
60
- base=__DEFAULT_BRANCH__
61
- if git -C "$repo_root" rev-parse --verify -q "origin/__DEFAULT_BRANCH__" >/dev/null 2>&1; then
62
- base="origin/__DEFAULT_BRANCH__"
63
- fi
64
- git -C "$repo_root" worktree add -b "$branch" "$worktree_dir" "$base"
65
-
66
- # Replace (or append) KEY=VALUE in a dotenv file — one definitive line per key.
67
- set_env() {
68
- local file="$1" key="$2" value="$3" tmp
69
- if [[ -s "$file" && -n "$(tail -c1 "$file")" ]]; then printf '\n' >>"$file"; fi
70
- tmp="$(mktemp)"
71
- grep -vE "^${key}=" "$file" >"$tmp" || true
72
- mv "$tmp" "$file"
73
- printf '%s=%s\n' "$key" "$value" >>"$file"
74
- }
75
-
76
- # --- per-worktree env (init customizes these stanzas per surface) -----------
77
- # __ENV_STANZAS__
78
-
79
- # --- create the isolated database (if DB isolation enabled) -----------------
80
- # __DB_STANZA__
81
-
82
- echo "→ ${repo_root##*/} install (shared store)"
83
- ( cd "$worktree_dir" && __INSTALL_CMD__ )
84
-
85
- cat <<EOF
86
-
87
- ✓ worktree ready: $worktree_dir (branch $branch, slot $slot)
88
- api http://localhost:$api_port
89
- web http://localhost:$web_port
90
-
91
- Fully isolated — run its dev + migrations alongside other worktrees:
92
- cd $worktree_dir
93
- __MIGRATE_CMD__
94
- cd $worktree_dir && __DEV_CMD__
95
- /cohorte-brainstorm → /cohorte-spec → (design) → /cohorte-build $id → test → /cohorte-review → /cohorte-ship
96
-
97
- Tear down when merged: scripts/remove-feature.sh $id
98
- EOF