@smartmemory/compose 0.1.7-beta → 0.1.9-beta

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 (84) hide show
  1. package/README.md +32 -5
  2. package/bin/compose.js +294 -34
  3. package/bin/git-hooks/post-commit.template +2 -1
  4. package/bin/git-hooks/pre-push.template +2 -1
  5. package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
  6. package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
  7. package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
  8. package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
  9. package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
  10. package/dist/assets/channel-DDkv7DUd.js +1 -0
  11. package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
  12. package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
  14. package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
  16. package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
  18. package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
  19. package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
  20. package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
  21. package/dist/assets/clone-5MVZ89iV.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
  23. package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
  24. package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
  25. package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
  26. package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
  27. package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
  28. package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
  30. package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
  32. package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
  33. package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
  34. package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
  36. package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
  37. package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
  38. package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
  39. package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
  40. package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
  41. package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
  42. package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
  44. package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
  47. package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
  49. package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
  50. package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
  51. package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
  52. package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
  53. package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
  54. package/dist/index.html +1 -1
  55. package/lib/build.js +193 -19
  56. package/lib/completion-writer.js +7 -4
  57. package/lib/deps.js +17 -6
  58. package/lib/discover-workspaces.js +109 -0
  59. package/lib/feature-events.js +3 -0
  60. package/lib/feature-writer.js +34 -22
  61. package/lib/followup-writer.js +556 -0
  62. package/lib/mcp-enforcement.js +173 -0
  63. package/lib/migrate-roadmap.js +4 -1
  64. package/lib/project-paths.js +36 -0
  65. package/lib/resolve-workspace.js +166 -0
  66. package/lib/review-lenses.js +23 -8
  67. package/lib/review-normalize.js +42 -3
  68. package/lib/roadmap-drift.js +54 -0
  69. package/lib/roadmap-gen.js +297 -27
  70. package/lib/roadmap-preservers.js +353 -0
  71. package/lib/step-prompt.js +15 -0
  72. package/lib/triage.js +2 -1
  73. package/lib/version-check.js +110 -0
  74. package/package.json +1 -2
  75. package/server/compose-mcp-tools.js +44 -8
  76. package/server/compose-mcp.js +66 -1
  77. package/server/project-root.js +4 -0
  78. package/server/vision-routes.js +51 -2
  79. package/templates/ROADMAP.md +6 -0
  80. package/dist/assets/channel-LRG9kHqJ.js +0 -1
  81. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
  82. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
  83. package/dist/assets/clone-dRxgFrBv.js +0 -1
  84. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +0 -1
package/README.md CHANGED
@@ -34,9 +34,20 @@ compose build TODO-1
34
34
 
35
35
  Prerequisites: Node.js 18+ and `stratum-mcp` on PATH (`pip install stratum`). Codex steps additionally need the OpenAI `codex` CLI. Full prereqs in [docs/install.md](docs/install.md).
36
36
 
37
+ The package is published to npm as `@smartmemory/compose`. Pick one install style:
38
+
39
+ **Option A — npm (recommended for users):**
40
+
41
+ ```bash
42
+ npm install -g @smartmemory/compose
43
+ compose setup # global skill + stratum-mcp registration
44
+ ```
45
+
46
+ **Option B — git clone (for development):**
47
+
37
48
  ```bash
38
49
  git clone https://github.com/smartmemory/compose.git && cd compose && npm install
39
- npx compose setup # global skill + stratum-mcp registration
50
+ npx @smartmemory/compose setup # or: node bin/compose.js setup
40
51
  ln -s "$(pwd)/bin/compose.js" ~/bin/compose && chmod +x ~/bin/compose # optional: bare `compose` command
41
52
  ```
42
53
 
@@ -44,15 +55,31 @@ Then in your project:
44
55
 
45
56
  ```bash
46
57
  cd /path/to/your/project
47
- npx compose init # writes .compose/, registers MCP, scaffolds ROADMAP and pipeline specs
48
- npx compose new "what you want to build"
58
+ compose init # writes .compose/, registers MCP, scaffolds ROADMAP and pipeline specs
59
+ compose new "what you want to build"
49
60
  ```
50
61
 
51
62
  Add an isolated feature to an existing project:
52
63
 
