@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.
- package/LICENSE +21 -0
- package/README.md +1014 -0
- package/bin/compose.js +1515 -0
- package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
- package/dist/assets/arc-SxJ2J1sh.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
- package/dist/assets/channel-DGElom1e.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
- package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
- package/dist/assets/clone-DUJKJXd7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
- package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
- package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
- package/dist/assets/graph-D0Cfv00Y.js +1 -0
- package/dist/assets/index-CUd6pFGF.css +1 -0
- package/dist/assets/index-DReRlzZI.js +1144 -0
- package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bj72wOEB.js +1 -0
- package/dist/assets/linear-BRFo114D.js +1 -0
- package/dist/assets/min-GCHnKlJS.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
- package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
- package/dist/index.html +30 -0
- package/lib/agent-chains.js +65 -0
- package/lib/agent-string.js +86 -0
- package/lib/budget-ledger.js +86 -0
- package/lib/build-all.js +162 -0
- package/lib/build-dag.js +120 -0
- package/lib/build-stream-writer.js +190 -0
- package/lib/build.js +2997 -0
- package/lib/capability-checker.js +53 -0
- package/lib/cert-inject.js +38 -0
- package/lib/cli-progress.js +483 -0
- package/lib/constants.js +69 -0
- package/lib/cross-layer-audit.js +84 -0
- package/lib/debug-discipline.js +173 -0
- package/lib/feature-json.js +106 -0
- package/lib/gate-prompt.js +291 -0
- package/lib/gate-tiers.js +194 -0
- package/lib/health-history.js +119 -0
- package/lib/health-score.js +227 -0
- package/lib/ideabox.js +570 -0
- package/lib/import.js +244 -0
- package/lib/migrate-roadmap.js +94 -0
- package/lib/model-pricing.js +67 -0
- package/lib/new.js +413 -0
- package/lib/pipeline-cli.js +489 -0
- package/lib/plan-parser.js +103 -0
- package/lib/qa-scoping.js +474 -0
- package/lib/questionnaire.js +200 -0
- package/lib/resolve-port.js +7 -0
- package/lib/result-normalizer.js +349 -0
- package/lib/review-lenses.js +166 -0
- package/lib/roadmap-gen.js +210 -0
- package/lib/roadmap-parser.js +176 -0
- package/lib/server-probe.js +23 -0
- package/lib/staleness.js +87 -0
- package/lib/step-prompt.js +260 -0
- package/lib/step-validator.js +49 -0
- package/lib/stratum-mcp-client.js +365 -0
- package/lib/team-flag.js +46 -0
- package/lib/test-bootstrap.js +401 -0
- package/lib/triage.js +274 -0
- package/lib/vision-writer.js +391 -0
- package/package.json +111 -0
- package/pipelines/bug-fix.stratum.yaml +230 -0
- package/pipelines/build.stratum.yaml +498 -0
- package/pipelines/content.stratum.yaml +112 -0
- package/pipelines/coverage-sweep.stratum.yaml +52 -0
- package/pipelines/refactor.stratum.yaml +169 -0
- package/pipelines/research.stratum.yaml +88 -0
- package/pipelines/review-fix.stratum.yaml +109 -0
- package/presets/team-feature.stratum.yaml +105 -0
- package/presets/team-research.stratum.yaml +108 -0
- package/presets/team-review.stratum.yaml +106 -0
- package/scripts/agent-activity-hook.sh +31 -0
- package/scripts/agent-error-hook.sh +28 -0
- package/scripts/analyze-orphans.mjs +50 -0
- package/scripts/find-orphans.mjs +26 -0
- package/scripts/fix-phases.mjs +49 -0
- package/scripts/generate-stratum-spec.mjs +137 -0
- package/scripts/import-roadmap.mjs +116 -0
- package/scripts/phase-audit.mjs +33 -0
- package/scripts/run-pipeline.mjs +314 -0
- package/scripts/session-end-hook.sh +18 -0
- package/scripts/session-start-hook.sh +38 -0
- package/scripts/vision-hook.sh +104 -0
- package/scripts/vision-track.mjs +554 -0
- package/scripts/wire-all-orphans.mjs +108 -0
- package/scripts/wire-orphans.mjs +164 -0
- package/server/activity-routes.js +123 -0
- package/server/agent-health.js +197 -0
- package/server/agent-hooks.js +102 -0
- package/server/agent-mcp.js +10 -0
- package/server/agent-registry.js +95 -0
- package/server/agent-server.js +290 -0
- package/server/agent-spawn.js +251 -0
- package/server/agent-templates.js +77 -0
- package/server/artifact-manager.js +247 -0
- package/server/artifact-templates/architecture.md +28 -0
- package/server/artifact-templates/blueprint.md +21 -0
- package/server/artifact-templates/design.md +36 -0
- package/server/artifact-templates/plan.md +25 -0
- package/server/artifact-templates/prd.md +43 -0
- package/server/artifact-templates/report.md +40 -0
- package/server/block-tracker.js +90 -0
- package/server/build-stream-bridge.js +502 -0
- package/server/coalescing-buffer.js +46 -0
- package/server/compose-mcp-tools.js +479 -0
- package/server/compose-mcp.js +324 -0
- package/server/connectors/agent-connector.js +78 -0
- package/server/connectors/claude-sdk-connector.js +198 -0
- package/server/connectors/codex-connector.js +240 -0
- package/server/connectors/connector-discovery.js +18 -0
- package/server/connectors/connector-runtime.js +13 -0
- package/server/connectors/opencode-connector.js +200 -0
- package/server/design-routes.js +540 -0
- package/server/design-session.js +161 -0
- package/server/feature-scan.js +593 -0
- package/server/file-watcher.js +284 -0
- package/server/find-root.js +29 -0
- package/server/graph-export.js +343 -0
- package/server/ideabox-cache.js +77 -0
- package/server/ideabox-routes.js +294 -0
- package/server/index.js +156 -0
- package/server/model-tiers.js +49 -0
- package/server/pipeline-routes.js +288 -0
- package/server/policy-evaluator.js +36 -0
- package/server/project-root.js +122 -0
- package/server/security.js +23 -0
- package/server/session-manager.js +403 -0
- package/server/session-routes.js +190 -0
- package/server/session-store.js +107 -0
- package/server/settings-routes.js +35 -0
- package/server/settings-store.js +234 -0
- package/server/stratum-api.js +102 -0
- package/server/stratum-client.js +192 -0
- package/server/stratum-sync.js +193 -0
- package/server/summarizer.js +139 -0
- package/server/supervisor.js +196 -0
- package/server/vision-routes.js +668 -0
- package/server/vision-server.js +393 -0
- package/server/vision-store.js +360 -0
- package/server/vision-utils.js +179 -0
- package/server/worktree-gc.js +137 -0
- 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
|
+
}
|