@smartmemory/compose 0.1.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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
package/bin/compose.js ADDED
@@ -0,0 +1,1515 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * compose CLI
4
+ *
5
+ * compose init — initialize Compose in the current project (project-local)
6
+ * compose setup — install global skill + register stratum-mcp (user-global)
7
+ * compose install — run init + setup (backwards-compat alias)
8
+ * compose start — start the compose app (supervisor.js)
9
+ * compose build — headless feature lifecycle runner
10
+ */
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync, readdirSync } from 'fs'
12
+ import { resolve, join, basename, dirname } from 'path'
13
+ import { homedir } from 'os'
14
+ import { spawn, spawnSync } from 'child_process'
15
+ import { fileURLToPath } from 'url'
16
+ import { findProjectRoot } from '../server/find-root.js'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const PACKAGE_ROOT = resolve(__dirname, '..')
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // --team flag (COMP-TEAMS)
23
+ // ---------------------------------------------------------------------------
24
+ import { parseTeamFlag } from '../lib/team-flag.js';
25
+
26
+ const [,, cmd, ...args] = process.argv
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Help
30
+ // ---------------------------------------------------------------------------
31
+
32
+ if (!cmd || cmd === '--help' || cmd === '-h') {
33
+ console.log('Usage: compose <command>')
34
+ console.log('')
35
+ console.log('Commands:')
36
+ console.log(' new Kickoff a product (research, brainstorm, roadmap, scaffold)')
37
+ console.log(' import Scan existing project and generate structured analysis')
38
+ console.log(' feature Add a single feature (folder, design seed, ROADMAP entry)')
39
+ console.log(' build Run a feature through the headless lifecycle')
40
+ console.log(' pipeline View and edit the build pipeline')
41
+ console.log(' roadmap Show roadmap status and next buildable features')
42
+ console.log(' roadmap generate Regenerate ROADMAP.md from feature.json files')
43
+ console.log(' roadmap migrate Extract ROADMAP.md entries into feature.json files')
44
+ console.log(' roadmap check Verify feature.json and ROADMAP.md are in sync')
45
+ console.log(' triage Analyze a feature and recommend build profile')
46
+ console.log(' qa-scope Show affected routes from a feature\'s changed files')
47
+ console.log(' init Initialize Compose in the current project')
48
+ console.log(' setup Install global skill + register stratum-mcp')
49
+ process.exit(0)
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // compose init — project-local setup
54
+ // ---------------------------------------------------------------------------
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Agent detection + skill installation
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function detectAgents() {
61
+ const agents = []
62
+ const home = homedir()
63
+
64
+ // Claude Code
65
+ const hasClaude = spawnSync('which', ['claude'], { encoding: 'utf-8' }).status === 0
66
+ || existsSync(join(home, '.claude'))
67
+ if (hasClaude) {
68
+ agents.push({ name: 'claude', skillDir: join(home, '.claude', 'skills', 'stratum') })
69
+ }
70
+
71
+ // Codex (via opencode) — shares ~/.claude/skills/ with Claude Code
72
+ const hasCodex = spawnSync('which', ['opencode'], { encoding: 'utf-8' }).status === 0
73
+ || existsSync(join(home, '.codex'))
74
+ if (hasCodex) {
75
+ agents.push({ name: 'codex', skillDir: join(home, '.claude', 'skills', 'stratum'), sharedWith: 'claude' })
76
+ }
77
+
78
+ // Gemini CLI
79
+ const hasGemini = spawnSync('which', ['gemini-cli'], { encoding: 'utf-8' }).status === 0
80
+ || existsSync(join(home, '.gemini'))
81
+ if (hasGemini) {
82
+ agents.push({ name: 'gemini', skillDir: join(home, '.gemini', 'skills', 'stratum') })
83
+ }
84
+
85
+ return agents
86
+ }
87
+
88
+ /**
89
+ * Sync compose-owned skills to ~/.claude/skills/ (and other agent skill dirs).
90
+ * Copies all skills from source dirs, removes previously-installed skills that
91
+ * no longer exist in source. Tracks installed set via a manifest file.
92
+ *
93
+ * Source dirs:
94
+ * - PACKAGE_ROOT/.claude/skills/* (compose skill)
95
+ * - PACKAGE_ROOT/skills/* (stratum base skill)
96
+ */
97
+ function syncSkills(agents) {
98
+ // Collect source skills: { name -> sourcePath }
99
+ const sourceSkills = new Map()
100
+ const skillSourceDirs = [
101
+ join(PACKAGE_ROOT, '.claude', 'skills'),
102
+ join(PACKAGE_ROOT, 'skills'),
103
+ ]
104
+ for (const dir of skillSourceDirs) {
105
+ if (!existsSync(dir)) continue
106
+ for (const entry of readdirSync(dir)) {
107
+ const skillFile = join(dir, entry, 'SKILL.md')
108
+ if (existsSync(skillFile)) {
109
+ sourceSkills.set(entry, skillFile)
110
+ }
111
+ }
112
+ }
113
+
114
+ if (sourceSkills.size === 0) {
115
+ console.log('Warning: no skills found to install')
116
+ return
117
+ }
118
+
119
+ console.log('\nSyncing skills...')
120
+ const syncedRoots = new Set()
121
+ for (const agent of agents) {
122
+ if (agent.name === 'gemini') {
123
+ console.log(` - ${agent.name} — detected but skill sync skipped (unverified path)`)
124
+ continue
125
+ }
126
+ // Skip if this agent shares a skill dir already synced (e.g. codex → ~/.claude/skills/)
127
+ const agentRoot = dirname(agent.skillDir)
128
+ if (syncedRoots.has(agentRoot)) {
129
+ console.log(` ~ ${agent.name} — shares skill dir with ${agent.sharedWith ?? 'another agent'}, skipped`)
130
+ continue
131
+ }
132
+ syncedRoots.add(agentRoot)
133
+
134
+ const agentSkillsRoot = agentRoot
135
+ const manifestPath = join(agentSkillsRoot, '.compose-skills.json')
136
+
137
+ // Load previous manifest
138
+ let previousSkills = []
139
+ if (existsSync(manifestPath)) {
140
+ try { previousSkills = JSON.parse(readFileSync(manifestPath, 'utf-8')) } catch {}
141
+ }
142
+
143
+ // Install current skills
144
+ for (const [name, srcPath] of sourceSkills) {
145
+ const destDir = join(agentSkillsRoot, name)
146
+ mkdirSync(destDir, { recursive: true })
147
+ copyFileSync(srcPath, join(destDir, 'SKILL.md'))
148
+ console.log(` + ${agent.name}/${name}`)
149
+ }
150
+
151
+ // Remove skills we previously installed that no longer exist in source
152
+ const removed = previousSkills.filter(name => !sourceSkills.has(name))
153
+ for (const name of removed) {
154
+ const destDir = join(agentSkillsRoot, name)
155
+ if (existsSync(destDir)) {
156
+ rmSync(destDir, { recursive: true })
157
+ console.log(` - ${agent.name}/${name} (removed)`)
158
+ }
159
+ }
160
+
161
+ // Write updated manifest
162
+ writeFileSync(manifestPath, JSON.stringify([...sourceSkills.keys()], null, 2))
163
+ }
164
+
165
+ // Report undetected agents
166
+ const detected = new Set(agents.map(a => a.name))
167
+ for (const name of ['claude', 'codex', 'gemini']) {
168
+ if (!detected.has(name)) {
169
+ console.log(` - ${name} — not found`)
170
+ }
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // compose init — project-local setup
176
+ // ---------------------------------------------------------------------------
177
+
178
+ async function runInit(flags) {
179
+ const noStratum = flags.includes('--no-stratum')
180
+ const noLifecycle = flags.includes('--no-lifecycle')
181
+ const cwd = process.cwd()
182
+
183
+ // 1. Create .compose/ directory
184
+ const composeDir = join(cwd, '.compose')
185
+ mkdirSync(composeDir, { recursive: true })
186
+
187
+ // 2. Detect / auto-install stratum
188
+ let hasStratum = !noStratum && spawnSync('which', ['stratum-mcp'], { encoding: 'utf-8' }).status === 0
189
+ if (!noStratum && !hasStratum) {
190
+ console.log('stratum-mcp not found — installing via pip...')
191
+ const pipResult = spawnSync('pip', ['install', 'stratum'], {
192
+ stdio: 'inherit',
193
+ encoding: 'utf-8',
194
+ })
195
+ if (pipResult.status === 0) {
196
+ // Verify the binary is now on PATH
197
+ hasStratum = spawnSync('which', ['stratum-mcp'], { encoding: 'utf-8' }).status === 0
198
+ if (hasStratum) {
199
+ console.log('stratum-mcp installed successfully')
200
+ } else {
201
+ console.warn('Warning: pip install stratum succeeded but stratum-mcp not found on PATH')
202
+ }
203
+ } else {
204
+ console.warn('Warning: pip install stratum failed — Stratum will be disabled')
205
+ console.warn(' Install manually: pip install stratum')
206
+ }
207
+ }
208
+ const hasLifecycle = !noLifecycle
209
+
210
+ // 3. Detect agents
211
+ const agents = detectAgents()
212
+
213
+ // 4. Write .compose/compose.json (merge with existing if present)
214
+ const configPath = join(composeDir, 'compose.json')
215
+ let existing = {}
216
+ if (existsSync(configPath)) {
217
+ try { existing = JSON.parse(readFileSync(configPath, 'utf-8')) } catch {}
218
+ }
219
+
220
+ const agentsConfig = {}
221
+ for (const agent of agents) {
222
+ agentsConfig[agent.name] = { detected: true, skillInstalled: agent.name !== 'gemini' }
223
+ }
224
+ for (const name of ['claude', 'codex', 'gemini']) {
225
+ if (!agentsConfig[name]) {
226
+ agentsConfig[name] = { detected: false }
227
+ }
228
+ }
229
+
230
+ const config = {
231
+ version: 2,
232
+ capabilities: {
233
+ ...(existing.capabilities || {}),
234
+ stratum: hasStratum,
235
+ lifecycle: hasLifecycle,
236
+ },
237
+ agents: {
238
+ ...(existing.agents || {}),
239
+ ...agentsConfig,
240
+ },
241
+ paths: {
242
+ docs: 'docs',
243
+ features: 'docs/features',
244
+ journal: 'docs/journal',
245
+ context: 'docs/context',
246
+ ideabox: 'docs/product/ideabox.md',
247
+ ...(existing.paths || {}),
248
+ },
249
+ }
250
+
251
+ // Flags override existing values
252
+ if (noStratum) config.capabilities.stratum = false
253
+ if (noLifecycle) config.capabilities.lifecycle = false
254
+
255
+ writeFileSync(configPath, JSON.stringify(config, null, 2))
256
+ console.log(`Wrote ${configPath}`)
257
+
258
+ // 5. Create .compose/data/
259
+ mkdirSync(join(composeDir, 'data'), { recursive: true })
260
+
261
+ // 5b. Scaffold docs/context/ with ambient context templates
262
+ const contextDir = join(cwd, config.paths.context)
263
+ mkdirSync(contextDir, { recursive: true })
264
+ const contextTemplates = {
265
+ 'tech-stack.md': '# Tech Stack\n\nDescribe your technology stack here.\n',
266
+ 'conventions.md': '# Conventions\n\nDescribe coding conventions here.\n',
267
+ 'decisions.md': '# Decision Log\n\nDecisions accumulate here during builds.\n',
268
+ }
269
+ for (const [filename, content] of Object.entries(contextTemplates)) {
270
+ const dest = join(contextDir, filename)
271
+ if (!existsSync(dest)) {
272
+ writeFileSync(dest, content)
273
+ console.log(`Created ${dest}`)
274
+ }
275
+ }
276
+
277
+ // 5c. Scaffold docs/product/ideabox.md if absent
278
+ const ideaboxRel = config.paths.ideabox || 'docs/product/ideabox.md'
279
+ const ideaboxDest = join(cwd, ideaboxRel)
280
+ if (!existsSync(ideaboxDest)) {
281
+ mkdirSync(dirname(ideaboxDest), { recursive: true })
282
+ const { IDEABOX_TEMPLATE } = await import('../lib/ideabox.js')
283
+ writeFileSync(ideaboxDest, IDEABOX_TEMPLATE)
284
+ console.log(`Created ${ideaboxDest}`)
285
+ }
286
+
287
+ // 6. Register compose-mcp in .mcp.json
288
+ const mcpPath = join(cwd, '.mcp.json')
289
+ let mcpConfig = {}
290
+ if (existsSync(mcpPath)) {
291
+ try { mcpConfig = JSON.parse(readFileSync(mcpPath, 'utf-8')) } catch {}
292
+ }
293
+ mcpConfig.mcpServers = mcpConfig.mcpServers || {}
294
+ mcpConfig.mcpServers.compose = {
295
+ command: 'node',
296
+ args: [join(PACKAGE_ROOT, 'server', 'compose-mcp.js')],
297
+ }
298
+ // T2-F5 retirement: remove legacy 'agents' entry. The file it points to is
299
+ // now a retirement shim that exits non-zero with a migration message;
300
+ // removing the entry prevents Claude Code from spawning the shim on session start.
301
+ // The agent_run capability lives on stratum-mcp as stratum_agent_run.
302
+ if (mcpConfig.mcpServers.agents) {
303
+ delete mcpConfig.mcpServers.agents
304
+ console.log('Removed retired agents MCP server from .mcp.json (T2-F5). '
305
+ + 'Use stratum_agent_run on the stratum MCP server instead.')
306
+ }
307
+ if (hasStratum && !mcpConfig.mcpServers.stratum) {
308
+ // Use absolute path — miniconda/pip binaries may not be on Claude Code's PATH
309
+ const stratumPath = spawnSync('which', ['stratum-mcp'], { encoding: 'utf-8' }).stdout.trim()
310
+ mcpConfig.mcpServers.stratum = {
311
+ command: stratumPath || 'stratum-mcp',
312
+ }
313
+ console.log('Registered stratum-mcp in .mcp.json')
314
+ }
315
+ writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2))
316
+ console.log(`Registered compose-mcp in ${mcpPath}`)
317
+
318
+ // 6b. Run stratum-mcp install to register hooks + CLAUDE.md in this project
319
+ if (hasStratum) {
320
+ console.log('Running stratum-mcp install for hooks...')
321
+ const stratumResult = spawnSync('stratum-mcp', ['install'], { cwd, stdio: 'inherit' })
322
+ if (stratumResult.status !== 0) {
323
+ console.warn('Warning: stratum-mcp install failed (hooks may not be registered)')
324
+ }
325
+ }
326
+
327
+ // 7. Scaffold ROADMAP.md from template if absent
328
+ const roadmapDest = join(cwd, 'ROADMAP.md')
329
+ if (!existsSync(roadmapDest)) {
330
+ const roadmapSrc = join(PACKAGE_ROOT, 'templates', 'ROADMAP.md')
331
+ if (existsSync(roadmapSrc)) {
332
+ const projectName = basename(cwd)
333
+ const today = new Date().toISOString().slice(0, 10)
334
+ let template = readFileSync(roadmapSrc, 'utf-8')
335
+ template = template
336
+ .replace(/\{\{PROJECT_NAME\}\}/g, projectName)
337
+ .replace(/\{\{PROJECT_DESCRIPTION\}\}/g, 'describe your project here')
338
+ .replace(/\{\{DATE\}\}/g, today)
339
+ writeFileSync(roadmapDest, template)
340
+ console.log('Created ROADMAP.md from template')
341
+ }
342
+ }
343
+
344
+ // 8. Copy default pipeline specs if absent
345
+ const pipelinesDir = join(cwd, 'pipelines')
346
+ mkdirSync(pipelinesDir, { recursive: true })
347
+ for (const specName of ['build.stratum.yaml', 'new.stratum.yaml']) {
348
+ const dest = join(pipelinesDir, specName)
349
+ if (!existsSync(dest)) {
350
+ const src = join(PACKAGE_ROOT, 'pipelines', specName)
351
+ if (existsSync(src)) {
352
+ copyFileSync(src, dest)
353
+ console.log(`Copied default pipeline to ${dest}`)
354
+ }
355
+ }
356
+ }
357
+
358
+ // 9. Sync all compose-owned skills to detected agents
359
+ syncSkills(agents)
360
+
361
+ // 10. Summary
362
+ console.log('')
363
+ console.log('Compose initialized:')
364
+ console.log(` Stratum: ${config.capabilities.stratum ? 'enabled' : 'disabled'}`)
365
+ console.log(` Lifecycle: ${config.capabilities.lifecycle ? 'enabled' : 'disabled'}`)
366
+ console.log(` Agents: ${agents.map(a => a.name).join(', ') || 'none detected'}`)
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // compose setup — user-global setup
371
+ // ---------------------------------------------------------------------------
372
+
373
+ function runSetup() {
374
+ // 1. Sync all compose-owned skills to detected agents.
375
+ // If none detected, fall back to Claude — setup is unconditional
376
+ // "install global skill" per its help text.
377
+ let agents = detectAgents()
378
+ if (agents.length === 0) {
379
+ agents = [{ name: 'claude', skillDir: join(homedir(), '.claude', 'skills', 'stratum') }]
380
+ }
381
+ syncSkills(agents)
382
+
383
+ // 2. Register stratum-mcp if available
384
+ const hasStratum = spawnSync('which', ['stratum-mcp'], { encoding: 'utf-8' }).status === 0
385
+ if (hasStratum) {
386
+ console.log('Registering stratum-mcp with Claude Code...')
387
+ const result = spawnSync('stratum-mcp', ['install'], { stdio: 'inherit' })
388
+ if (result.status !== 0) {
389
+ console.warn('Warning: stratum-mcp install failed (non-fatal)')
390
+ }
391
+ } else {
392
+ console.log('stratum-mcp not found — skipping global registration')
393
+ console.log(' Install later: pip install stratum && compose setup')
394
+ }
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Command dispatch
399
+ // ---------------------------------------------------------------------------
400
+
401
+ if (cmd === 'init') {
402
+ await runInit(args)
403
+ process.exit(0)
404
+ }
405
+
406
+ if (cmd === 'setup') {
407
+ runSetup()
408
+ process.exit(0)
409
+ }
410
+
411
+ if (cmd === 'install') {
412
+ // Backwards-compat: run both init + setup
413
+ await runInit(args)
414
+ runSetup()
415
+ process.exit(0)
416
+ }
417
+
418
+ if (cmd === 'import') {
419
+ const cwd = process.cwd()
420
+
421
+ // Auto-init if needed
422
+ if (!existsSync(join(cwd, '.compose', 'compose.json'))) {
423
+ console.log('No .compose/ found — running init first...\n')
424
+ await runInit(args.filter(a => a.startsWith('--')))
425
+ console.log('')
426
+ }
427
+
428
+ const { runImport } = await import('../lib/import.js')
429
+ try {
430
+ await runImport({ cwd })
431
+ } catch (err) {
432
+ console.error(`\nError: ${err.message}`)
433
+ process.exit(1)
434
+ }
435
+ process.exit(0)
436
+ }
437
+
438
+ if (cmd === 'new') {
439
+ const autoMode = args.includes('--auto')
440
+ const askMode = args.includes('--ask')
441
+ const fromIdeaIdx = args.indexOf('--from-idea')
442
+ const fromIdeaId = fromIdeaIdx !== -1 ? args[fromIdeaIdx + 1] : null
443
+ const intent = args.filter((a, i) => !a.startsWith('-') && i !== fromIdeaIdx + 1).join(' ')
444
+
445
+ if (!intent) {
446
+ console.error('Usage: compose new "description of the product" [--auto] [--ask]')
447
+ console.error('')
448
+ console.error('Run from inside your project directory.')
449
+ console.error('')
450
+ console.error('Options:')
451
+ console.error(' --auto Skip questionnaire entirely')
452
+ console.error(' --ask Re-run questionnaire (uses previous answers as defaults)')
453
+ console.error('')
454
+ console.error('Examples:')
455
+ console.error(' cd myapp && compose new "Structured log analyzer CLI for JSON-lines files"')
456
+ console.error(' compose new "REST API for managing team todo lists" --auto')
457
+ process.exit(1)
458
+ }
459
+
460
+ const cwd = process.cwd()
461
+ const name = basename(cwd)
462
+
463
+ // --from-idea <ID>: pre-populate intent from a promoted ideabox entry (Item 184)
464
+ let fromIdeaIntent = ''
465
+ if (fromIdeaId) {
466
+ try {
467
+ const { readIdeabox: _ribNew } = await import('../lib/ideabox.js')
468
+ const ibCfgNew = (() => { try { return JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8')) } catch { return {} } })()
469
+ const ibRelNew = ibCfgNew?.paths?.ideabox || 'docs/product/ideabox.md'
470
+ const ibDataNew = _ribNew(cwd, ibRelNew)
471
+ const foundIdea = [...ibDataNew.ideas, ...ibDataNew.killed].find(
472
+ i => i.id.toUpperCase() === fromIdeaId.toUpperCase()
473
+ )
474
+ if (foundIdea) {
475
+ const parts = [`Feature idea: ${foundIdea.title}`]
476
+ if (foundIdea.description) parts.push(`Description: ${foundIdea.description}`)
477
+ if (foundIdea.cluster) parts.push(`Cluster: ${foundIdea.cluster}`)
478
+ fromIdeaIntent = parts.join('\n')
479
+ console.log(`Pre-populating intent from ${foundIdea.id}: ${foundIdea.title}`)
480
+ } else {
481
+ console.warn(`--from-idea: idea not found: ${fromIdeaId}`)
482
+ }
483
+ } catch (err) {
484
+ console.warn(`--from-idea: could not load idea: ${err.message}`)
485
+ }
486
+ }
487
+
488
+ // Read any existing context to enrich intent
489
+ let existingContext = ''
490
+
491
+ // Project analysis from compose import (richest source)
492
+ const analysisPath = join(cwd, 'docs', 'discovery', 'project-analysis.md')
493
+ if (existsSync(analysisPath)) {
494
+ existingContext += `\n\n--- project-analysis.md (from compose import) ---\n${readFileSync(analysisPath, 'utf-8')}`
495
+ }
496
+
497
+ // Key project files
498
+ for (const contextFile of ['README.md', 'package.json', 'pyproject.toml', 'Cargo.toml']) {
499
+ const p = join(cwd, contextFile)
500
+ if (existsSync(p)) {
501
+ existingContext += `\n\n--- ${contextFile} ---\n${readFileSync(p, 'utf-8')}`
502
+ }
503
+ }
504
+
505
+ // Auto-init if not already initialized (or missing pipeline specs)
506
+ if (!existsSync(join(cwd, '.compose', 'compose.json')) || !existsSync(join(cwd, 'pipelines', 'new.stratum.yaml'))) {
507
+ console.log('Running compose init...\n')
508
+ await runInit(args.filter(a => a.startsWith('--')))
509
+ console.log('')
510
+ }
511
+
512
+ // Questionnaire: runs on first time automatically, then only with --ask
513
+ // Skip questionnaire if a design doc exists — it provides the enriched intent
514
+ // Also skip if --from-idea provided — the idea already carries title/description/cluster
515
+ const hasDesignDoc = existsSync(join(cwd, 'docs', 'design.md'))
516
+ let finalIntent = fromIdeaIntent ? `${fromIdeaIntent}${intent ? '\n\n' + intent : ''}` : intent
517
+ let skipResearch = false
518
+ const hasAnswers = existsSync(join(cwd, '.compose', 'questionnaire.json'))
519
+ const runQuestionnaireNow = !autoMode && !hasDesignDoc && (!hasAnswers || askMode) && !fromIdeaId
520
+
521
+ if (runQuestionnaireNow) {
522
+ const { runQuestionnaire } = await import('../lib/questionnaire.js')
523
+ const hasExisting = existingContext.length > 0
524
+ const result = await runQuestionnaire(name, intent, { cwd, hasExistingContent: hasExisting })
525
+ if (!result) process.exit(0) // user aborted
526
+
527
+ finalIntent = result.enrichedIntent
528
+ skipResearch = !result.options.doResearch
529
+
530
+ // Apply pipeline customizations from questionnaire
531
+ if (result.options.reviewAgent === 'Codex (automated review)') {
532
+ const { pipelineSet } = await import('../lib/pipeline-cli.js')
533
+ try {
534
+ pipelineSet(cwd, 'review_gate', ['--mode', 'review'])
535
+ } catch { /* gate may not exist in new.stratum.yaml */ }
536
+ } else if (result.options.reviewAgent === 'Skip review') {
537
+ const { pipelineDisable } = await import('../lib/pipeline-cli.js')
538
+ try {
539
+ pipelineDisable(cwd, ['review_gate'])
540
+ } catch { /* ignore */ }
541
+ }
542
+ } else if (hasAnswers && !autoMode) {
543
+ // Load saved answers to enrich intent without prompting
544
+ const saved = JSON.parse(readFileSync(join(cwd, '.compose', 'questionnaire.json'), 'utf-8'))
545
+ const parts = [saved.refined ?? intent]
546
+ parts.push(`\n## Project Constraints`)
547
+ parts.push(`- Type: ${saved.projectType ?? 'unknown'}`)
548
+ parts.push(`- Language/Runtime: ${saved.language ?? 'unknown'}`)
549
+ parts.push(`- Scope: ${saved.complexity ?? 'unknown'}`)
550
+ if (saved.notes) parts.push(`\n## Additional Context\n${saved.notes}`)
551
+ finalIntent = parts.join('\n')
552
+ skipResearch = saved.doResearch === false
553
+ }
554
+
555
+ // Build final enriched intent with existing context
556
+ const enrichedIntent = existingContext
557
+ ? `${finalIntent}\n\n## Existing project context\n${existingContext}`
558
+ : finalIntent
559
+
560
+ const { runNew } = await import('../lib/new.js')
561
+ try {
562
+ await runNew(enrichedIntent, { cwd, projectName: name, skipResearch })
563
+ } catch (err) {
564
+ console.error(`\nError: ${err.message}`)
565
+ process.exit(1)
566
+ }
567
+ process.exit(0)
568
+ }
569
+
570
+ if (cmd === 'feature') {
571
+ const featureCode = args.find(a => !a.startsWith('-'))
572
+ const description = args.filter(a => !a.startsWith('-')).slice(1).join(' ')
573
+
574
+ if (!featureCode) {
575
+ console.error('Usage: compose feature <CODE> "description of the feature"')
576
+ console.error('')
577
+ console.error('Examples:')
578
+ console.error(' compose feature LOG-1 "CLI tool for parsing JSON-lines log files"')
579
+ console.error(' compose feature AUTH-2 "Add OAuth2 login flow with PKCE"')
580
+ process.exit(1)
581
+ }
582
+
583
+ if (!description) {
584
+ console.error(`Usage: compose feature ${featureCode} "description of the feature"`)
585
+ process.exit(1)
586
+ }
587
+
588
+ const cwd = process.cwd()
589
+ const configPath = join(cwd, '.compose', 'compose.json')
590
+ if (!existsSync(configPath)) {
591
+ console.error("No .compose/compose.json found. Run 'compose init' first.")
592
+ process.exit(1)
593
+ }
594
+
595
+ let config = {}
596
+ try { config = JSON.parse(readFileSync(configPath, 'utf-8')) } catch {}
597
+ const featuresDir = config.paths?.features || 'docs/features'
598
+
599
+ // 1. Create feature folder + seed design doc
600
+ const featureDir = join(cwd, featuresDir, featureCode)
601
+ const designPath = join(featureDir, 'design.md')
602
+
603
+ if (existsSync(designPath)) {
604
+ console.error(`Feature ${featureCode} already exists at ${featureDir}/`)
605
+ process.exit(1)
606
+ }
607
+
608
+ mkdirSync(featureDir, { recursive: true })
609
+
610
+ // ── COMP-UX-3a: Infer project defaults ────────────────────────────────────
611
+ // Detect language from lock files / manifests
612
+ let detectedLang = null
613
+ if (existsSync(join(cwd, 'package.json'))) detectedLang = 'node'
614
+ else if (existsSync(join(cwd, 'pyproject.toml')) || existsSync(join(cwd, 'setup.py'))) detectedLang = 'python'
615
+ else if (existsSync(join(cwd, 'Cargo.toml'))) detectedLang = 'rust'
616
+ else if (existsSync(join(cwd, 'go.mod'))) detectedLang = 'go'
617
+
618
+ // Detect test framework
619
+ let detectedTestFramework = null
620
+ if (detectedLang === 'node') {
621
+ if (existsSync(join(cwd, 'jest.config.js')) || existsSync(join(cwd, 'jest.config.ts')) || existsSync(join(cwd, 'jest.config.mjs'))) detectedTestFramework = 'jest'
622
+ else if (existsSync(join(cwd, 'vitest.config.js')) || existsSync(join(cwd, 'vitest.config.ts')) || existsSync(join(cwd, 'vitest.config.mjs'))) detectedTestFramework = 'vitest'
623
+ else {
624
+ // Check package.json devDependencies
625
+ try {
626
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'))
627
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
628
+ if (deps.jest) detectedTestFramework = 'jest'
629
+ else if (deps.vitest) detectedTestFramework = 'vitest'
630
+ else if (deps.mocha) detectedTestFramework = 'mocha'
631
+ } catch { /* ignore */ }
632
+ }
633
+ } else if (detectedLang === 'python') {
634
+ if (existsSync(join(cwd, 'pytest.ini')) || existsSync(join(cwd, 'conftest.py'))) detectedTestFramework = 'pytest'
635
+ }
636
+
637
+ // Count existing features for complexity estimate
638
+ const existingFeatureCount = (() => {
639
+ try {
640
+ const fdir = join(cwd, featuresDir)
641
+ if (!existsSync(fdir)) return 0
642
+ return readdirSync(fdir, { withFileTypes: true })
643
+ .filter(e => e.isDirectory() && existsSync(join(fdir, e.name, 'feature.json')))
644
+ .length
645
+ } catch { return 0 }
646
+ })()
647
+
648
+ // Infer smart defaults for feature.json profile
649
+ const isSmallProject = existingFeatureCount < 3
650
+ const profile = {
651
+ needs_prd: !isSmallProject,
652
+ needs_architecture: existingFeatureCount >= 3,
653
+ needs_verification: true,
654
+ needs_report: !isSmallProject,
655
+ ...(detectedLang ? { language: detectedLang } : {}),
656
+ ...(detectedTestFramework ? { test_framework: detectedTestFramework } : {}),
657
+ }
658
+
659
+ if (detectedLang) console.log(`Detected language: ${detectedLang}${detectedTestFramework ? ` (${detectedTestFramework})` : ''}`)
660
+ if (existingFeatureCount > 0) console.log(`Existing features: ${existingFeatureCount} — profile: ${JSON.stringify({ needs_prd: profile.needs_prd, needs_architecture: profile.needs_architecture })}`)
661
+ // ── end COMP-UX-3a ─────────────────────────────────────────────────────────
662
+
663
+ // Write feature.json (source of truth)
664
+ const { writeFeature } = await import('../lib/feature-json.js')
665
+ const today = new Date().toISOString().slice(0, 10)
666
+ writeFeature(cwd, {
667
+ code: featureCode,
668
+ description,
669
+ status: 'PLANNED',
670
+ created: today,
671
+ profile,
672
+ }, featuresDir)
673
+ console.log(`Created ${join(featureDir, 'feature.json')}`)
674
+
675
+ const designContent = `# ${featureCode}: ${description}
676
+
677
+ **Status:** PLANNED
678
+ **Created:** ${today}
679
+
680
+ ---
681
+
682
+ ## Intent
683
+
684
+ ${description}
685
+
686
+ ---
687
+
688
+ ## Notes
689
+
690
+ _This is a seed design doc created by \`compose feature\`. The \`compose build\` pipeline will expand it into a full design, blueprint, and implementation plan._
691
+ `
692
+
693
+ writeFileSync(designPath, designContent)
694
+ console.log(`Created ${designPath}`)
695
+
696
+ // 2. Add entry to ROADMAP.md
697
+ const roadmapPath = join(cwd, 'ROADMAP.md')
698
+ if (existsSync(roadmapPath)) {
699
+ let roadmap = readFileSync(roadmapPath, 'utf-8')
700
+
701
+ // Find the last table row with a number to get the next item number
702
+ const itemNums = [...roadmap.matchAll(/^\| (\d+) \|/gm)].map(m => parseInt(m[1], 10))
703
+ const nextNum = itemNums.length > 0 ? Math.max(...itemNums) + 1 : 1
704
+
705
+ // Find the first PLANNED phase table and append, or append to the last table
706
+ const tableRowPattern = /^(\| \d+ \|.*\| PLANNED \|)$/m
707
+ const match = roadmap.match(tableRowPattern)
708
+
709
+ if (match) {
710
+ // Insert after the last row of this table
711
+ const tableEnd = roadmap.indexOf(match[0]) + match[0].length
712
+ // Find end of table (next blank line or ---)
713
+ let insertPos = tableEnd
714
+ const rest = roadmap.slice(tableEnd)
715
+ const nextRows = rest.match(/^(\| \d+ \|.*\|)$/gm)
716
+ if (nextRows) {
717
+ for (const row of nextRows) {
718
+ insertPos = roadmap.indexOf(row, insertPos) + row.length
719
+ }
720
+ }
721
+ const newRow = `\n| ${nextNum} | ${featureCode} | ${description} | PLANNED |`
722
+ roadmap = roadmap.slice(0, insertPos) + newRow + roadmap.slice(insertPos)
723
+ } else {
724
+ // No PLANNED table found — append a features section
725
+ roadmap += `\n\n## Features\n\n| # | Feature | Item | Status |\n|---|---------|------|--------|\n| ${nextNum} | ${featureCode} | ${description} | PLANNED |\n`
726
+ }
727
+
728
+ writeFileSync(roadmapPath, roadmap)
729
+ console.log(`Added ${featureCode} to ROADMAP.md (item #${nextNum})`)
730
+ }
731
+
732
+ // 3. Update project description in ROADMAP if still placeholder
733
+ if (existsSync(roadmapPath)) {
734
+ let roadmap = readFileSync(roadmapPath, 'utf-8')
735
+ if (roadmap.includes('describe your project here')) {
736
+ // Use the first feature description as a hint
737
+ roadmap = roadmap.replace('describe your project here', description)
738
+ writeFileSync(roadmapPath, roadmap)
739
+ }
740
+ }
741
+
742
+ console.log('')
743
+ console.log(`Feature ${featureCode} ready. Next:`)
744
+ console.log(` compose build ${featureCode}`)
745
+ process.exit(0)
746
+ }
747
+
748
+ if (cmd === 'roadmap') {
749
+ const subcmd = args[0]
750
+
751
+ // compose roadmap generate — regenerate ROADMAP.md from feature.json files
752
+ if (subcmd === 'generate' || subcmd === 'gen') {
753
+ const { writeRoadmap } = await import('../lib/roadmap-gen.js')
754
+ const cwd = process.cwd()
755
+ const path = writeRoadmap(cwd)
756
+ console.log(`Generated ${path} from feature.json files`)
757
+ process.exit(0)
758
+ }
759
+
760
+ // compose roadmap migrate — extract ROADMAP.md entries into feature.json files
761
+ if (subcmd === 'migrate') {
762
+ const { migrateRoadmap } = await import('../lib/migrate-roadmap.js')
763
+ const cwd = process.cwd()
764
+ const dryRun = args.includes('--dry-run')
765
+ const overwrite = args.includes('--overwrite')
766
+ const result = migrateRoadmap(cwd, { dryRun, overwrite })
767
+ if (!dryRun) {
768
+ console.log(`Created: ${result.created.length} feature.json files`)
769
+ if (result.created.length > 0) console.log(` ${result.created.join(', ')}`)
770
+ console.log(`Updated: ${result.updated.length}`)
771
+ if (result.updated.length > 0) console.log(` ${result.updated.join(', ')}`)
772
+ console.log(`Skipped: ${result.skipped.length} (already exist, use --overwrite to replace)`)
773
+ if (result.skipped.length > 0) console.log(` ${result.skipped.join(', ')}`)
774
+ }
775
+ process.exit(0)
776
+ }
777
+
778
+ // compose roadmap check — verify feature.json ↔ ROADMAP.md consistency
779
+ if (subcmd === 'check') {
780
+ const { listFeatures } = await import('../lib/feature-json.js')
781
+ const { parseRoadmap } = await import('../lib/roadmap-parser.js')
782
+ const cwd = process.cwd()
783
+ const roadmapPath = join(cwd, 'ROADMAP.md')
784
+ if (!existsSync(roadmapPath)) {
785
+ console.error('No ROADMAP.md found. Run: compose roadmap generate')
786
+ process.exit(1)
787
+ }
788
+ const features = listFeatures(cwd)
789
+ const roadmapEntries = parseRoadmap(readFileSync(roadmapPath, 'utf-8'))
790
+ const roadmapCodes = new Set(roadmapEntries.filter(e => !e.code.startsWith('_anon_')).map(e => e.code))
791
+ const featureCodes = new Set(features.map(f => f.code))
792
+
793
+ let clean = true
794
+ // Features in feature.json but missing from ROADMAP.md
795
+ for (const f of features) {
796
+ if (!roadmapCodes.has(f.code)) {
797
+ console.log(`MISSING from ROADMAP.md: ${f.code}`)
798
+ clean = false
799
+ }
800
+ }
801
+ // Features in ROADMAP.md but missing feature.json
802
+ for (const e of roadmapEntries) {
803
+ if (e.code.startsWith('_anon_')) continue
804
+ if (!featureCodes.has(e.code)) {
805
+ console.log(`NO feature.json: ${e.code}`)
806
+ clean = false
807
+ }
808
+ }
809
+ // Status mismatches
810
+ const roadmapMap = new Map(roadmapEntries.map(e => [e.code, e]))
811
+ for (const f of features) {
812
+ const rm = roadmapMap.get(f.code)
813
+ if (rm && rm.status !== f.status) {
814
+ console.log(`STATUS MISMATCH: ${f.code} — feature.json=${f.status}, ROADMAP.md=${rm.status}`)
815
+ clean = false
816
+ }
817
+ }
818
+
819
+ if (clean) {
820
+ console.log('feature.json and ROADMAP.md are in sync.')
821
+ } else {
822
+ console.log('\nRun `compose roadmap generate` to regenerate ROADMAP.md from feature.json.')
823
+ process.exit(1)
824
+ }
825
+ process.exit(0)
826
+ }
827
+
828
+ // Default: compose roadmap (show status)
829
+ const { parseRoadmap, filterBuildable } = await import('../lib/roadmap-parser.js')
830
+ const { buildDag, topoSort } = await import('../lib/build-dag.js')
831
+ const { readdirSync, statSync } = await import('fs')
832
+
833
+ const SYM = { COMPLETE: '\x1b[32m✓\x1b[0m', PLANNED: '\x1b[90m○\x1b[0m', IN_PROGRESS: '\x1b[33m◐\x1b[0m', PARTIAL: '\x1b[33m◐\x1b[0m', SUPERSEDED: '\x1b[90m✗\x1b[0m', PARKED: '\x1b[90m⏸\x1b[0m' }
834
+
835
+ function showRoadmap(roadmapPath, fallbackLabel) {
836
+ const text = readFileSync(roadmapPath, 'utf-8')
837
+ // Extract project name from "# Title Roadmap" or "# Title" heading
838
+ const titleMatch = text.match(/^#\s+(.+?)(?:\s+Roadmap)?\s*$/m)
839
+ const label = titleMatch ? titleMatch[1].trim() : fallbackLabel
840
+ const allEntries = parseRoadmap(text)
841
+ const named = allEntries.filter(e => !e.code.startsWith('_anon_'))
842
+
843
+ if (named.length === 0) return
844
+
845
+ // Group by phase
846
+ const phases = new Map()
847
+ for (const entry of named) {
848
+ const phase = entry.phaseId || '(ungrouped)'
849
+ if (!phases.has(phase)) phases.set(phase, [])
850
+ phases.get(phase).push(entry)
851
+ }
852
+
853
+ // Counts
854
+ const counts = {}
855
+ for (const e of named) counts[e.status] = (counts[e.status] ?? 0) + 1
856
+ const total = named.length
857
+
858
+ console.log(`\n\x1b[1m${label}\x1b[0m (${total} features)\n`)
859
+
860
+ for (const [phase, entries] of phases) {
861
+ const complete = entries.filter(e => e.status === 'COMPLETE').length
862
+ const phaseColor = complete === entries.length ? '\x1b[32m' : entries.some(e => e.status === 'IN_PROGRESS' || e.status === 'PARTIAL') ? '\x1b[33m' : '\x1b[0m'
863
+ const shortPhase = phase.includes(' > ') ? phase.split(' > ').pop() : phase
864
+ console.log(`${phaseColor}${shortPhase}\x1b[0m (${complete}/${entries.length})`)
865
+ for (const e of entries) {
866
+ const sym = SYM[e.status] ?? '?'
867
+ const desc = e.description.length > 70 ? e.description.slice(0, 67) + '...' : e.description
868
+ console.log(` ${sym} ${e.code.padEnd(16)} ${desc}`)
869
+ }
870
+ console.log('')
871
+ }
872
+
873
+ // Summary bar
874
+ const bar = []
875
+ if (counts.COMPLETE > 0) bar.push(`\x1b[32m${counts.COMPLETE} complete\x1b[0m`)
876
+ if ((counts.IN_PROGRESS ?? 0) + (counts.PARTIAL ?? 0) > 0) bar.push(`\x1b[33m${(counts.IN_PROGRESS ?? 0) + (counts.PARTIAL ?? 0)} in progress\x1b[0m`)
877
+ if (counts.PLANNED > 0) bar.push(`\x1b[90m${counts.PLANNED} planned\x1b[0m`)
878
+ if (counts.SUPERSEDED > 0) bar.push(`\x1b[90m${counts.SUPERSEDED} superseded\x1b[0m`)
879
+ if (counts.PARKED > 0) bar.push(`\x1b[90m${counts.PARKED} parked\x1b[0m`)
880
+ console.log(bar.join(' '))
881
+
882
+ // Next buildable
883
+ const buildable = filterBuildable(allEntries)
884
+ if (buildable.length > 0) {
885
+ const dag = buildDag(allEntries)
886
+ const order = topoSort(dag)
887
+ const buildableSet = new Set(buildable.map(e => e.code))
888
+ const buildOrder = order.filter(code => buildableSet.has(code))
889
+ const descMap = new Map(named.map(e => [e.code, e.description]))
890
+ const next = buildOrder.slice(0, 5)
891
+ console.log(`\n\x1b[1mNext up:\x1b[0m`)
892
+ for (const code of next) {
893
+ const desc = descMap.get(code) ?? ''
894
+ const short = desc.length > 60 ? desc.slice(0, 57) + '...' : desc
895
+ console.log(` compose build ${code}${short ? ` — ${short}` : ''}`)
896
+ }
897
+ if (buildOrder.length > 5) {
898
+ console.log(` ... and ${buildOrder.length - 5} more`)
899
+ }
900
+ }
901
+ }
902
+
903
+ const cwd = process.cwd()
904
+ const roadmapPath = join(cwd, 'ROADMAP.md')
905
+
906
+ if (existsSync(roadmapPath)) {
907
+ // Show cwd roadmap
908
+ showRoadmap(roadmapPath, basename(cwd))
909
+
910
+ // Also scan immediate subdirs for sibling roadmaps
911
+ const subdirs = []
912
+ try {
913
+ for (const entry of readdirSync(cwd)) {
914
+ if (entry.startsWith('.')) continue
915
+ const sub = join(cwd, entry)
916
+ if (statSync(sub).isDirectory() && existsSync(join(sub, 'ROADMAP.md'))) {
917
+ subdirs.push({ name: entry, path: join(sub, 'ROADMAP.md') })
918
+ }
919
+ }
920
+ } catch { /* ignore permission errors */ }
921
+
922
+ for (const { name, path } of subdirs) {
923
+ showRoadmap(path, name)
924
+ }
925
+ } else {
926
+ // No roadmap in cwd — scan subdirs (parent folder of multiple projects)
927
+ const subdirs = []
928
+ try {
929
+ for (const entry of readdirSync(cwd)) {
930
+ if (entry.startsWith('.')) continue
931
+ const sub = join(cwd, entry)
932
+ if (statSync(sub).isDirectory() && existsSync(join(sub, 'ROADMAP.md'))) {
933
+ subdirs.push({ name: entry, path: join(sub, 'ROADMAP.md') })
934
+ }
935
+ }
936
+ } catch { /* ignore */ }
937
+
938
+ if (subdirs.length === 0) {
939
+ console.error('No ROADMAP.md found in the current directory or any subdirectory.')
940
+ process.exit(1)
941
+ }
942
+
943
+ for (const { name, path } of subdirs) {
944
+ showRoadmap(path, name)
945
+ }
946
+ }
947
+
948
+ console.log('')
949
+ process.exit(0)
950
+ }
951
+
952
+ if (cmd === 'pipeline') {
953
+ const { runPipelineCli } = await import('../lib/pipeline-cli.js')
954
+ try {
955
+ runPipelineCli(process.cwd(), args)
956
+ } catch (err) {
957
+ console.error(`Error: ${err.message}`)
958
+ process.exit(1)
959
+ }
960
+ process.exit(0)
961
+ }
962
+
963
+ if (cmd === 'build') {
964
+ // Parse --cwd <path> for cross-repo builds
965
+ let agentWorkDir = null
966
+ const cwdIdx = args.indexOf('--cwd')
967
+ if (cwdIdx !== -1) {
968
+ const cwdValue = args[cwdIdx + 1]
969
+ if (!cwdValue || cwdValue.startsWith('-')) {
970
+ console.error('Error: --cwd requires a path argument')
971
+ process.exit(1)
972
+ }
973
+ agentWorkDir = resolve(cwdValue)
974
+ }
975
+ let filteredArgs = args.filter((a, i) => i !== cwdIdx && (cwdIdx === -1 || i !== cwdIdx + 1))
976
+
977
+ // --team flag (COMP-TEAMS)
978
+ let teamTemplate = null
979
+ try {
980
+ const teamResult = parseTeamFlag(filteredArgs)
981
+ teamTemplate = teamResult.template
982
+ if (teamTemplate) filteredArgs = teamResult.args
983
+ } catch (err) {
984
+ console.error(`Error: ${err.message}`)
985
+ process.exit(1)
986
+ }
987
+
988
+ // --template <name>
989
+ let templateName = null
990
+ const templateIdx = filteredArgs.indexOf('--template')
991
+ if (templateIdx !== -1) {
992
+ const templateValue = filteredArgs[templateIdx + 1]
993
+ if (!templateValue || templateValue.startsWith('-')) {
994
+ console.error('Error: --template requires a name argument')
995
+ process.exit(1)
996
+ }
997
+ templateName = templateValue
998
+ }
999
+ if (teamTemplate && !templateName) {
1000
+ templateName = teamTemplate
1001
+ }
1002
+ const filteredArgs2 = filteredArgs.filter((a, i) => i !== templateIdx && (templateIdx === -1 || i !== templateIdx + 1))
1003
+
1004
+ const featureCodes = filteredArgs2.filter(a => !a.startsWith('-'))
1005
+ const featureCode = featureCodes[0]
1006
+ const abort = filteredArgs2.includes('--abort')
1007
+ const all = filteredArgs2.includes('--all')
1008
+ const dryRun = filteredArgs2.includes('--dry-run')
1009
+ const skipTriage = filteredArgs2.includes('--skip-triage')
1010
+
1011
+ // Multiple codes: compose build FEAT-1 FEAT-2 FEAT-3
1012
+ const isMulti = featureCodes.length > 1
1013
+ // Single prefix: compose build STRAT-COMP (no trailing digit)
1014
+ const isPrefix = featureCodes.length === 1 && featureCode && !/\d$/.test(featureCode)
1015
+ const isBatch = all || isPrefix || isMulti
1016
+
1017
+ if (abort && isBatch) {
1018
+ console.error('Error: --abort and --all/prefix/multi are mutually exclusive')
1019
+ process.exit(1)
1020
+ }
1021
+
1022
+ if (!featureCode && !abort && !all) {
1023
+ console.error('Usage: compose build <feature-code> [feature-code...]')
1024
+ console.error(' compose build STRAT-COMP (prefix — builds all matching)')
1025
+ console.error(' compose build --all (builds entire roadmap)')
1026
+ console.error('')
1027
+ console.error('Options:')
1028
+ console.error(' --abort Abort the active build')
1029
+ console.error(' --all Build all PLANNED features in dependency order')
1030
+ console.error(' --dry-run Print build order without executing')
1031
+ console.error(' --cwd <path> Agent working directory (for cross-repo features)')
1032
+ process.exit(1)
1033
+ }
1034
+
1035
+ // Auto-init if needed
1036
+ const buildCwd = process.cwd()
1037
+ if (!existsSync(join(buildCwd, '.compose', 'compose.json')) || !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml'))) {
1038
+ console.log('Running compose init...\n')
1039
+ await runInit(args.filter(a => a.startsWith('--')))
1040
+ console.log('')
1041
+ }
1042
+
1043
+ if (isBatch && teamTemplate) {
1044
+ console.error('Error: --team cannot be used with batch builds (--all, multiple features, or prefix matching)')
1045
+ process.exit(1)
1046
+ }
1047
+
1048
+ if (isBatch) {
1049
+ import('../lib/build-all.js').then(({ runBuildAll }) => {
1050
+ const batchOpts = { cwd: buildCwd, dryRun }
1051
+ if (agentWorkDir) batchOpts.workingDirectory = agentWorkDir
1052
+ if (isMulti) {
1053
+ batchOpts.features = featureCodes
1054
+ } else if (isPrefix) {
1055
+ batchOpts.filter = featureCode
1056
+ }
1057
+ runBuildAll(batchOpts).then((result) => {
1058
+ process.exit(result.failed.length > 0 ? 1 : 0)
1059
+ }).catch((err) => {
1060
+ console.error(`Build all failed: ${err.message}`)
1061
+ process.exit(1)
1062
+ })
1063
+ })
1064
+ } else {
1065
+ import('../lib/build.js').then(({ runBuild }) => {
1066
+ const singleOpts = { abort }
1067
+ if (agentWorkDir) singleOpts.workingDirectory = agentWorkDir
1068
+ if (skipTriage) singleOpts.skipTriage = true
1069
+ if (templateName) singleOpts.template = templateName
1070
+ runBuild(featureCode, singleOpts).then(() => {
1071
+ process.exit(0)
1072
+ }).catch((err) => {
1073
+ console.error(`Build failed: ${err.message}`)
1074
+ process.exit(1)
1075
+ })
1076
+ })
1077
+ }
1078
+ } else if (cmd === 'triage') {
1079
+ const triageCode = args.find(a => !a.startsWith('-'))
1080
+ if (!triageCode) {
1081
+ console.error('Usage: compose triage <feature-code>')
1082
+ process.exit(1)
1083
+ }
1084
+ import('../lib/triage.js').then(({ runTriage }) => {
1085
+ import('../lib/feature-json.js').then(({ readFeature, writeFeature, updateFeature }) => {
1086
+ const trCwd = process.cwd()
1087
+ runTriage(triageCode, { cwd: trCwd }).then((result) => {
1088
+ console.log(`\nFeature: ${triageCode}`)
1089
+ console.log(`Tier: ${result.tier}`)
1090
+ console.log(`Rationale: ${result.rationale}`)
1091
+ console.log(`\nProfile:`)
1092
+ for (const [k, v] of Object.entries(result.profile)) {
1093
+ console.log(` ${k}: ${v}`)
1094
+ }
1095
+ console.log(`\nSignals:`)
1096
+ console.log(` file paths found: ${result.signals.fileCount}`)
1097
+ console.log(` task count: ${result.signals.taskCount}`)
1098
+ console.log(` security paths: ${result.signals.securityPaths}`)
1099
+ console.log(` core paths: ${result.signals.corePaths}`)
1100
+
1101
+ // Persist to feature.json
1102
+ const triageTimestamp = new Date().toISOString()
1103
+ const existing = readFeature(trCwd, triageCode)
1104
+ if (!existing) {
1105
+ writeFeature(trCwd, {
1106
+ code: triageCode,
1107
+ description: triageCode,
1108
+ status: 'PLANNED',
1109
+ complexity: String(result.tier),
1110
+ profile: result.profile,
1111
+ triageTimestamp,
1112
+ })
1113
+ console.log(`\nCreated feature.json for ${triageCode}`)
1114
+ } else {
1115
+ updateFeature(trCwd, triageCode, {
1116
+ complexity: String(result.tier),
1117
+ profile: result.profile,
1118
+ triageTimestamp,
1119
+ })
1120
+ console.log(`\nUpdated feature.json for ${triageCode}`)
1121
+ }
1122
+ process.exit(0)
1123
+ }).catch((err) => {
1124
+ console.error(`Triage failed: ${err.message}`)
1125
+ process.exit(1)
1126
+ })
1127
+ })
1128
+ })
1129
+ } else if (cmd === 'start') {
1130
+ // Resolve target root BEFORE spawning supervisor
1131
+ const explicitTarget = process.env.COMPOSE_TARGET
1132
+ const targetRoot = explicitTarget
1133
+ ? resolve(explicitTarget)
1134
+ : findProjectRoot(process.cwd())
1135
+
1136
+ if (explicitTarget && !existsSync(resolve(explicitTarget))) {
1137
+ console.error(`[compose] COMPOSE_TARGET=${explicitTarget} does not exist.`)
1138
+ process.exit(1)
1139
+ }
1140
+ if (!targetRoot || !existsSync(join(targetRoot, '.compose', 'compose.json'))) {
1141
+ console.error('[compose] No .compose/ found (searched from cwd upward).')
1142
+ console.error("[compose] Run 'compose init' first, or set COMPOSE_TARGET.")
1143
+ process.exit(1)
1144
+ }
1145
+
1146
+ const child = spawn('node', [join(PACKAGE_ROOT, 'server', 'supervisor.js')], {
1147
+ stdio: 'inherit',
1148
+ cwd: PACKAGE_ROOT,
1149
+ env: { ...process.env, COMPOSE_TARGET: targetRoot },
1150
+ })
1151
+ child.on('error', (err) => {
1152
+ console.error(`Failed to start compose: ${err.message}`)
1153
+ process.exit(1)
1154
+ })
1155
+ child.on('exit', (code) => process.exit(code ?? 0))
1156
+ } else if (cmd === 'ideabox') {
1157
+ // ---------------------------------------------------------------------------
1158
+ // compose ideabox — idea management CLI
1159
+ // ---------------------------------------------------------------------------
1160
+ const ibSubcmd = args[0]
1161
+ const ibCwd = process.cwd()
1162
+
1163
+ // Resolve compose config (paths, etc.)
1164
+ function loadComposeConfig(cwd) {
1165
+ const cfgPath = join(cwd, '.compose', 'compose.json')
1166
+ if (existsSync(cfgPath)) {
1167
+ try { return JSON.parse(readFileSync(cfgPath, 'utf-8')) } catch {}
1168
+ }
1169
+ return {}
1170
+ }
1171
+ function getIdeaboxRelPath(cwd) {
1172
+ return loadComposeConfig(cwd)?.paths?.ideabox || 'docs/product/ideabox.md'
1173
+ }
1174
+ const ibConfig = loadComposeConfig(ibCwd)
1175
+
1176
+ const {
1177
+ parseIdeabox: _parseIdeabox,
1178
+ serializeIdeabox: _serializeIdeabox,
1179
+ addIdea: _addIdea,
1180
+ promoteIdea: _promoteIdea,
1181
+ killIdea: _killIdea,
1182
+ setPriority: _setPriority,
1183
+ loadLens: _loadLens,
1184
+ readIdeabox: _readIdeabox,
1185
+ writeIdeabox: _writeIdeabox,
1186
+ addDiscussion: _addDiscussion,
1187
+ } = await import('../lib/ideabox.js')
1188
+
1189
+ const ibRelPath = getIdeaboxRelPath(ibCwd)
1190
+ const ibFullPath = join(ibCwd, ibRelPath)
1191
+
1192
+ if (!ibSubcmd || ibSubcmd === '--help' || ibSubcmd === '-h') {
1193
+ console.log('Usage: compose ideabox <subcommand>')
1194
+ console.log('')
1195
+ console.log('Subcommands:')
1196
+ console.log(' add "<title>" Add a new idea')
1197
+ console.log(' list List all ideas')
1198
+ console.log(' promote <ID> Mark idea as PROMOTED (creates feature folder)')
1199
+ console.log(' kill <ID> "<reason>" Move idea to Killed Ideas')
1200
+ console.log(' pri <ID> <P0|P1|P2> Set priority')
1201
+ console.log(' discuss <ID> "<comment>" Add a discussion comment')
1202
+ console.log(' triage [--lens <name>] Walk untriaged ideas and assign priorities')
1203
+ process.exit(0)
1204
+ }
1205
+
1206
+ if (ibSubcmd === 'add') {
1207
+ const title = args.slice(1).find(a => !a.startsWith('-')) || args[1]
1208
+ if (!title) {
1209
+ console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]')
1210
+ process.exit(1)
1211
+ }
1212
+ // Parse optional flags
1213
+ const sourceIdx = args.indexOf('--source')
1214
+ const descIdx = args.indexOf('--desc')
1215
+ const clusterIdx = args.indexOf('--cluster')
1216
+ const tagsIdx = args.indexOf('--tags')
1217
+ const source = sourceIdx !== -1 ? args[sourceIdx + 1] : ''
1218
+ const description = descIdx !== -1 ? args[descIdx + 1] : ''
1219
+ const cluster = clusterIdx !== -1 ? args[clusterIdx + 1] : null
1220
+ const tagsRaw = tagsIdx !== -1 ? args[tagsIdx + 1] : ''
1221
+ const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim().startsWith('#') ? t.trim() : `#${t.trim()}`) : []
1222
+
1223
+ if (!existsSync(ibFullPath)) {
1224
+ const { IDEABOX_TEMPLATE } = await import('../lib/ideabox.js')
1225
+ mkdirSync(dirname(ibFullPath), { recursive: true })
1226
+ writeFileSync(ibFullPath, IDEABOX_TEMPLATE)
1227
+ }
1228
+
1229
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1230
+ _addIdea(parsed, { title, description, source, tags, cluster })
1231
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1232
+ const newIdea = parsed.ideas[parsed.ideas.length - 1]
1233
+ console.log(`Added ${newIdea.id}: ${newIdea.title}`)
1234
+ process.exit(0)
1235
+ }
1236
+
1237
+ if (ibSubcmd === 'list') {
1238
+ if (!existsSync(ibFullPath)) {
1239
+ console.log('No ideabox found. Run: compose ideabox add "<title>"')
1240
+ process.exit(0)
1241
+ }
1242
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1243
+ if (parsed.ideas.length === 0 && parsed.killed.length === 0) {
1244
+ console.log('No ideas yet.')
1245
+ process.exit(0)
1246
+ }
1247
+
1248
+ // Group by status then priority
1249
+ const byStatus = {}
1250
+ for (const idea of parsed.ideas) {
1251
+ const s = idea.status.startsWith('PROMOTED') ? 'PROMOTED' : idea.status
1252
+ if (!byStatus[s]) byStatus[s] = []
1253
+ byStatus[s].push(idea)
1254
+ }
1255
+
1256
+ const statusOrder = ['NEW', 'DISCUSSING', 'PROMOTED']
1257
+ const priorityOrder = { P0: 0, P1: 1, P2: 2, '—': 3 }
1258
+
1259
+ for (const status of statusOrder) {
1260
+ const group = byStatus[status]
1261
+ if (!group || group.length === 0) continue
1262
+ group.sort((a, b) => (priorityOrder[a.priority] ?? 3) - (priorityOrder[b.priority] ?? 3))
1263
+ console.log(`\n[${status}]`)
1264
+ for (const idea of group) {
1265
+ const pri = idea.priority !== '—' ? ` [${idea.priority}]` : ''
1266
+ const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : ''
1267
+ console.log(` ${idea.id}${pri} ${idea.title}${tags}`)
1268
+ }
1269
+ }
1270
+
1271
+ if (parsed.killed.length > 0) {
1272
+ console.log(`\n[KILLED] (${parsed.killed.length})`)
1273
+ for (const idea of parsed.killed) {
1274
+ console.log(` ${idea.id} ${idea.title} — ${idea.killedReason}`)
1275
+ }
1276
+ }
1277
+ process.exit(0)
1278
+ }
1279
+
1280
+ if (ibSubcmd === 'promote') {
1281
+ const ideaId = args[1]
1282
+ if (!ideaId) {
1283
+ console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]')
1284
+ process.exit(1)
1285
+ }
1286
+ const featureCode = args[2] || ''
1287
+
1288
+ if (!existsSync(ibFullPath)) {
1289
+ console.error(`Ideabox not found at ${ibFullPath}`)
1290
+ process.exit(1)
1291
+ }
1292
+
1293
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1294
+ const idea = parsed.ideas.find(i => i.id.toUpperCase() === ideaId.toUpperCase())
1295
+ if (!idea) {
1296
+ console.error(`Idea not found: ${ideaId}`)
1297
+ process.exit(1)
1298
+ }
1299
+
1300
+ // Generate feature code if not provided
1301
+ let resolvedCode = featureCode
1302
+ if (!resolvedCode) {
1303
+ // Derive a slug from the title
1304
+ const slug = idea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '')
1305
+ resolvedCode = `IDEA-${idea.num}-${slug}`.toUpperCase()
1306
+ }
1307
+
1308
+ // Create feature folder if missing — respect paths.features from compose.json
1309
+ const featuresRel = ibConfig.paths?.features || 'docs/features'
1310
+ const featuresDir = join(ibCwd, featuresRel, resolvedCode)
1311
+ if (!existsSync(featuresDir)) {
1312
+ mkdirSync(featuresDir, { recursive: true })
1313
+ writeFileSync(join(featuresDir, 'feature.json'), JSON.stringify({
1314
+ code: resolvedCode,
1315
+ description: idea.title,
1316
+ status: 'PLANNED',
1317
+ promotedFrom: ideaId,
1318
+ createdAt: new Date().toISOString(),
1319
+ }, null, 2))
1320
+ console.log(`Created feature folder: ${featuresRel}/${resolvedCode}/`)
1321
+ }
1322
+
1323
+ _promoteIdea(parsed, ideaId, resolvedCode)
1324
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1325
+ console.log(`Promoted ${ideaId} → ${resolvedCode}`)
1326
+ process.exit(0)
1327
+ }
1328
+
1329
+ if (ibSubcmd === 'kill') {
1330
+ const ideaId = args[1]
1331
+ const reason = args[2] || ''
1332
+ if (!ideaId) {
1333
+ console.error('Usage: compose ideabox kill <ID> "<reason>"')
1334
+ process.exit(1)
1335
+ }
1336
+ if (!existsSync(ibFullPath)) {
1337
+ console.error(`Ideabox not found at ${ibFullPath}`)
1338
+ process.exit(1)
1339
+ }
1340
+
1341
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1342
+ _killIdea(parsed, ideaId, reason)
1343
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1344
+ console.log(`Killed ${ideaId}: ${reason}`)
1345
+ process.exit(0)
1346
+ }
1347
+
1348
+ if (ibSubcmd === 'pri') {
1349
+ const ideaId = args[1]
1350
+ const priority = args[2]
1351
+ if (!ideaId || !priority) {
1352
+ console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>')
1353
+ process.exit(1)
1354
+ }
1355
+ if (!existsSync(ibFullPath)) {
1356
+ console.error(`Ideabox not found at ${ibFullPath}`)
1357
+ process.exit(1)
1358
+ }
1359
+
1360
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1361
+ _setPriority(parsed, ideaId, priority)
1362
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1363
+ console.log(`Set ${ideaId} priority → ${priority}`)
1364
+ process.exit(0)
1365
+ }
1366
+
1367
+ if (ibSubcmd === 'discuss') {
1368
+ const ideaId = args[1]
1369
+ const comment = args[2]
1370
+ if (!ideaId || !comment) {
1371
+ console.error('Usage: compose ideabox discuss <ID> "<comment>"')
1372
+ process.exit(1)
1373
+ }
1374
+ if (!existsSync(ibFullPath)) {
1375
+ console.error(`Ideabox not found at ${ibFullPath}`)
1376
+ process.exit(1)
1377
+ }
1378
+
1379
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1380
+ _addDiscussion(parsed, ideaId, 'human', comment)
1381
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1382
+ const today = new Date().toISOString().slice(0, 10)
1383
+ console.log(`[${today}] human: ${comment}`)
1384
+ process.exit(0)
1385
+ }
1386
+
1387
+ if (ibSubcmd === 'triage') {
1388
+ const lensIdx = args.indexOf('--lens')
1389
+ const lensName = lensIdx !== -1 ? args[lensIdx + 1] : null
1390
+
1391
+ if (!existsSync(ibFullPath)) {
1392
+ console.log('No ideabox found. Run: compose ideabox add "<title>" first.')
1393
+ process.exit(0)
1394
+ }
1395
+
1396
+ const parsed = _readIdeabox(ibCwd, ibRelPath)
1397
+ const untriaged = parsed.ideas.filter(i => i.priority === '—' && i.status === 'NEW')
1398
+
1399
+ if (untriaged.length === 0) {
1400
+ console.log('No untriaged ideas.')
1401
+ process.exit(0)
1402
+ }
1403
+
1404
+ let lens = null
1405
+ if (lensName) {
1406
+ lens = _loadLens(ibCwd, lensName)
1407
+ if (!lens) {
1408
+ console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`)
1409
+ } else {
1410
+ console.log(`Using lens: ${lensName}`)
1411
+ }
1412
+ }
1413
+
1414
+ // Interactive triage using readline
1415
+ const { createInterface } = await import('node:readline')
1416
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1417
+ const question = (q) => new Promise(resolve => rl.question(q, resolve))
1418
+
1419
+ let changed = false
1420
+ for (const idea of untriaged) {
1421
+ console.log(`\n${idea.id}: ${idea.title}`)
1422
+ if (idea.description) console.log(` ${idea.description.slice(0, 120)}`)
1423
+ if (lens) console.log(` [lens: ${lensName}]`)
1424
+
1425
+ const ans = await question(' Priority [P0/P1/P2/skip]: ')
1426
+ const p = ans.trim().toUpperCase()
1427
+ if (['P0', 'P1', 'P2'].includes(p)) {
1428
+ _setPriority(parsed, idea.id, p)
1429
+ changed = true
1430
+ console.log(` Set ${idea.id} → ${p}`)
1431
+ } else {
1432
+ console.log(' Skipped')
1433
+ }
1434
+ }
1435
+
1436
+ rl.close()
1437
+
1438
+ if (changed) {
1439
+ _writeIdeabox(ibCwd, ibRelPath, parsed)
1440
+ console.log('\nSaved.')
1441
+ }
1442
+ process.exit(0)
1443
+ }
1444
+
1445
+ console.error(`Unknown ideabox subcommand: ${ibSubcmd}`)
1446
+ console.error('Run: compose ideabox --help')
1447
+ process.exit(1)
1448
+
1449
+ } else if (cmd === 'qa-scope') {
1450
+ // ---------------------------------------------------------------------------
1451
+ // compose qa-scope <featureCode>
1452
+ // COMP-QA item 116: inspect which routes are affected by a feature's filesChanged
1453
+ // ---------------------------------------------------------------------------
1454
+ const qsCode = args.find(a => !a.startsWith('-'))
1455
+ if (!qsCode) {
1456
+ console.error('Usage: compose qa-scope <feature-code>')
1457
+ process.exit(1)
1458
+ }
1459
+
1460
+ const qsCwd = process.cwd()
1461
+
1462
+ import('../lib/feature-json.js').then(({ readFeature }) => {
1463
+ import('../lib/qa-scoping.js').then(({ mapFilesToRoutes, classifyRoutes }) => {
1464
+ const feature = readFeature(qsCwd, qsCode)
1465
+ if (!feature) {
1466
+ console.error(`Feature not found: ${qsCode}`)
1467
+ process.exit(1)
1468
+ }
1469
+
1470
+ const filesChanged = feature.filesChanged ?? []
1471
+ if (filesChanged.length === 0) {
1472
+ console.log(`No filesChanged recorded for ${qsCode}.`)
1473
+ console.log('Run a build first so the pipeline tracks touched files.')
1474
+ process.exit(0)
1475
+ }
1476
+
1477
+ const result = mapFilesToRoutes(filesChanged, { cwd: qsCwd })
1478
+ const allKnown = [] // v1: no known-routes registry
1479
+ const { affected, adjacent } = classifyRoutes(result.affectedRoutes, allKnown)
1480
+
1481
+ console.log(`\nQA Scope for ${qsCode}`)
1482
+ console.log(`Framework: ${result.framework}`)
1483
+ console.log(`Docs-only: ${result.docsOnly}`)
1484
+ console.log(`\nAffected routes (${affected.length}):`)
1485
+ if (affected.length === 0) {
1486
+ console.log(' (none — no code files mapped to known routes)')
1487
+ } else {
1488
+ for (const r of affected) console.log(` ${r}`)
1489
+ }
1490
+
1491
+ console.log(`\nAdjacent routes (${adjacent.length}):`)
1492
+ if (adjacent.length === 0) {
1493
+ console.log(' (none)')
1494
+ } else {
1495
+ for (const r of adjacent) console.log(` ${r}`)
1496
+ }
1497
+
1498
+ console.log(`\nUnmapped files (${result.unmappedFiles.length}):`)
1499
+ if (result.unmappedFiles.length === 0) {
1500
+ console.log(' (none)')
1501
+ } else {
1502
+ for (const f of result.unmappedFiles) console.log(` ${f}`)
1503
+ }
1504
+
1505
+ process.exit(0)
1506
+ })
1507
+ }).catch((err) => {
1508
+ console.error(`qa-scope failed: ${err.message}`)
1509
+ process.exit(1)
1510
+ })
1511
+
1512
+ } else {
1513
+ console.error(`Unknown command: ${cmd}`)
1514
+ process.exit(1)
1515
+ }