53
64
  ```bash
54
- npx compose feature AUTH-1 "JWT middleware with refresh tokens"
55
- npx compose build AUTH-1
65
+ compose feature AUTH-1 "JWT middleware with refresh tokens"
66
+ compose build AUTH-1
67
+ ```
68
+
69
+ ## Upgrading
70
+
71
+ One command — auto-detects whether compose was installed via npm or git clone:
72
+
73
+ ```bash
74
+ compose update
75
+ ```
76
+
77
+ For npm installs, this runs `npm install -g @smartmemory/compose@latest`. For git clones, it runs `git pull --ff-only && npm install`. Either way it then refreshes the global skill and (if invoked from inside a Compose project) re-runs `compose init` to refresh `.mcp.json` and pipeline templates. Use `compose update --force` to bypass the dirty-tree check on git clones.
78
+
79
+ Check what you're running:
80
+
81
+ ```bash
82
+ compose --version
56
83
  ```
57
84
 
58
85
  ## Documentation
package/bin/compose.js CHANGED
@@ -10,20 +10,73 @@
10
10
  * compose fix — headless bug-fix lifecycle runner (pipelines/bug-fix.stratum.yaml)
11
11
  */
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync, readdirSync } from 'fs'
13
- import { resolve, join, basename, dirname } from 'path'
13
+ import { resolve, join, basename, dirname, sep } from 'path'
14
14
  import { homedir } from 'os'
15
15
  import { spawn, spawnSync } from 'child_process'
16
16
  import { fileURLToPath } from 'url'
17
17
  import { findProjectRoot } from '../server/find-root.js'
18
+ import { resolveWorkspace, getWorkspaceFlag } from '../lib/resolve-workspace.js'
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url))
20
21
  const PACKAGE_ROOT = resolve(__dirname, '..')
21
22
 
23
+ function dieOnWorkspaceError(err) {
24
+ switch (err.code) {
25
+ case 'WorkspaceAmbiguous':
26
+ console.error('Multiple workspaces match cwd. Add --workspace=<id> or set COMPOSE_TARGET:')
27
+ for (const c of err.candidates) console.error(` --workspace=${c.id} (${c.root})`)
28
+ process.exit(1)
29
+ case 'WorkspaceIdCollision':
30
+ console.error(`workspaceId "${err.id}" is used by multiple roots:`)
31
+ for (const r of err.roots) console.error(` ${r}`)
32
+ console.error('Set an explicit workspaceId in each .compose/compose.json.')
33
+ process.exit(1)
34
+ case 'WorkspaceUnknown':
35
+ // err.message may be the path-doesn't-exist form; prefer it when richer.
36
+ console.error(err.message.includes('does not exist') ? err.message : `Unknown workspace: ${err.id}. Run \`compose doctor\` to list candidates.`)
37
+ process.exit(1)
38
+ case 'WorkspaceUnset':
39
+ console.error('No compose workspace found from the current directory.')
40
+ console.error('Run `compose init` to scaffold one, or cd into a project that has a .compose/ directory.')
41
+ process.exit(1)
42
+ case 'WorkspaceDiscoveryTooBroad':
43
+ console.error('Workspace discovery exceeded its bound from anchor.')
44
+ console.error('Set COMPOSE_TARGET=/absolute/path/to/workspace to bypass discovery.')
45
+ process.exit(1)
46
+ default:
47
+ throw err
48
+ }
49
+ }
50
+
51
+ // Cache the resolved cwd for the lifetime of this CLI process. resolveCwdWithWorkspace
52
+ // strips --workspace from args on first call (via getWorkspaceFlag splice), so a second
53
+ // call would re-resolve without the hint. Cache prevents this; ensures auto-init paths
54
+ // (runInit re-entry from build/fix/import/new) see the same workspace.
55
+ let _resolvedCwdCache = null
56
+
57
+ function resolveCwdWithWorkspace(args) {
58
+ if (_resolvedCwdCache !== null) return _resolvedCwdCache
59
+ let wsId = getWorkspaceFlag(args)
60
+ // Legacy hooks may pass the unsubstituted token literally — treat as absent.
61
+ if (wsId === '__COMPOSE_WORKSPACE_ID__') {
62
+ console.warn('[compose] hook predates workspace-aware install — re-run `compose hooks install`')
63
+ wsId = null
64
+ }
65
+ try {
66
+ const ws = resolveWorkspace({ workspaceId: wsId })
67
+ _resolvedCwdCache = ws.root
68
+ return ws.root
69
+ } catch (err) {
70
+ dieOnWorkspaceError(err)
71
+ }
72
+ }
73
+
22
74
  // ---------------------------------------------------------------------------
23
75
  // --team flag (COMP-TEAMS)
24
76
  // ---------------------------------------------------------------------------
25
77
  import { parseTeamFlag } from '../lib/team-flag.js';
