@smartmemory/compose 0.1.6-beta → 0.1.8-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 (87) hide show
  1. package/README.md +32 -5
  2. package/bin/compose.js +353 -60
  3. package/bin/git-hooks/pre-push.template +26 -0
  4. package/contracts/feature-json.schema.json +115 -0
  5. package/contracts/roadmap-row.schema.json +23 -0
  6. package/contracts/vision-state.schema.json +64 -0
  7. package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
  8. package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
  9. package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
  10. package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
  11. package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
  12. package/dist/assets/channel-DDkv7DUd.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
  14. package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
  15. package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
  16. package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
  18. package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
  20. package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
  23. package/dist/assets/clone-5MVZ89iV.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
  25. package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
  26. package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
  27. package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
  28. package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
  29. package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
  30. package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
  31. package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
  32. package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
  34. package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
  35. package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
  36. package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
  38. package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
  39. package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
  40. package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
  41. package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
  42. package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
  43. package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
  44. package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
  46. package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
  49. package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
  51. package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
  52. package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
  53. package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
  56. package/dist/index.html +1 -1
  57. package/lib/build.js +193 -19
  58. package/lib/completion-writer.js +8 -6
  59. package/lib/deps.js +17 -6
  60. package/lib/feature-code.js +29 -0
  61. package/lib/feature-events.js +3 -0
  62. package/lib/feature-validator.js +629 -0
  63. package/lib/feature-writer.js +35 -23
  64. package/lib/followup-writer.js +556 -0
  65. package/lib/journal-writer.js +1 -1
  66. package/lib/mcp-enforcement.js +173 -0
  67. package/lib/migrate-roadmap.js +4 -1
  68. package/lib/project-paths.js +36 -0
  69. package/lib/review-lenses.js +23 -8
  70. package/lib/review-normalize.js +42 -3
  71. package/lib/roadmap-drift.js +54 -0
  72. package/lib/roadmap-gen.js +297 -27
  73. package/lib/roadmap-preservers.js +353 -0
  74. package/lib/step-prompt.js +15 -0
  75. package/lib/triage.js +2 -1
  76. package/lib/version-check.js +110 -0
  77. package/package.json +1 -1
  78. package/server/compose-mcp-tools.js +34 -2
  79. package/server/compose-mcp.js +52 -1
  80. package/server/schema-validator.js +50 -9
  81. package/server/vision-routes.js +51 -2
  82. package/templates/ROADMAP.md +6 -0
  83. package/dist/assets/channel-LRG9kHqJ.js +0 -1
  84. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
  85. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
  86. package/dist/assets/clone-dRxgFrBv.js +0 -1
  87. 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,7 +10,7 @@
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'
@@ -23,7 +23,8 @@ const PACKAGE_ROOT = resolve(__dirname, '..')
23
23
  // --team flag (COMP-TEAMS)
24
24
  // ---------------------------------------------------------------------------
25
25
  import { parseTeamFlag } from '../lib/team-flag.js';
26
- import { loadDeps, checkExternalSkills, printDepReport } from '../lib/deps.js';
26
+ import { loadDeps, checkExternalSkills, printDepReport, buildDepReport } from '../lib/deps.js';
27
+ import { checkLatestVersion } from '../lib/version-check.js';
27
28
 
28
29
  const [,, cmd, ...args] = process.argv
29
30
 
@@ -31,6 +32,20 @@ const [,, cmd, ...args] = process.argv
31
32
  // Help
32
33
  // ---------------------------------------------------------------------------
33
34
 