26
- import { loadDeps, checkExternalSkills, printDepReport } from '../lib/deps.js';
78
+ import { loadDeps, checkExternalSkills, printDepReport, buildDepReport } from '../lib/deps.js';
79
+ import { checkLatestVersion } from '../lib/version-check.js';
27
80
 
28
81
  const [,, cmd, ...args] = process.argv
29
82
 
@@ -31,6 +84,20 @@ const [,, cmd, ...args] = process.argv
31
84
  // Help
32
85
  // ---------------------------------------------------------------------------
33
86
 
87
+ if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
88
+ const pkgPath = join(PACKAGE_ROOT, 'package.json')
89
+ let version = 'unknown'
90
+ try { version = JSON.parse(readFileSync(pkgPath, 'utf-8')).version } catch {}
91
+ console.log(`compose ${version}`)
92
+ const gitDir = join(PACKAGE_ROOT, '.git')
93
+ if (existsSync(gitDir)) {
94
+ const sha = spawnSync('git', ['-C', PACKAGE_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' })
95
+ if (sha.status === 0) console.log(` git: ${sha.stdout.trim()}`)
96
+ }
97
+ console.log(` root: ${PACKAGE_ROOT}`)
98
+ process.exit(0)
99
+ }
100
+
34
101
  if (!cmd || cmd === '--help' || cmd === '-h') {
35
102
  console.log('Usage: compose <command>')
36
103
  console.log('')
@@ -49,7 +116,9 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
49
116
  console.log(' qa-scope Show affected routes from a feature\'s changed files')
50
117
  console.log(' init Initialize Compose in the current project')
51
118
  console.log(' setup Install global skill + register stratum-mcp')
119
+ console.log(' update Pull latest compose, reinstall deps, refresh global skill')
52
120
  console.log(' doctor Check external skill dependencies')
121
+ console.log(' --version Print compose version, git SHA, and install root')
53
122
  process.exit(0)
54
123
  }
55
124
 
@@ -195,10 +264,11 @@ function syncSkills(agents) {
195
264
  // compose doctor — re-run the external dep check
196
265
  // ---------------------------------------------------------------------------
197
266
 
198
- function runDoctor(flags = []) {
267
+ async function runDoctor(flags = []) {
199
268
  const json = flags.includes('--json')
200
269
  const strict = flags.includes('--strict')
201
270
  const verbose = flags.includes('--verbose') || flags.includes('-v')
271
+ const refresh = flags.includes('--refresh-versions')
202
272
 
203
273
  const deps = loadDeps(PACKAGE_ROOT)
204
274
  if (!deps) {
@@ -206,8 +276,39 @@ function runDoctor(flags = []) {
206
276
  process.exit(1)
207
277
  }
208
278
  const result = checkExternalSkills(deps)
209
- const allRequiredPresent = printDepReport(result, { json, verbose })
210
279
 
280
+ // Version drift check — never fails the doctor run.
281
+ const currentVersion = (() => {
282
+ try { return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version }
283
+ catch { return null }
284
+ })()
285
+ const versionInfo = await checkLatestVersion(currentVersion, { force: refresh })
286
+
287
+ if (json) {
288
+ // Single top-level JSON document — the deps report and the version block share one root
289
+ // so consumers like `JSON.parse(stdout)` work. (Previously two concatenated objects.)
290
+ const report = buildDepReport(result)
291
+ console.log(JSON.stringify({ ...report, version: versionInfo }, null, 2))
292
+ const allRequiredPresent = result.missing.every(d => d.optional)
293
+ if (strict && !allRequiredPresent) process.exit(1)
294
+ return
295
+ }
296
+
297
+ // Version section — printed first so it's visible above long dep lists.
298
+ console.log('Version:')
299
+ console.log(` installed: ${currentVersion ?? 'unknown'}`)
300
+ if (versionInfo) {
301
+ console.log(` latest: ${versionInfo.latest} (${versionInfo.source})`)
302
+ if (versionInfo.behind) {
303
+ console.log(` ⚠ behind — run: compose update`)
304
+ } else {
305
+ console.log(` ✓ up to date`)
306
+ }
307
+ } else {
308
+ console.log(` latest: unavailable (registry unreachable or cache missing)`)
309
+ }
310
+
311
+ const allRequiredPresent = printDepReport(result, { json: false, verbose })
211
312
  if (strict && !allRequiredPresent) process.exit(1)
212
313
  }
213
314
 
@@ -218,6 +319,10 @@ function runDoctor(flags = []) {
218
319
  async function runInit(flags) {
219
320
  const noStratum = flags.includes('--no-stratum')
220
321
  const noLifecycle = flags.includes('--no-lifecycle')
322
+ // init creates the workspace — never go through resolveCwdWithWorkspace (which
323
+ // requires one to exist). Strip --workspace if present to avoid leaving it in
324
+ // the shared args array for downstream subcommands.
325
+ getWorkspaceFlag(args)
221
326
  const cwd = process.cwd()
222
327
 
223
328
  // 1. Create .compose/ directory
@@ -434,6 +539,127 @@ function runSetup() {
434
539
  }
435
540
  }
436
541
 
542
+ // ---------------------------------------------------------------------------
543
+ // compose update — pull latest, reinstall deps, refresh global skill
544
+ // ---------------------------------------------------------------------------
545
+
546
+ function detectInstallStyle() {
547
+ // npm install resolves bin to either:
548
+ // - global: /<prefix>/lib/node_modules/@smartmemory/compose/bin/compose.js
549
+ // - local: <project>/node_modules/@smartmemory/compose/bin/compose.js
550
+ // - npx cache: ~/.npm/_npx/<hash>/node_modules/@smartmemory/compose/bin/compose.js
551
+ // git clone places it under any path WITHOUT a node_modules ancestor that
552
+ // contains the package itself, but WITH a .git directory at PACKAGE_ROOT.
553
+ if (PACKAGE_ROOT.includes(`${sep}node_modules${sep}`)) {
554
+ return { style: 'npm', root: PACKAGE_ROOT }
555
+ }
556
+ if (existsSync(join(PACKAGE_ROOT, '.git'))) {
557
+ return { style: 'git', root: PACKAGE_ROOT }
558
+ }
559
+ return { style: 'unknown', root: PACKAGE_ROOT }
560
+ }
561
+
562
+ function getPkgVersion() {
563
+ try {
564
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version
565
+ } catch { return 'unknown' }
566
+ }
567
+
568
+ function getGitSha(repoPath) {
569
+ const r = spawnSync('git', ['-C', repoPath, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' })
570
+ return r.status === 0 ? r.stdout.trim() : null
571
+ }
572
+
573
+ async function runUpdate(flags) {
574
+ const force = flags.includes('--force')
575
+ // update is user-global — workspace is optional. Try to resolve; if no
576
+ // workspace exists, just use process.cwd() (we may still operate on
577
+ // user-global state below).
578
+ const wsId = getWorkspaceFlag(args)
579
+ let cwd
580
+ try {
581
+ cwd = resolveWorkspace({ workspaceId: wsId }).root
582
+ _resolvedCwdCache = cwd
583
+ } catch (err) {
584
+ // Only WorkspaceUnset is benign for `update` (user-global). Any explicit
585
+ // mistake (bad --workspace, collision, ambiguity, too-broad) should still die.
586
+ if (err.code !== 'WorkspaceUnset') dieOnWorkspaceError(err)
587
+ cwd = process.cwd()
588
+ }
589
+ const { style, root } = detectInstallStyle()
590
+
591
+ console.log(`compose update — install style: ${style}`)
592
+ console.log(` root: ${root}`)
593
+ console.log(` current: v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
594
+ console.log('')
595
+
596
+ if (style === 'unknown') {
597
+ console.error('Cannot determine install style. Expected either:')
598
+ console.error(` - npm install: PACKAGE_ROOT inside node_modules`)
599
+ console.error(` - git clone: .git directory at ${root}`)
600
+ console.error('Reinstall with: npm install -g @smartmemory/compose')
601
+ process.exit(1)
602
+ }
603
+
604
+ if (style === 'npm') {
605
+ // Decide global vs local: global if root is under a global prefix.
606
+ const npmPrefix = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf-8' }).stdout.trim()
607
+ const isGlobal = npmPrefix && root.startsWith(npmPrefix)
608
+ const installCmd = isGlobal
609
+ ? ['install', '-g', '@smartmemory/compose@latest']
610
+ : ['install', '@smartmemory/compose@latest']
611
+ console.log(`Running: npm ${installCmd.join(' ')}`)
612
+ const r = spawnSync('npm', installCmd, { stdio: 'inherit' })
613
+ if (r.status !== 0) {
614
+ console.error('npm install failed')
615
+ process.exit(r.status || 1)
616
+ }
617
+ } else {
618
+ // git clone — check clean, fast-forward pull, npm install
619
+ const status = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf-8' })
620
+ if (status.stdout.trim() && !force) {
621
+ console.error(`Working tree at ${root} has uncommitted changes.`)
622
+ console.error('Commit/stash them, or re-run with --force to skip the check.')
623
+ process.exit(1)
624
+ }
625
+ const beforeSha = getGitSha(root)
626
+ console.log(`Running: git fetch && git pull --ff-only`)
627
+ const fetch = spawnSync('git', ['-C', root, 'fetch'], { stdio: 'inherit' })
628
+ if (fetch.status !== 0) { process.exit(fetch.status || 1) }
629
+ const pull = spawnSync('git', ['-C', root, 'pull', '--ff-only'], { stdio: 'inherit' })
630
+ if (pull.status !== 0) {
631
+ console.error('git pull --ff-only failed (likely diverged from remote).')
632
+ console.error(`Reconcile manually in ${root}, then re-run compose update.`)
633
+ process.exit(pull.status || 1)
634
+ }
635
+ const afterSha = getGitSha(root)
636
+ if (beforeSha === afterSha) {
637
+ console.log(`Already up to date at ${afterSha}.`)
638
+ } else {
639
+ console.log(`Updated ${beforeSha} → ${afterSha}`)
640
+ }
641
+
642
+ console.log('Running: npm install')
643
+ const ni = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' })
644
+ if (ni.status !== 0) { process.exit(ni.status || 1) }
645
+ }
646
+
647
+ // Refresh global skill + stratum-mcp registration
648
+ console.log('')
649
+ console.log('Refreshing global skill installation...')
650
+ runSetup()
651
+
652
+ // If invoked from inside a Compose project, refresh project artifacts too
653
+ if (existsSync(join(cwd, '.compose', 'compose.json'))) {
654
+ console.log('')
655
+ console.log(`Refreshing project at ${cwd}...`)
656
+ await runInit([])
657
+ }
658
+
659
+ console.log('')
660
+ console.log(`compose updated to v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
661
+ }
662
+
437
663
  // ---------------------------------------------------------------------------
438
664
  // Command dispatch
439
665
  // ---------------------------------------------------------------------------
@@ -449,7 +675,12 @@ if (cmd === 'setup') {
449
675
  }
450
676
 
451
677
  if (cmd === 'doctor') {
452
- runDoctor(args)
678
+ await runDoctor(args)
679
+ process.exit(0)
680
+ }
681
+
682
+ if (cmd === 'update' || cmd === 'upgrade') {
683
+ await runUpdate(args)
453
684
  process.exit(0)
454
685
  }
455
686
 
@@ -461,7 +692,7 @@ if (cmd === 'install') {
461
692
  }
462
693
 
463
694
  if (cmd === 'import') {
464
- const cwd = process.cwd()
695
+ const cwd = resolveCwdWithWorkspace(args)
465
696
 
466
697
  // Auto-init if needed
467
698
  if (!existsSync(join(cwd, '.compose', 'compose.json'))) {
@@ -502,7 +733,7 @@ if (cmd === 'new') {
502
733
  process.exit(1)
503
734
  }
504
735
 
505
- const cwd = process.cwd()
736
+ const cwd = resolveCwdWithWorkspace(args)
506
737
  const name = basename(cwd)
507
738
 
508
739
  // --from-idea <ID>: pre-populate intent from a promoted ideabox entry (Item 184)
@@ -630,7 +861,7 @@ if (cmd === 'feature') {
630
861
  process.exit(1)
631
862
  }
632
863
 
633
- const cwd = process.cwd()
864
+ const cwd = resolveCwdWithWorkspace(args)
634
865
  const configPath = join(cwd, '.compose', 'compose.json')
635
866
  if (!existsSync(configPath)) {
636
867
  console.error("No .compose/compose.json found. Run 'compose init' first.")
@@ -796,7 +1027,7 @@ if (cmd === 'roadmap') {
796
1027
  // compose roadmap generate — regenerate ROADMAP.md from feature.json files
797
1028
  if (subcmd === 'generate' || subcmd === 'gen') {
798
1029
  const { writeRoadmap } = await import('../lib/roadmap-gen.js')
799
- const cwd = process.cwd()
1030
+ const cwd = resolveCwdWithWorkspace(args)
800
1031
  const path = writeRoadmap(cwd)
801
1032
  console.log(`Generated ${path} from feature.json files`)
802
1033
  process.exit(0)
@@ -805,7 +1036,7 @@ if (cmd === 'roadmap') {
805
1036
  // compose roadmap migrate — extract ROADMAP.md entries into feature.json files
806
1037
  if (subcmd === 'migrate') {
807
1038
  const { migrateRoadmap } = await import('../lib/migrate-roadmap.js')
808
- const cwd = process.cwd()
1039
+ const cwd = resolveCwdWithWorkspace(args)
809
1040
  const dryRun = args.includes('--dry-run')
810
1041
  const overwrite = args.includes('--overwrite')
811
1042
  const result = migrateRoadmap(cwd, { dryRun, overwrite })
@@ -824,7 +1055,7 @@ if (cmd === 'roadmap') {
824
1055
  if (subcmd === 'check') {
825
1056
  const { listFeatures } = await import('../lib/feature-json.js')
826
1057
  const { parseRoadmap } = await import('../lib/roadmap-parser.js')
827
- const cwd = process.cwd()
1058
+ const cwd = resolveCwdWithWorkspace(args)
828
1059
  const roadmapPath = join(cwd, 'ROADMAP.md')
829
1060
  if (!existsSync(roadmapPath)) {
830
1061
  console.error('No ROADMAP.md found. Run: compose roadmap generate')
@@ -945,7 +1176,7 @@ if (cmd === 'roadmap') {
945
1176
  }
946
1177
  }
947
1178
 
948
- const cwd = process.cwd()
1179
+ const cwd = resolveCwdWithWorkspace(args)
949
1180
  const roadmapPath = join(cwd, 'ROADMAP.md')
950
1181
 
951
1182
  if (existsSync(roadmapPath)) {
@@ -1102,7 +1333,7 @@ if (cmd === 'record-completion') {
1102
1333
  if (flags['force'] === true) completionArgs.force = true
1103
1334
  if (flags['idempotency-key']) completionArgs.idempotency_key = flags['idempotency-key']
1104
1335
 
1105
- const cwd = findProjectRoot(process.cwd())
1336
+ const cwd = resolveCwdWithWorkspace(args)
1106
1337
  const { recordCompletion } = await import('../lib/completion-writer.js')
1107
1338
  try {
1108
1339
  const result = await recordCompletion(cwd, completionArgs)
@@ -1170,7 +1401,7 @@ if (cmd === 'hooks') {
1170
1401
  const { join: pjoin, resolve: presolve } = await import('path')
1171
1402
  const { fileURLToPath: futp } = await import('url')
1172
1403
 
1173
- const projectRoot = findProjectRoot(process.cwd())
1404
+ const projectRoot = resolveCwdWithWorkspace(args)
1174
1405
  const gitDir = pjoin(projectRoot, '.git')
1175
1406
  if (!exSync(gitDir)) {
1176
1407
  console.error('Error: not a git repository (no .git directory found)')
@@ -1225,14 +1456,24 @@ if (cmd === 'hooks') {
1225
1456
  return 1
1226
1457
  }
1227
1458
  }
1459
+ // Hook install must pick exactly one workspace. Repo-level hooks bake one ID.
1460
+ const wsHint = hookFlags['workspace']
1461
+ let wsId
1462
+ try {
1463
+ wsId = resolveWorkspace({ cwd: projectRoot, workspaceId: wsHint }).id
1464
+ } catch (err) {
1465
+ dieOnWorkspaceError(err)
1466
+ }
1228
1467
  const substituted = template
1229
1468
  .replace(/__COMPOSE_NODE__/g, composeNode)
1230
1469
  .replace(/__COMPOSE_BIN__/g, composeBin)
1470
+ .replace(/__COMPOSE_WORKSPACE_ID__/g, wsId)
1231
1471
  wfSync(dest, substituted)
1232
1472
  chmodSync(dest, 0o755)
1233
1473
  console.log(`Installed ${type} hook at ${dest}`)
1234
1474
  console.log(` COMPOSE_NODE=${composeNode}`)
1235
1475
  console.log(` COMPOSE_BIN=${composeBin}`)
1476
+ console.log(` COMPOSE_WORKSPACE_ID=${wsId}`)
1236
1477
  return 0
1237
1478
  }
1238
1479
 
@@ -1253,6 +1494,11 @@ if (cmd === 'hooks') {
1253
1494
  return 0
1254
1495
  }
1255
1496
 
1497
+ function extractBakedWorkspaceId(content) {
1498
+ const m = content.match(/^COMPOSE_WORKSPACE_ID="([^"]*)"$/m)
1499
+ return m ? m[1] : null
1500
+ }
1501
+
1256
1502
  function statusOne(type) {
1257
1503
  const { marker, dest } = HOOK_TYPES[type]
1258
1504
  if (!exSync(dest)) {
@@ -1264,12 +1510,29 @@ if (cmd === 'hooks') {
1264
1510
  console.log(`${type}: foreign — hook exists but is not a Compose hook`)
1265
1511
  return
1266
1512
  }
1513
+ const wsHint = hookFlags['workspace']
1514
+ let expectedWsId = null
1515
+ if (wsHint) {
1516
+ try { expectedWsId = resolveWorkspace({ cwd: projectRoot, workspaceId: wsHint }).id } catch { /* ignore for status */ }
1517
+ } else {
1518
+ try { expectedWsId = resolveWorkspace({ cwd: projectRoot }).id } catch { /* ignore for status */ }
1519
+ }
1267
1520
  const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1268
1521
  const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1269
- if (nodeMatch && binMatch) {
1522
+ const hasRawToken = content.includes('__COMPOSE_WORKSPACE_ID__')
1523
+ const wsMatch = hasRawToken ? false
1524
+ : expectedWsId ? content.includes(`COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1525
+ : true
1526
+ if (nodeMatch && binMatch && wsMatch && !hasRawToken) {
1270
1527
  console.log(`${type}: installed (current)`)
1528
+ const baked = extractBakedWorkspaceId(content)
1529
+ if (baked) console.log(` workspace: ${baked}`)
1271
1530
  } else {
1272
- console.log(`${type}: installed (stale paths re-run install)`)
1531
+ const reason = hasRawToken ? 'MISSING_WORKSPACE_ID'
1532
+ : (expectedWsId && !wsMatch) ? 'STALE_WORKSPACE_ID'
1533
+ : 'stale paths'
1534
+ console.log(`${type}: installed (${reason} — re-run install)`)
1535
+ if (expectedWsId && !wsMatch && !hasRawToken) console.log(` expected COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1273
1536
  if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1274
1537
  if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1275
1538
  }
@@ -1360,11 +1623,12 @@ Exit codes:
1360
1623
  }
1361
1624
 
1362
1625
  const { validateFeature, validateProject } = await import('../lib/feature-validator.js')
1626
+ const valCwd = resolveCwdWithWorkspace(args)
1363
1627
  let result
1364
1628
  try {
1365
1629
  result = scope === 'feature'
1366
- ? await validateFeature(process.cwd(), code)
1367
- : await validateProject(process.cwd())
1630
+ ? await validateFeature(valCwd, code)
1631
+ : await validateProject(valCwd)
1368
1632
  } catch (err) {
1369
1633
  if (err.code === 'INVALID_INPUT') {
1370
1634
  console.error(`Error [INVALID_INPUT]: ${err.message}`)
@@ -1406,8 +1670,9 @@ Exit codes:
1406
1670
 
1407
1671
  if (cmd === 'pipeline') {
1408
1672
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
1673
+ const pipeCwd = resolveCwdWithWorkspace(args)
1409
1674
  try {
1410
- runPipelineCli(process.cwd(), args)
1675
+ runPipelineCli(pipeCwd, args)
1411
1676
  } catch (err) {
1412
1677
  console.error(`Error: ${err.message}`)
1413
1678
  process.exit(1)
@@ -1488,7 +1753,7 @@ if (cmd === 'build') {
1488
1753
  }
1489
1754
 
1490
1755
  // Auto-init if needed
1491
- const buildCwd = process.cwd()
1756
+ const buildCwd = resolveCwdWithWorkspace(args)
1492
1757
  if (!existsSync(join(buildCwd, '.compose', 'compose.json')) || !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml'))) {
1493
1758
  console.log('Running compose init...\n')
1494
1759
  await runInit(args.filter(a => a.startsWith('--')))
@@ -1563,7 +1828,7 @@ if (cmd === 'build') {
1563
1828
  process.exit(1)
1564
1829
  }
1565
1830
 
1566
- const fixCwd = process.cwd()
1831
+ const fixCwd = resolveCwdWithWorkspace(args)
1567
1832
  if (!existsSync(join(fixCwd, '.compose', 'compose.json')) || !existsSync(join(fixCwd, 'pipelines', 'bug-fix.stratum.yaml'))) {
1568
1833
  console.log('Running compose init...\n')
1569
1834
  await runInit(args.filter(a => a.startsWith('--')))
@@ -1650,7 +1915,7 @@ if (cmd === 'build') {
1650
1915
  }
1651
1916
  import('../lib/triage.js').then(({ runTriage }) => {
1652
1917
  import('../lib/feature-json.js').then(({ readFeature, writeFeature, updateFeature }) => {
1653
- const trCwd = process.cwd()
1918
+ const trCwd = resolveCwdWithWorkspace(args)
1654
1919
  runTriage(triageCode, { cwd: trCwd }).then((result) => {
1655
1920
  console.log(`\nFeature: ${triageCode}`)
1656
1921
  console.log(`Tier: ${result.tier}`)
@@ -1694,16 +1959,11 @@ if (cmd === 'build') {
1694
1959
  })
1695
1960
  })
1696
1961
  } else if (cmd === 'start') {
1697
- // Resolve target root BEFORE spawning supervisor
1698
- const explicitTarget = process.env.COMPOSE_TARGET
1699
- const targetRoot = explicitTarget
1700
- ? resolve(explicitTarget)
1701
- : findProjectRoot(process.cwd())
1702
-
1703
- if (explicitTarget && !existsSync(resolve(explicitTarget))) {
1704
- console.error(`[compose] COMPOSE_TARGET=${explicitTarget} does not exist.`)
1705
- process.exit(1)
1706
- }
1962
+ // Resolve target root BEFORE spawning supervisor.
1963
+ // Use the unified resolver — it handles COMPOSE_TARGET as either ID or absolute path,
1964
+ // --workspace=<id>, and discovery. No need for the legacy explicitTarget short-circuit.
1965
+ const targetRoot = resolveCwdWithWorkspace(args)
1966
+
1707
1967
  if (!targetRoot || !existsSync(join(targetRoot, '.compose', 'compose.json'))) {
1708
1968
  console.error('[compose] No .compose/ found (searched from cwd upward).')
1709
1969
  console.error("[compose] Run 'compose init' first, or set COMPOSE_TARGET.")
@@ -1725,7 +1985,7 @@ if (cmd === 'build') {
1725
1985
  // compose ideabox — idea management CLI
1726
1986
  // ---------------------------------------------------------------------------
1727
1987
  const ibSubcmd = args[0]
1728
- const ibCwd = process.cwd()
1988
+ const ibCwd = resolveCwdWithWorkspace(args)
1729
1989
 
1730
1990
  // Resolve compose config (paths, etc.)
1731
1991
  function loadComposeConfig(cwd) {
@@ -2024,7 +2284,7 @@ if (cmd === 'build') {
2024
2284
  process.exit(1)
2025
2285
  }
2026
2286
 
2027
- const qsCwd = process.cwd()
2287
+ const qsCwd = resolveCwdWithWorkspace(args)
2028
2288
 
2029
2289
  import('../lib/feature-json.js').then(({ readFeature }) => {
2030
2290
  import('../lib/qa-scoping.js').then(({ mapFilesToRoutes, classifyRoutes }) => {
@@ -6,6 +6,7 @@
6
6
  set -u
7
7
  COMPOSE_NODE="__COMPOSE_NODE__"
8
8
  COMPOSE_BIN="__COMPOSE_BIN__"
9
+ COMPOSE_WORKSPACE_ID="__COMPOSE_WORKSPACE_ID__"
9
10
  LOG="${COMPOSE_HOOK_LOG:-.compose/data/post-commit.log}"
10
11
  mkdir -p "$(dirname "$LOG")"
11
12
 
@@ -52,7 +53,7 @@ echo "$trailers" | while IFS= read -r line; do
52
53
  done
53
54
 
54
55
  if ! echo "$files" | "$COMPOSE_NODE" "$COMPOSE_BIN" record-completion "$code" \
55
- --commit-sha="$sha" --tests-pass="$tp" --notes="$notes" \
56
+ --commit-sha="$sha" --tests-pass="$tp" --notes="$notes" --workspace="$COMPOSE_WORKSPACE_ID" \
56
57
  --files-changed-from-stdin >> "$LOG" 2>&1; then
57
58
  echo "[$(date -Iseconds)] hook: record_completion failed for $code (sha=$sha) — see above" >> "$LOG"
58
59
  fi
@@ -6,10 +6,11 @@
6
6
  set -u
7
7
  COMPOSE_NODE="__COMPOSE_NODE__"
8
8
  COMPOSE_BIN="__COMPOSE_BIN__"
9
+ COMPOSE_WORKSPACE_ID="__COMPOSE_WORKSPACE_ID__"
9
10
  LOG="${COMPOSE_HOOK_LOG:-.compose/data/pre-push.log}"
10
11
  mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
11
12
 
12
- OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error 2>&1)
13
+ OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error --workspace="$COMPOSE_WORKSPACE_ID" 2>&1)
13
14
  EXIT_CODE=$?
14
15
 
15
16
  if [ "$EXIT_CODE" -ne 0 ]; then