35
+ if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
36
+ const pkgPath = join(PACKAGE_ROOT, 'package.json')
37
+ let version = 'unknown'
38
+ try { version = JSON.parse(readFileSync(pkgPath, 'utf-8')).version } catch {}
39
+ console.log(`compose ${version}`)
40
+ const gitDir = join(PACKAGE_ROOT, '.git')
41
+ if (existsSync(gitDir)) {
42
+ const sha = spawnSync('git', ['-C', PACKAGE_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' })
43
+ if (sha.status === 0) console.log(` git: ${sha.stdout.trim()}`)
44
+ }
45
+ console.log(` root: ${PACKAGE_ROOT}`)
46
+ process.exit(0)
47
+ }
48
+
34
49
  if (!cmd || cmd === '--help' || cmd === '-h') {
35
50
  console.log('Usage: compose <command>')
36
51
  console.log('')
@@ -49,7 +64,9 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
49
64
  console.log(' qa-scope Show affected routes from a feature\'s changed files')
50
65
  console.log(' init Initialize Compose in the current project')
51
66
  console.log(' setup Install global skill + register stratum-mcp')
67
+ console.log(' update Pull latest compose, reinstall deps, refresh global skill')
52
68
  console.log(' doctor Check external skill dependencies')
69
+ console.log(' --version Print compose version, git SHA, and install root')
53
70
  process.exit(0)
54
71
  }
55
72
 
@@ -195,10 +212,11 @@ function syncSkills(agents) {
195
212
  // compose doctor — re-run the external dep check
196
213
  // ---------------------------------------------------------------------------
197
214
 
198
- function runDoctor(flags = []) {
215
+ async function runDoctor(flags = []) {
199
216
  const json = flags.includes('--json')
200
217
  const strict = flags.includes('--strict')
201
218
  const verbose = flags.includes('--verbose') || flags.includes('-v')
219
+ const refresh = flags.includes('--refresh-versions')
202
220
 
203
221
  const deps = loadDeps(PACKAGE_ROOT)
204
222
  if (!deps) {
@@ -206,8 +224,39 @@ function runDoctor(flags = []) {
206
224
  process.exit(1)
207
225
  }
208
226
  const result = checkExternalSkills(deps)
209
- const allRequiredPresent = printDepReport(result, { json, verbose })
210
227
 
228
+ // Version drift check — never fails the doctor run.
229
+ const currentVersion = (() => {
230
+ try { return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version }
231
+ catch { return null }
232
+ })()
233
+ const versionInfo = await checkLatestVersion(currentVersion, { force: refresh })
234
+
235
+ if (json) {
236
+ // Single top-level JSON document — the deps report and the version block share one root
237
+ // so consumers like `JSON.parse(stdout)` work. (Previously two concatenated objects.)
238
+ const report = buildDepReport(result)
239
+ console.log(JSON.stringify({ ...report, version: versionInfo }, null, 2))
240
+ const allRequiredPresent = result.missing.every(d => d.optional)
241
+ if (strict && !allRequiredPresent) process.exit(1)
242
+ return
243
+ }
244
+
245
+ // Version section — printed first so it's visible above long dep lists.
246
+ console.log('Version:')
247
+ console.log(` installed: ${currentVersion ?? 'unknown'}`)
248
+ if (versionInfo) {
249
+ console.log(` latest: ${versionInfo.latest} (${versionInfo.source})`)
250
+ if (versionInfo.behind) {
251
+ console.log(` ⚠ behind — run: compose update`)
252
+ } else {
253
+ console.log(` ✓ up to date`)
254
+ }
255
+ } else {
256
+ console.log(` latest: unavailable (registry unreachable or cache missing)`)
257
+ }
258
+
259
+ const allRequiredPresent = printDepReport(result, { json: false, verbose })
211
260
  if (strict && !allRequiredPresent) process.exit(1)
212
261
  }
213
262
 
@@ -434,6 +483,114 @@ function runSetup() {
434
483
  }
435
484
  }
436
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // compose update — pull latest, reinstall deps, refresh global skill
488
+ // ---------------------------------------------------------------------------
489
+
490
+ function detectInstallStyle() {
491
+ // npm install resolves bin to either:
492
+ // - global: /<prefix>/lib/node_modules/@smartmemory/compose/bin/compose.js
493
+ // - local: <project>/node_modules/@smartmemory/compose/bin/compose.js
494
+ // - npx cache: ~/.npm/_npx/<hash>/node_modules/@smartmemory/compose/bin/compose.js
495
+ // git clone places it under any path WITHOUT a node_modules ancestor that
496
+ // contains the package itself, but WITH a .git directory at PACKAGE_ROOT.
497
+ if (PACKAGE_ROOT.includes(`${sep}node_modules${sep}`)) {
498
+ return { style: 'npm', root: PACKAGE_ROOT }
499
+ }
500
+ if (existsSync(join(PACKAGE_ROOT, '.git'))) {
501
+ return { style: 'git', root: PACKAGE_ROOT }
502
+ }
503
+ return { style: 'unknown', root: PACKAGE_ROOT }
504
+ }
505
+
506
+ function getPkgVersion() {
507
+ try {
508
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version
509
+ } catch { return 'unknown' }
510
+ }
511
+
512
+ function getGitSha(repoPath) {
513
+ const r = spawnSync('git', ['-C', repoPath, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' })
514
+ return r.status === 0 ? r.stdout.trim() : null
515
+ }
516
+
517
+ async function runUpdate(flags) {
518
+ const force = flags.includes('--force')
519
+ const cwd = process.cwd()
520
+ const { style, root } = detectInstallStyle()
521
+
522
+ console.log(`compose update — install style: ${style}`)
523
+ console.log(` root: ${root}`)
524
+ console.log(` current: v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
525
+ console.log('')
526
+
527
+ if (style === 'unknown') {
528
+ console.error('Cannot determine install style. Expected either:')
529
+ console.error(` - npm install: PACKAGE_ROOT inside node_modules`)
530
+ console.error(` - git clone: .git directory at ${root}`)
531
+ console.error('Reinstall with: npm install -g @smartmemory/compose')
532
+ process.exit(1)
533
+ }
534
+
535
+ if (style === 'npm') {
536
+ // Decide global vs local: global if root is under a global prefix.
537
+ const npmPrefix = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf-8' }).stdout.trim()
538
+ const isGlobal = npmPrefix && root.startsWith(npmPrefix)
539
+ const installCmd = isGlobal
540
+ ? ['install', '-g', '@smartmemory/compose@latest']
541
+ : ['install', '@smartmemory/compose@latest']
542
+ console.log(`Running: npm ${installCmd.join(' ')}`)
543
+ const r = spawnSync('npm', installCmd, { stdio: 'inherit' })
544
+ if (r.status !== 0) {
545
+ console.error('npm install failed')
546
+ process.exit(r.status || 1)
547
+ }
548
+ } else {
549
+ // git clone — check clean, fast-forward pull, npm install
550
+ const status = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf-8' })
551
+ if (status.stdout.trim() && !force) {
552
+ console.error(`Working tree at ${root} has uncommitted changes.`)
553
+ console.error('Commit/stash them, or re-run with --force to skip the check.')
554
+ process.exit(1)
555
+ }
556
+ const beforeSha = getGitSha(root)
557
+ console.log(`Running: git fetch && git pull --ff-only`)
558
+ const fetch = spawnSync('git', ['-C', root, 'fetch'], { stdio: 'inherit' })
559
+ if (fetch.status !== 0) { process.exit(fetch.status || 1) }
560
+ const pull = spawnSync('git', ['-C', root, 'pull', '--ff-only'], { stdio: 'inherit' })
561
+ if (pull.status !== 0) {
562
+ console.error('git pull --ff-only failed (likely diverged from remote).')
563
+ console.error(`Reconcile manually in ${root}, then re-run compose update.`)
564
+ process.exit(pull.status || 1)
565
+ }
566
+ const afterSha = getGitSha(root)
567
+ if (beforeSha === afterSha) {
568
+ console.log(`Already up to date at ${afterSha}.`)
569
+ } else {
570
+ console.log(`Updated ${beforeSha} → ${afterSha}`)
571
+ }
572
+
573
+ console.log('Running: npm install')
574
+ const ni = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' })
575
+ if (ni.status !== 0) { process.exit(ni.status || 1) }
576
+ }
577
+
578
+ // Refresh global skill + stratum-mcp registration
579
+ console.log('')
580
+ console.log('Refreshing global skill installation...')
581
+ runSetup()
582
+
583
+ // If invoked from inside a Compose project, refresh project artifacts too
584
+ if (existsSync(join(cwd, '.compose', 'compose.json'))) {
585
+ console.log('')
586
+ console.log(`Refreshing project at ${cwd}...`)
587
+ await runInit([])
588
+ }
589
+
590
+ console.log('')
591
+ console.log(`compose updated to v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
592
+ }
593
+
437
594
  // ---------------------------------------------------------------------------
438
595
  // Command dispatch
439
596
  // ---------------------------------------------------------------------------
@@ -449,7 +606,12 @@ if (cmd === 'setup') {
449
606
  }
450
607
 
451
608
  if (cmd === 'doctor') {
452
- runDoctor(args)
609
+ await runDoctor(args)
610
+ process.exit(0)
611
+ }
612
+
613
+ if (cmd === 'update' || cmd === 'upgrade') {
614
+ await runUpdate(args)
453
615
  process.exit(0)
454
616
  }
455
617
 
@@ -1180,92 +1342,129 @@ if (cmd === 'hooks') {
1180
1342
  const hooksDir = pjoin(gitDir, 'hooks')
1181
1343
  const { mkdirSync: mSync } = await import('fs')
1182
1344
  mSync(hooksDir, { recursive: true })
1183
- const hookPath = pjoin(hooksDir, 'post-commit')
1184
-
1185
- // Marker line used to identify our hooks
1186
- const HOOK_MARKER = '# Compose post-commit hook —'
1187
1345
 
1188
1346
  // Resolve absolute paths for substitution
1189
1347
  const composeBin = presolve(presolve(futp(import.meta.url), '..'), 'compose.js')
1190
1348
  const composeNode = process.execPath
1191
1349
 
1192
- if (sub === 'install') {
1193
- // Read template
1194
- const templatePath = pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template')
1350
+ // Hook-type table. Each entry knows its template, marker, and destination.
1351
+ const HOOK_TYPES = {
1352
+ 'post-commit': {
1353
+ template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template'),
1354
+ marker: '# Compose post-commit hook —',
1355
+ dest: pjoin(hooksDir, 'post-commit'),
1356
+ },
1357
+ 'pre-push': {
1358
+ template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'pre-push.template'),
1359
+ marker: '# Compose pre-push hook —',
1360
+ dest: pjoin(hooksDir, 'pre-push'),
1361
+ },
1362
+ }
1363
+
1364
+ // Determine which hook types this invocation operates on.
1365
+ // Flags: --pre-push, --post-commit, or none (default = post-commit, back-compat).
1366
+ const selectedTypes = []
1367
+ if (hookFlags['pre-push']) selectedTypes.push('pre-push')
1368
+ if (hookFlags['post-commit']) selectedTypes.push('post-commit')
1369
+ if (selectedTypes.length === 0) selectedTypes.push('post-commit') // default = back-compat
1370
+
1371
+ function installOne(type) {
1372
+ const { template: tplPath, marker, dest } = HOOK_TYPES[type]
1195
1373
  let template
1196
1374
  try {
1197
- template = rfSync(templatePath, 'utf-8')
1375
+ template = rfSync(tplPath, 'utf-8')
1198
1376
  } catch (err) {
1199
- console.error(`Error: could not read hook template: ${err.message}`)
1200
- process.exit(1)
1377
+ console.error(`Error: could not read ${type} template: ${err.message}`)
1378
+ return 1
1201
1379
  }
1202
-
1203
- // Check existing hook
1204
- if (exSync(hookPath)) {
1205
- const existing = rfSync(hookPath, 'utf-8')
1206
- const isOurs = existing.includes(HOOK_MARKER)
1380
+ if (exSync(dest)) {
1381
+ const existing = rfSync(dest, 'utf-8')
1382
+ const isOurs = existing.includes(marker)
1207
1383
  if (!isOurs && !hookFlags.force) {
1208
- console.error('Error: a foreign post-commit hook already exists at ' + hookPath)
1384
+ console.error(`Error: a foreign ${type} hook already exists at ${dest}`)
1209
1385
  console.error('')
1210
- console.error('To chain our hook after yours, add this to your existing hook:')
1211
- console.error('')
1212
- console.error(` "${composeNode}" "${composeBin}" record-completion ...`)
1213
- console.error('')
1214
- console.error('Or run `compose hooks install --force` to overwrite.')
1215
- process.exit(1)
1386
+ console.error(`Run \`compose hooks install --${type} --force\` to overwrite.`)
1387
+ return 1
1216
1388
  }
1217
1389
  }
1218
-
1219
- // Substitute placeholders
1220
1390
  const substituted = template
1221
1391
  .replace(/__COMPOSE_NODE__/g, composeNode)
1222
1392
  .replace(/__COMPOSE_BIN__/g, composeBin)
1223
-
1224
- wfSync(hookPath, substituted)
1225
- chmodSync(hookPath, 0o755)
1226
- console.log(`Installed post-commit hook at ${hookPath}`)
1393
+ wfSync(dest, substituted)
1394
+ chmodSync(dest, 0o755)
1395
+ console.log(`Installed ${type} hook at ${dest}`)
1227
1396
  console.log(` COMPOSE_NODE=${composeNode}`)
1228
1397
  console.log(` COMPOSE_BIN=${composeBin}`)
1229
- process.exit(0)
1398
+ return 0
1230
1399
  }
1231
1400
 
1232
- if (sub === 'uninstall') {
1233
- if (!exSync(hookPath)) {
1234
- console.log('No post-commit hook installed.')
1235
- process.exit(0)
1401
+ function uninstallOne(type) {
1402
+ const { marker, dest } = HOOK_TYPES[type]
1403
+ if (!exSync(dest)) {
1404
+ console.log(`No ${type} hook installed.`)
1405
+ return 0
1236
1406
  }
1237
- const content = rfSync(hookPath, 'utf-8')
1238
- if (!content.includes(HOOK_MARKER)) {
1239
- console.warn('Warning: post-commit hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.')
1240
- process.exit(0)
1407
+ const content = rfSync(dest, 'utf-8')
1408
+ if (!content.includes(marker)) {
1409
+ console.warn(`Warning: ${type} hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.`)
1410
+ return 0
1241
1411
  }
1242
- const { rmSync: rmS } = await import('fs')
1243
- rmS(hookPath)
1244
- console.log(`Removed post-commit hook at ${hookPath}`)
1245
- process.exit(0)
1412
+ const { rmSync: rmS } = require('fs')
1413
+ rmS(dest)
1414
+ console.log(`Removed ${type} hook at ${dest}`)
1415
+ return 0
1246
1416
  }
1247
1417
 
1248
- // status (default)
1249
- if (!sub || sub === 'status') {
1250
- if (!exSync(hookPath)) {
1251
- console.log('absent — no post-commit hook installed')
1252
- process.exit(0)
1418
+ function statusOne(type) {
1419
+ const { marker, dest } = HOOK_TYPES[type]
1420
+ if (!exSync(dest)) {
1421
+ console.log(`${type}: absent — no hook installed`)
1422
+ return
1253
1423
  }
1254
- const content = rfSync(hookPath, 'utf-8')
1255
- if (!content.includes(HOOK_MARKER)) {
1256
- console.log('foreign — post-commit hook exists but is not a Compose hook')
1257
- process.exit(0)
1424
+ const content = rfSync(dest, 'utf-8')
1425
+ if (!content.includes(marker)) {
1426
+ console.log(`${type}: foreign — hook exists but is not a Compose hook`)
1427
+ return
1258
1428
  }
1259
- // Check if paths match current
1260
- const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1261
- const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1429
+ const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1430
+ const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1262
1431
  if (nodeMatch && binMatch) {
1263
- console.log('installed (current)')
1432
+ console.log(`${type}: installed (current)`)
1264
1433
  } else {
1265
- console.log('installed (stale paths — re-run install)')
1434
+ console.log(`${type}: installed (stale paths — re-run install)`)
1266
1435
  if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1267
1436
  if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1268
1437
  }
1438
+ }
1439
+
1440
+ if (sub === 'install') {
1441
+ let exitCode = 0
1442
+ for (const t of selectedTypes) exitCode = installOne(t) || exitCode
1443
+ process.exit(exitCode)
1444
+ }
1445
+
1446
+ if (sub === 'uninstall') {
1447
+ const { rmSync: _rmS } = await import('fs') // ensure fs.rmSync is available
1448
+ // uninstallOne calls require('fs') but we're ESM — replace with import-based deletion
1449
+ for (const t of selectedTypes) {
1450
+ const { marker, dest } = HOOK_TYPES[t]
1451
+ if (!exSync(dest)) { console.log(`No ${t} hook installed.`); continue }
1452
+ const content = rfSync(dest, 'utf-8')
1453
+ if (!content.includes(marker)) {
1454
+ console.warn(`Warning: ${t} hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.`)
1455
+ continue
1456
+ }
1457
+ _rmS(dest)
1458
+ console.log(`Removed ${t} hook at ${dest}`)
1459
+ }
1460
+ process.exit(0)
1461
+ }
1462
+
1463
+ // status (default)
1464
+ if (!sub || sub === 'status') {
1465
+ // Status reports on ALL known hook types (selection flags ignored), so users
1466
+ // see the full picture. Selection only affects install/uninstall.
1467
+ for (const t of Object.keys(HOOK_TYPES)) statusOne(t)
1269
1468
  process.exit(0)
1270
1469
  }
1271
1470
 
@@ -1273,6 +1472,100 @@ if (cmd === 'hooks') {
1273
1472
  process.exit(1)
1274
1473
  }
1275
1474
 
1475
+ if (cmd === 'validate') {
1476
+ // compose validate [--scope=feature|project] [--code=CODE] [--block-on=error|warning|info] [--json]
1477
+ let scope = 'project'
1478
+ let code = null
1479
+ let blockOn = 'error'
1480
+ let asJson = false
1481
+ for (let i = 0; i < args.length; i++) {
1482
+ const a = args[i]
1483
+ if (a === '--help' || a === '-h') {
1484
+ console.log(`Usage: compose validate [options]
1485
+
1486
+ Options:
1487
+ --scope=feature|project Scope (default: project)
1488
+ --code=CODE Feature code (required when scope=feature)
1489
+ --block-on=LEVEL Exit non-zero if any finding >= LEVEL (default: error)
1490
+ LEVEL: error | warning | info
1491
+ --json Emit findings as JSON (default: human-readable)
1492
+
1493
+ Exit codes:
1494
+ 0 no findings >= block-on threshold
1495
+ 1 findings >= block-on threshold present
1496
+ 2 usage error`)
1497
+ process.exit(0)
1498
+ }
1499
+ if (a === '--json') { asJson = true; continue }
1500
+ if (a.startsWith('--scope=')) scope = a.slice('--scope='.length)
1501
+ else if (a === '--scope') scope = args[++i]
1502
+ else if (a.startsWith('--code=')) code = a.slice('--code='.length)
1503
+ else if (a === '--code') code = args[++i]
1504
+ else if (a.startsWith('--block-on=')) blockOn = a.slice('--block-on='.length)
1505
+ else if (a === '--block-on') blockOn = args[++i]
1506
+ else if (a.startsWith('--')) {
1507
+ console.error(`Unknown flag: ${a}`)
1508
+ process.exit(2)
1509
+ }
1510
+ }
1511
+ if (!['feature', 'project'].includes(scope)) {
1512
+ console.error(`Invalid --scope=${scope}; expected feature or project`)
1513
+ process.exit(2)
1514
+ }
1515
+ if (scope === 'feature' && !code) {
1516
+ console.error(`--scope=feature requires --code=<CODE>`)
1517
+ process.exit(2)
1518
+ }
1519
+ if (!['error', 'warning', 'info'].includes(blockOn)) {
1520
+ console.error(`Invalid --block-on=${blockOn}; expected error, warning, or info`)
1521
+ process.exit(2)
1522
+ }
1523
+
1524
+ const { validateFeature, validateProject } = await import('../lib/feature-validator.js')
1525
+ let result
1526
+ try {
1527
+ result = scope === 'feature'
1528
+ ? await validateFeature(process.cwd(), code)
1529
+ : await validateProject(process.cwd())
1530
+ } catch (err) {
1531
+ if (err.code === 'INVALID_INPUT') {
1532
+ console.error(`Error [INVALID_INPUT]: ${err.message}`)
1533
+ process.exit(2)
1534
+ }
1535
+ console.error(`Error: ${err.message}`)
1536
+ process.exit(2)
1537
+ }
1538
+
1539
+ // Threshold: findings at or above this severity block the exit code
1540
+ const SEV_RANK = { error: 3, warning: 2, info: 1 }
1541
+ const threshold = SEV_RANK[blockOn]
1542
+ const blocking = result.findings.filter((f) => SEV_RANK[f.severity] >= threshold)
1543
+
1544
+ if (asJson) {
1545
+ console.log(JSON.stringify(result, null, 2))
1546
+ } else {
1547
+ const byKind = {}
1548
+ for (const f of result.findings) {
1549
+ const sev = f.severity.toUpperCase()
1550
+ const tag = `[${sev}] ${f.kind}${f.feature_code ? ' ' + f.feature_code : ''}`
1551
+ if (!byKind[tag]) byKind[tag] = []
1552
+ byKind[tag].push(f.detail)
1553
+ }
1554
+ if (result.findings.length === 0) {
1555
+ console.log(`compose validate: no findings (scope=${scope}${code ? ' code=' + code : ''})`)
1556
+ } else {
1557
+ console.log(`compose validate findings (scope=${scope}${code ? ' code=' + code : ''}):`)
1558
+ for (const tag of Object.keys(byKind).sort()) {
1559
+ console.log(` ${tag}`)
1560
+ for (const detail of byKind[tag]) console.log(` - ${detail}`)
1561
+ }
1562
+ console.log(`\n${result.findings.length} finding(s); ${blocking.length} at or above --block-on=${blockOn}`)
1563
+ }
1564
+ }
1565
+
1566
+ process.exit(blocking.length > 0 ? 1 : 0)
1567
+ }
1568
+
1276
1569
  if (cmd === 'pipeline') {
1277
1570
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
1278
1571
  try {
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Compose pre-push hook — runs `compose validate` and blocks the push on
3
+ # any error-severity drift finding. Installed by `compose hooks install --pre-push`;
4
+ # placeholders below are substituted at install time.
5
+
6
+ set -u
7
+ COMPOSE_NODE="__COMPOSE_NODE__"
8
+ COMPOSE_BIN="__COMPOSE_BIN__"
9
+ LOG="${COMPOSE_HOOK_LOG:-.compose/data/pre-push.log}"
10
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
11
+
12
+ OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error 2>&1)
13
+ EXIT_CODE=$?
14
+
15
+ if [ "$EXIT_CODE" -ne 0 ]; then
16
+ echo "$OUTPUT" | tee -a "$LOG" >&2
17
+ echo "" >&2
18
+ echo "compose validate found error-severity drift. Push aborted." >&2
19
+ echo "Run \`compose validate\` to see findings, then fix and retry." >&2
20
+ echo "Bypass at your own risk: git push --no-verify" >&2
21
+ exit "$EXIT_CODE"
22
+ fi
23
+
24
+ # Below-threshold findings (warnings/info) are still printed for visibility.
25
+ echo "$OUTPUT"
26
+ exit 0