@smartmemory/compose 0.2.49-beta → 0.2.51-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 (148) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +340 -70
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
  8. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
  9. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
  10. package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
  11. package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
  23. package/dist/assets/clone-CBsbNGAa.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
  25. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
  26. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
  27. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
  28. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
  29. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
  30. package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
  32. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
  33. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
  34. package/dist/assets/graph-DZe55uk8.js +331 -0
  35. package/dist/assets/graph-Tq_bs_r0.js +1 -0
  36. package/dist/assets/index-8UhRLbGq.js +123 -0
  37. package/dist/assets/index-CRqB9els.css +1 -0
  38. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
  39. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
  42. package/dist/assets/katex-DkKDou_j.js +257 -0
  43. package/dist/assets/layout-qtUgN9BC.js +1 -0
  44. package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
  47. package/dist/assets/mobile-1gVCT0OK.css +1 -0
  48. package/dist/assets/mobile-Chw8RWyH.js +17 -0
  49. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
  51. package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
  52. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
  56. package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
  58. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
  60. package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build-all.js +2 -1
  63. package/lib/build.js +363 -38
  64. package/lib/checkpoint/checkpoint-writer.js +3 -2
  65. package/lib/codex-preflight.js +151 -0
  66. package/lib/completion-writer.js +59 -15
  67. package/lib/feature-json.js +14 -3
  68. package/lib/feature-validator.js +27 -10
  69. package/lib/feature-write-guard.js +5 -7
  70. package/lib/feature-writer.js +3 -3
  71. package/lib/followup-writer.js +2 -2
  72. package/lib/get-roadmap.js +2 -2
  73. package/lib/gsd.js +2 -1
  74. package/lib/hooks-status.js +135 -0
  75. package/lib/ideabox.js +3 -2
  76. package/lib/install-agent-defs.js +22 -0
  77. package/lib/journal-writer.js +3 -3
  78. package/lib/migrate-anon.js +277 -0
  79. package/lib/migrate-roadmap.js +2 -3
  80. package/lib/paths-core.js +47 -0
  81. package/lib/project-paths.js +46 -37
  82. package/lib/roadmap-gen.js +3 -4
  83. package/lib/roadmap-graph/collect.js +5 -5
  84. package/lib/roadmap-graph/index.js +2 -2
  85. package/lib/roadmap-parser.js +1 -1
  86. package/lib/state-migrations.js +3 -3
  87. package/lib/test-bootstrap.js +165 -2
  88. package/lib/tracker/local-provider.js +2 -2
  89. package/lib/triage.js +7 -5
  90. package/lib/vocabulary-inject.js +103 -0
  91. package/lib/xref-push.js +4 -4
  92. package/lib/xref-sync.js +4 -4
  93. package/package.json +2 -1
  94. package/pipelines/build-quick.stratum.yaml +433 -0
  95. package/pipelines/build.stratum.yaml +60 -3
  96. package/scripts/watch-server.sh +32 -0
  97. package/server/build-routes.js +89 -9
  98. package/server/build-stream-bridge.js +12 -0
  99. package/server/compose-mcp-tools.js +73 -1
  100. package/server/compose-mcp.js +27 -0
  101. package/server/design-routes.js +9 -4
  102. package/server/drift-axes.js +5 -3
  103. package/server/feature-scaffold-routes.js +82 -0
  104. package/server/feature-scan.js +8 -2
  105. package/server/file-watcher.js +13 -7
  106. package/server/health-routes.js +261 -0
  107. package/server/ideabox-routes.js +12 -11
  108. package/server/project-root.js +7 -4
  109. package/server/qa-scope-routes.js +96 -0
  110. package/server/session-routes.js +2 -2
  111. package/server/stratum-sync.js +85 -9
  112. package/server/validate-routes.js +70 -0
  113. package/server/vision-routes.js +46 -2
  114. package/server/vision-server.js +25 -3
  115. package/server/vision-utils.js +23 -4
  116. package/dist/assets/App-DOerhoDp.js +0 -706
  117. package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
  118. package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
  119. package/dist/assets/channel-KsY1x-7s.js +0 -1
  120. package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
  121. package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
  122. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  123. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  124. package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
  125. package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
  126. package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
  127. package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
  128. package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
  129. package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
  130. package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
  131. package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
  132. package/dist/assets/graph-BBXaecIU.js +0 -331
  133. package/dist/assets/graph-CAnANduQ.js +0 -1
  134. package/dist/assets/index-BwLfbbOu.css +0 -1
  135. package/dist/assets/index-DlgxA8dX.js +0 -123
  136. package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
  137. package/dist/assets/katex-C5jXJg4s.js +0 -257
  138. package/dist/assets/layout-DGIYPm2g.js +0 -1
  139. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  140. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  141. package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
  142. package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
  143. package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
  144. package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
  145. package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
  146. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
  147. package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
  148. package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
package/bin/compose.js CHANGED
@@ -17,6 +17,8 @@ import { fileURLToPath } from 'url'
17
17
  import { findProjectRoot } from '../server/find-root.js'
18
18
  import { resolveWorkspace, getWorkspaceFlag } from '../lib/resolve-workspace.js'
19
19
  import { resolvePort } from '../lib/resolve-port.js'
20
+ import { resolveRoadmapPath, resolveFeaturesPath, resolveContextPathFromConfig, resolveFeaturesPathFromConfig, resolveRoadmapPathFromConfig } from '../lib/project-paths.js'
21
+ import { installAgentDefs } from '../lib/install-agent-defs.js'
20
22
 
21
23
  const __dirname = dirname(fileURLToPath(import.meta.url))
22
24
  const PACKAGE_ROOT = resolve(__dirname, '..')
@@ -83,6 +85,7 @@ function resolveCwdWithWorkspace(args) {
83
85
  import { parseTeamFlag } from '../lib/team-flag.js';
84
86
  import { loadDeps, checkExternalSkills, printDepReport, buildDepReport, checkExternalBinaries, printBinaryReport, buildBinaryReport } from '../lib/deps.js';
85
87
  import { checkLatestVersion } from '../lib/version-check.js';
88
+ import { computeHooksStatus, formatHookStatusLines, HOOK_MARKERS } from '../lib/hooks-status.js';
86
89
 
87
90
  const [,, cmd, ...args] = process.argv
88
91
 
@@ -123,10 +126,14 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
123
126
  console.log(' roadmap check Verify feature.json and ROADMAP.md are in sync')
124
127
  console.log(' roadmap xref-sync Pull-reconcile feature.json external links to live state')
125
128
  console.log(' roadmap xref-push Push-write GitHub trackers to match expect= (dry-run; --apply to write)')
129
+ console.log(' migrate-anon Promote historical anonymous ROADMAP rows to typed features (interactive)')
126
130
  console.log(' items List vision items from local state (no server)')
127
131
  console.log(' items show <id> Show detail for a specific vision item')
128
132
  console.log(' triage Analyze a feature and recommend build profile')
129
133
  console.log(' qa-scope Show affected routes from a feature\'s changed files')
134
+ console.log(' context decisions Show the build decision log (--feature <FC>, --format text|json)')
135
+ console.log(' gate list List pending gates (--item <id>, --status pending|all|resolved)')
136
+ console.log(' gate resolve <id> Resolve a gate (--approve|--revise|--kill, --comment <text>)')
130
137
  console.log(' init Initialize Compose in the current project')
131
138
  console.log(' setup Install/sync global skills + register stratum-mcp (alias: sync)')
132
139
  console.log(' sync Re-sync global skills from this install (alias of setup)')
@@ -243,6 +250,17 @@ function syncSkills(agents) {
243
250
  copyFileSync(depsSrc, join(composeSkillDir, '.compose-deps.json'))
244
251
  }
245
252
 
253
+ // COMP-AGENT-VENDOR-1: install the vendored Claude subagents the compose
254
+ // SKILL.md depends on (compose-explorer/compose-architect) into the Claude
255
+ // agents dir — sibling of the skills root. Claude tree only (basename check):
256
+ // gemini is skipped above and codex shares the claude root.
257
+ if (basename(dirname(agentSkillsRoot)) === '.claude') {
258
+ const agentDefsDest = join(dirname(agentSkillsRoot), 'agents')
259
+ for (const d of installAgentDefs(join(PACKAGE_ROOT, '.claude', 'agents'), agentDefsDest)) {
260
+ console.log(` + ${agent.name}/agents/${d}`)
261
+ }
262
+ }
263
+
246
264
  // Remove skills we previously installed that no longer exist in source
247
265
  const removed = previousSkills.filter(name => !sourceSkills.has(name))
248
266
  for (const name of removed) {
@@ -412,6 +430,7 @@ async function runInit(flags, cwdOverride) {
412
430
  },
413
431
  paths: {
414
432
  docs: 'docs',
433
+ roadmap: 'ROADMAP.md',
415
434
  features: 'docs/features',
416
435
  journal: 'docs/journal',
417
436
  context: 'docs/context',
@@ -447,8 +466,9 @@ async function runInit(flags, cwdOverride) {
447
466
  }
448
467
  }
449
468
 
450
- // 5b. Scaffold docs/context/ with ambient context templates
451
- const contextDir = join(cwd, config.paths.context)
469
+ // 5b. Scaffold docs/context/ with ambient context templates.
470
+ // COMP-PATHS-EXTERNAL: resolve from the in-memory config (may be relocated).
471
+ const contextDir = resolveContextPathFromConfig(cwd, config)
452
472
  mkdirSync(contextDir, { recursive: true })
453
473
  const contextTemplates = {
454
474
  'tech-stack.md': '# Tech Stack\n\nDescribe your technology stack here.\n',
@@ -464,8 +484,8 @@ async function runInit(flags, cwdOverride) {
464
484
  }
465
485
 
466
486
  // 5c. Scaffold docs/product/ideabox.md if absent
467
- const ideaboxRel = config.paths.ideabox || 'docs/product/ideabox.md'
468
- const ideaboxDest = join(cwd, ideaboxRel)
487
+ const { resolveIdeaboxPath: _resolveIdeaboxPathInit } = await import('../lib/project-paths.js')
488
+ const ideaboxDest = _resolveIdeaboxPathInit(cwd)
469
489
  if (!existsSync(ideaboxDest)) {
470
490
  mkdirSync(dirname(ideaboxDest), { recursive: true })
471
491
  const { IDEABOX_TEMPLATE } = await import('../lib/ideabox.js')
@@ -473,6 +493,16 @@ async function runInit(flags, cwdOverride) {
473
493
  console.log(`Created ${ideaboxDest}`)
474
494
  }
475
495
 
496
+ // 5d. STRAT-VOCAB-3: scaffold contracts/vocabulary.yaml (starter is comments-only,
497
+ // so vocabulary enforcement stays inert until the user fills it in).
498
+ const { VOCABULARY_FILE, VOCABULARY_TEMPLATE } = await import('../lib/vocabulary-inject.js')
499
+ const vocabDest = join(cwd, VOCABULARY_FILE)
500
+ if (!existsSync(vocabDest)) {
501
+ mkdirSync(dirname(vocabDest), { recursive: true })
502
+ writeFileSync(vocabDest, VOCABULARY_TEMPLATE)
503
+ console.log(`Created ${vocabDest}`)
504
+ }
505
+
476
506
  // 6. Register compose-mcp in .mcp.json
477
507
  const mcpPath = join(cwd, '.mcp.json')
478
508
  let mcpConfig = {}
@@ -513,8 +543,10 @@ async function runInit(flags, cwdOverride) {
513
543
  }
514
544
  }
515
545
 
516
- // 7. Scaffold ROADMAP.md from template if absent
517
- const roadmapDest = join(cwd, 'ROADMAP.md')
546
+ // 7. Scaffold ROADMAP.md from template if absent. COMP-PATHS-EXTERNAL:
547
+ // honor a configured (possibly relocated) paths.roadmap instead of always
548
+ // seeding <cwd>/ROADMAP.md. Byte-identical for the default.
549
+ const roadmapDest = resolveRoadmapPathFromConfig(cwd, config)
518
550
  if (!existsSync(roadmapDest)) {
519
551
  const roadmapSrc = join(PACKAGE_ROOT, 'templates', 'ROADMAP.md')
520
552
  if (existsSync(roadmapSrc)) {
@@ -533,7 +565,7 @@ async function runInit(flags, cwdOverride) {
533
565
  // 8. Copy default pipeline specs if absent
534
566
  const pipelinesDir = join(cwd, 'pipelines')
535
567
  mkdirSync(pipelinesDir, { recursive: true })
536
- for (const specName of ['build.stratum.yaml', 'new.stratum.yaml']) {
568
+ for (const specName of ['build.stratum.yaml', 'build-quick.stratum.yaml', 'new.stratum.yaml']) {
537
569
  const dest = join(pipelinesDir, specName)
538
570
  if (!existsSync(dest)) {
539
571
  const src = join(PACKAGE_ROOT, 'pipelines', specName)
@@ -756,6 +788,24 @@ if (cmd === 'migrate-state') {
756
788
  process.exit(0)
757
789
  }
758
790
 
791
+ if (cmd === 'migrate-anon') {
792
+ // COMP-MCP-MIGRATION-2-1-1-1 — interactively promote historical anonymous
793
+ // ROADMAP rows to typed features. The non-TTY guard lives here (where
794
+ // process.stdin does): piped stdin / --non-interactive / --dry-run → list-only,
795
+ // never hang waiting for a prompt.
796
+ const { root: cwd } = resolveCwdWithWorkspace(args)
797
+ const nonInteractive = args.includes('--non-interactive') || args.includes('--dry-run') || !process.stdin.isTTY
798
+ const dryRun = args.includes('--dry-run')
799
+ const { runMigrateAnon } = await import('../lib/migrate-anon.js')
800
+ try {
801
+ await runMigrateAnon(cwd, { nonInteractive, dryRun })
802
+ } catch (err) {
803
+ console.error(`\nError: ${err.message}`)
804
+ process.exit(1)
805
+ }
806
+ process.exit(0)
807
+ }
808
+
759
809
  if (cmd === 'install') {
760
810
  // Backwards-compat: run both init + setup
761
811
  await runInit(args)
@@ -942,10 +992,12 @@ if (cmd === 'feature') {
942
992
 
943
993
  let config = {}
944
994
  try { config = JSON.parse(readFileSync(configPath, 'utf-8')) } catch {}
945
- const featuresDir = config.paths?.features || 'docs/features'
995
+ // COMP-PATHS-EXTERNAL: absolute features dir (may be relocated outside cwd).
996
+ // Never join(cwd, …) below — that would re-root an absolute/../-escaping override.
997
+ const featuresDir = resolveFeaturesPath(cwd)
946
998
 
947
999
  // 1. Create feature folder + seed design doc
948
- const featureDir = join(cwd, featuresDir, featureCode)
1000
+ const featureDir = join(featuresDir, featureCode)
949
1001
  const designPath = join(featureDir, 'design.md')
950
1002
 
951
1003
  if (existsSync(designPath)) {
@@ -985,7 +1037,7 @@ if (cmd === 'feature') {
985
1037
  // Count existing features for complexity estimate
986
1038
  const existingFeatureCount = (() => {
987
1039
  try {
988
- const fdir = join(cwd, featuresDir)
1040
+ const fdir = featuresDir
989
1041
  if (!existsSync(fdir)) return 0
990
1042
  return readdirSync(fdir, { withFileTypes: true })
991
1043
  .filter(e => e.isDirectory() && existsSync(join(fdir, e.name, 'feature.json')))
@@ -1042,7 +1094,7 @@ _This is a seed design doc created by \`compose feature\`. The \`compose build\`
1042
1094
  console.log(`Created ${designPath}`)
1043
1095
 
1044
1096
  // 2. Add entry to ROADMAP.md
1045
- const roadmapPath = join(cwd, 'ROADMAP.md')
1097
+ const roadmapPath = resolveRoadmapPath(cwd)
1046
1098
  if (existsSync(roadmapPath)) {
1047
1099
  let roadmap = readFileSync(roadmapPath, 'utf-8')
1048
1100
 
@@ -1157,7 +1209,7 @@ if (cmd === 'roadmap') {
1157
1209
  const { checkRoundtrip, describeLossyDiff } = await import('../lib/roadmap-roundtrip.js')
1158
1210
  const { loadExternalPrefixes } = await import('../lib/project-paths.js')
1159
1211
  const { root: cwd } = resolveCwdWithWorkspace(args)
1160
- const roadmapPath = join(cwd, 'ROADMAP.md')
1212
+ const roadmapPath = resolveRoadmapPath(cwd)
1161
1213
  if (!existsSync(roadmapPath)) {
1162
1214
  console.error('No ROADMAP.md found. Run: compose roadmap generate')
1163
1215
  process.exit(1)
@@ -1350,7 +1402,7 @@ if (cmd === 'roadmap') {
1350
1402
  }
1351
1403
 
1352
1404
  const { root: cwd } = resolveCwdWithWorkspace(args)
1353
- const roadmapPath = join(cwd, 'ROADMAP.md')
1405
+ const roadmapPath = resolveRoadmapPath(cwd)
1354
1406
 
1355
1407
  if (existsSync(roadmapPath)) {
1356
1408
  // Show cwd roadmap
@@ -1593,12 +1645,12 @@ if (cmd === 'hooks') {
1593
1645
  const HOOK_TYPES = {
1594
1646
  'post-commit': {
1595
1647
  template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template'),
1596
- marker: '# Compose post-commit hook —',
1648
+ marker: HOOK_MARKERS['post-commit'],
1597
1649
  dest: pjoin(hooksDir, 'post-commit'),
1598
1650
  },
1599
1651
  'pre-push': {
1600
1652
  template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'pre-push.template'),
1601
- marker: '# Compose pre-push hook —',
1653
+ marker: HOOK_MARKERS['pre-push'],
1602
1654
  dest: pjoin(hooksDir, 'pre-push'),
1603
1655
  },
1604
1656
  }
@@ -1667,48 +1719,14 @@ if (cmd === 'hooks') {
1667
1719
  return 0
1668
1720
  }
1669
1721
 
1670
- function extractBakedWorkspaceId(content) {
1671
- const m = content.match(/^COMPOSE_WORKSPACE_ID="([^"]*)"$/m)
1672
- return m ? m[1] : null
1673
- }
1674
-
1675
- function statusOne(type) {
1676
- const { marker, dest } = HOOK_TYPES[type]
1677
- if (!exSync(dest)) {
1678
- console.log(`${type}: absent — no hook installed`)
1679
- return
1680
- }
1681
- const content = rfSync(dest, 'utf-8')
1682
- if (!content.includes(marker)) {
1683
- console.log(`${type}: foreign — hook exists but is not a Compose hook`)
1684
- return
1685
- }
1722
+ // Resolve the expected workspace id once (identical across hook types).
1723
+ // Hook-status detection itself lives in lib/hooks-status.js (shared with the
1724
+ // /api/environment-health endpoint); this just supplies the comparison id.
1725
+ function resolveExpectedWsId() {
1686
1726
  const wsHint = hookFlags['workspace']
1687
- let expectedWsId = null
1688
- if (wsHint) {
1689
- try { expectedWsId = resolveWorkspace({ cwd: projectRoot, workspaceId: wsHint }).id } catch { /* ignore for status */ }
1690
- } else {
1691
- try { expectedWsId = resolveWorkspace({ cwd: projectRoot }).id } catch { /* ignore for status */ }
1692
- }
1693
- const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1694
- const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1695
- const hasRawToken = content.includes('__COMPOSE_WORKSPACE_ID__')
1696
- const wsMatch = hasRawToken ? false
1697
- : expectedWsId ? content.includes(`COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1698
- : true
1699
- if (nodeMatch && binMatch && wsMatch && !hasRawToken) {
1700
- console.log(`${type}: installed (current)`)
1701
- const baked = extractBakedWorkspaceId(content)
1702
- if (baked) console.log(` workspace: ${baked}`)
1703
- } else {
1704
- const reason = hasRawToken ? 'MISSING_WORKSPACE_ID'
1705
- : (expectedWsId && !wsMatch) ? 'STALE_WORKSPACE_ID'
1706
- : 'stale paths'
1707
- console.log(`${type}: installed (${reason} — re-run install)`)
1708
- if (expectedWsId && !wsMatch && !hasRawToken) console.log(` expected COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1709
- if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1710
- if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1711
- }
1727
+ try {
1728
+ return resolveWorkspace(wsHint ? { cwd: projectRoot, workspaceId: wsHint } : { cwd: projectRoot }).id
1729
+ } catch { return null /* ignore for status */ }
1712
1730
  }
1713
1731
 
1714
1732
  if (sub === 'install') {
@@ -1738,7 +1756,17 @@ if (cmd === 'hooks') {
1738
1756
  if (!sub || sub === 'status') {
1739
1757
  // Status reports on ALL known hook types (selection flags ignored), so users
1740
1758
  // see the full picture. Selection only affects install/uninstall.
1741
- for (const t of Object.keys(HOOK_TYPES)) statusOne(t)
1759
+ const computed = computeHooksStatus({
1760
+ projectRoot,
1761
+ expectedWsId: resolveExpectedWsId(),
1762
+ composeNode,
1763
+ composeBin,
1764
+ })
1765
+ for (const t of Object.keys(HOOK_TYPES)) {
1766
+ for (const line of formatHookStatusLines(t, computed[t], { composeNode, composeBin })) {
1767
+ console.log(line)
1768
+ }
1769
+ }
1742
1770
  process.exit(0)
1743
1771
  }
1744
1772
 
@@ -1971,6 +1999,14 @@ if (cmd === 'build') {
1971
1999
  const all = filteredArgs2.includes('--all')
1972
2000
  const dryRun = filteredArgs2.includes('--dry-run')
1973
2001
  const skipTriage = filteredArgs2.includes('--skip-triage')
2002
+ // COMP-BUILD-QUICK: --quick selects the trimmed build-quick pipeline
2003
+ // (design → implement → ship). Symmetric to fix mode's Quick path; the real
2004
+ // mirror is the existing --template mechanism (fix dispatches template:'bug-fix'),
2005
+ // so --quick is sugar for template:'build-quick'. Single-feature only.
2006
+ const quick = filteredArgs2.includes('--quick')
2007
+ // COMP-CODEX-IMPL: --codex flips the implementer to Codex (Claude reviews). v1 is
2008
+ // full-build, single-feature only — mutually exclusive with --quick and batch.
2009
+ const codex = filteredArgs2.includes('--codex')
1974
2010
 
1975
2011
  // Multiple codes: compose build FEAT-1 FEAT-2 FEAT-3
1976
2012
  const isMulti = featureCodes.length > 1
@@ -1983,12 +2019,38 @@ if (cmd === 'build') {
1983
2019
  process.exit(1)
1984
2020
  }
1985
2021
 
2022
+ // COMP-BUILD-QUICK: --quick is a single-feature shortcut for a specific
2023
+ // template, so it conflicts with an explicit --template and with batch builds.
2024
+ if (quick && templateName) {
2025
+ console.error('Error: --quick and --template are mutually exclusive (--quick selects the build-quick template)')
2026
+ process.exit(1)
2027
+ }
2028
+ if (quick && isBatch) {
2029
+ console.error('Error: --quick cannot be combined with --all/prefix/multi (single feature only)')
2030
+ process.exit(1)
2031
+ }
2032
+
2033
+ // COMP-CODEX-IMPL: v1 scopes --codex to the full build, single feature.
2034
+ if (codex && quick) {
2035
+ console.error('Error: --codex and --quick are mutually exclusive in v1 (build-quick Codex parity is a follow-up)')
2036
+ process.exit(1)
2037
+ }
2038
+ if (codex && templateName) {
2039
+ console.error('Error: --codex and --template are mutually exclusive (--codex parameterizes the build template)')
2040
+ process.exit(1)
2041
+ }
2042
+ if (codex && isBatch) {
2043
+ console.error('Error: --codex cannot be combined with --all/prefix/multi (single feature only in v1)')
2044
+ process.exit(1)
2045
+ }
2046
+
1986
2047
  if (!featureCode && !abort && !all) {
1987
2048
  console.error('Usage: compose build <feature-code> [feature-code...]')
1988
2049
  console.error(' compose build STRAT-COMP (prefix — builds all matching)')
1989
2050
  console.error(' compose build --all (builds entire roadmap)')
1990
2051
  console.error('')
1991
2052
  console.error('Options:')
2053
+ console.error(' --quick Trimmed lifecycle (design → implement → ship) for small additive work')
1992
2054
  console.error(' --abort Abort the active build')
1993
2055
  console.error(' --all Build all PLANNED features in dependency order')
1994
2056
  console.error(' --dry-run Print build order without executing')
@@ -1996,11 +2058,23 @@ if (cmd === 'build') {
1996
2058
  process.exit(1)
1997
2059
  }
1998
2060
 
1999
- // Auto-init if needed
2061
+ // Auto-init if needed. COMP-BUILD-QUICK: a workspace initialized before the
2062
+ // build-quick pipeline existed won't have pipelines/build-quick.stratum.yaml,
2063
+ // and there's no bundled preset fallback — so when --quick is requested and
2064
+ // that file is absent, treat it as init-needed (runInit re-seeds it) rather
2065
+ // than letting runBuild fail later with "Lifecycle spec not found".
2000
2066
  const { root: buildCwd } = resolveCwdWithWorkspace(args)
2001
- if (!existsSync(join(buildCwd, '.compose', 'compose.json')) || !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml'))) {
2067
+ const needsInit =
2068
+ !existsSync(join(buildCwd, '.compose', 'compose.json')) ||
2069
+ !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml')) ||
2070
+ (quick && !existsSync(join(buildCwd, 'pipelines', 'build-quick.stratum.yaml')))
2071
+ if (needsInit) {
2002
2072
  console.log('Running compose init...\n')
2003
- await runInit(args.filter(a => a.startsWith('--')))
2073
+ // Thread the RESOLVED workspace root into runInit. Without it runInit seeds
2074
+ // process.cwd(), which differs from buildCwd when invoked from a subdirectory
2075
+ // — the guard checks buildCwd but the seed would land in the wrong dir, so
2076
+ // runBuild would still fail with the same missing-spec error (Codex review).
2077
+ await runInit(args.filter(a => a.startsWith('--')), buildCwd)
2004
2078
  console.log('')
2005
2079
  }
2006
2080
 
@@ -2031,6 +2105,8 @@ if (cmd === 'build') {
2031
2105
  if (agentWorkDir) singleOpts.workingDirectory = agentWorkDir
2032
2106
  if (skipTriage) singleOpts.skipTriage = true
2033
2107
  if (templateName) singleOpts.template = templateName
2108
+ if (quick) singleOpts.template = 'build-quick' // COMP-BUILD-QUICK
2109
+ if (codex) singleOpts.codex = true // COMP-CODEX-IMPL
2034
2110
  runBuild(featureCode, singleOpts).then(() => {
2035
2111
  process.exit(0)
2036
2112
  }).catch((err) => {
@@ -2426,7 +2502,11 @@ if (cmd === 'build') {
2426
2502
  } = await import('../lib/ideabox.js')
2427
2503
 
2428
2504
  const ibRelPath = getIdeaboxRelPath(ibCwd)
2429
- const ibFullPath = join(ibCwd, ibRelPath)
2505
+ // ibFullPath is used for direct file ops (existsSync/mkdirSync/writeFileSync/
2506
+ // display); resolve it absolute-safe. ibRelPath stays relative because the
2507
+ // lib readIdeabox/writeIdeabox readers re-join it under ibCwd.
2508
+ const { resolveIdeaboxPath: _resolveIdeaboxPathCli } = await import('../lib/project-paths.js')
2509
+ const ibFullPath = _resolveIdeaboxPathCli(ibCwd)
2430
2510
 
2431
2511
  if (!ibSubcmd || ibSubcmd === '--help' || ibSubcmd === '-h') {
2432
2512
  console.log('Usage: compose ideabox <subcommand>')
@@ -2544,9 +2624,10 @@ if (cmd === 'build') {
2544
2624
  resolvedCode = `IDEA-${idea.num}-${slug}`.toUpperCase()
2545
2625
  }
2546
2626
 
2547
- // Create feature folder if missing — respect paths.features from compose.json
2548
- const featuresRel = ibConfig.paths?.features || 'docs/features'
2549
- const featuresDir = join(ibCwd, featuresRel, resolvedCode)
2627
+ // Create feature folder if missing — respect paths.features (may be
2628
+ // relocated outside ibCwd COMP-PATHS-EXTERNAL; resolve absolute, no re-root)
2629
+ const featuresBase = resolveFeaturesPathFromConfig(ibCwd, ibConfig)
2630
+ const featuresDir = join(featuresBase, resolvedCode)
2550
2631
  if (!existsSync(featuresDir)) {
2551
2632
  // COMP-MCP-VALIDATE-1: route through the validated writer (schema-guarded)
2552
2633
  // instead of a raw writeFileSync.
@@ -2557,8 +2638,8 @@ if (cmd === 'build') {
2557
2638
  status: 'PLANNED',
2558
2639
  promotedFrom: ideaId,
2559
2640
  createdAt: new Date().toISOString(),
2560
- }, featuresRel)
2561
- console.log(`Created feature folder: ${featuresRel}/${resolvedCode}/`)
2641
+ }, featuresBase)
2642
+ console.log(`Created feature folder: ${join(featuresBase, resolvedCode)}/`)
2562
2643
  }
2563
2644
 
2564
2645
  _promoteIdea(parsed, ideaId, resolvedCode)
@@ -2750,11 +2831,76 @@ if (cmd === 'build') {
2750
2831
  process.exit(1)
2751
2832
  })
2752
2833
 
2753
- } else if (cmd === 'gates') {
2834
+ } else if (cmd === 'context') {
2835
+ // ---------------------------------------------------------------------------
2836
+ // compose context decisions [--feature <FC>] [--format text|json]
2837
+ // COMP-CTX-3: read-side of the decision log. During `compose build` each gate
2838
+ // outcome is auto-appended to docs/context/decisions.md (appendDecisionEntry);
2839
+ // this surfaces it. Read-only; no server.
2840
+ // ---------------------------------------------------------------------------
2841
+ const contextSubcmd = args[0]
2842
+ if (contextSubcmd === 'decisions') {
2843
+ const { existsSync, readFileSync } = await import('node:fs')
2844
+ const { join } = await import('node:path')
2845
+ const { resolveContextPath } = await import('../lib/project-paths.js')
2846
+ const flagVal = (flag) => {
2847
+ const i = args.indexOf(flag)
2848
+ return i !== -1 && args[i + 1] ? args[i + 1] : null
2849
+ }
2850
+ const featureFilter = flagVal('--feature')
2851
+ const format = flagVal('--format') || 'text'
2852
+
2853
+ const decisionsPath = join(resolveContextPath(process.cwd()), 'decisions.md')
2854
+ if (!existsSync(decisionsPath)) {
2855
+ console.error(`No decision log at ${decisionsPath} — run \`compose init\` to scaffold docs/context/, then decisions accrue during builds.`)
2856
+ process.exit(1)
2857
+ }
2858
+ const raw = readFileSync(decisionsPath, 'utf-8')
2859
+
2860
+ // Each entry is a "## [YYYY-MM-DD] FEATURE — step" heading + body
2861
+ // (see appendDecisionEntry in lib/build.js). Slice raw on the headings.
2862
+ const heads = []
2863
+ const re = /^## \[(\d{4}-\d{2}-\d{2})\] (\S+) — (.+)$/gm
2864
+ let m
2865
+ while ((m = re.exec(raw)) !== null) {
2866
+ heads.push({ idx: m.index, date: m[1], feature: m[2], step: m[3] })
2867
+ }
2868
+ const blocks = heads.map((h, i) => ({
2869
+ ...h,
2870
+ text: raw.slice(h.idx, i + 1 < heads.length ? heads[i + 1].idx : raw.length).trim(),
2871
+ }))
2872
+ const filtered = featureFilter ? blocks.filter(b => b.feature === featureFilter) : blocks
2873
+
2874
+ if (format === 'json') {
2875
+ const out = filtered.map(b => ({
2876
+ date: b.date,
2877
+ feature: b.feature,
2878
+ step: b.step,
2879
+ outcome: (b.text.match(/\*\*Outcome:\*\* (.+)/) || [])[1] || null,
2880
+ rationale: (b.text.match(/\*\*Rationale:\*\* ([\s\S]+?)(?:\n## |\s*$)/) || [])[1]?.trim() || null,
2881
+ }))
2882
+ console.log(JSON.stringify(out, null, 2))
2883
+ } else if (filtered.length === 0) {
2884
+ console.log(featureFilter ? `No decisions recorded for ${featureFilter}.` : 'No decisions recorded yet.')
2885
+ } else {
2886
+ const label = featureFilter ? ` for ${featureFilter}` : ''
2887
+ console.log(`Decision log${label} — ${filtered.length} entr${filtered.length === 1 ? 'y' : 'ies'}:\n`)
2888
+ for (const b of filtered) console.log(b.text + '\n')
2889
+ }
2890
+ } else {
2891
+ console.error('usage: compose context decisions [--feature <FC>] [--format text|json]')
2892
+ process.exit(1)
2893
+ }
2894
+
2895
+ } else if (cmd === 'gates' || cmd === 'gate') {
2754
2896
  // ---------------------------------------------------------------------------
2897
+ // compose gate list [--item <id>] [--status pending|all|resolved] [--format text|json]
2898
+ // compose gate resolve <gateId> (--approve|--revise|--kill) [--comment <text>] [--reason <text>]
2899
+ // COMP-PARITY-1: CLI gate resolution — wraps GET/POST /api/vision/gates[/:id/resolve]
2755
2900
  // compose gates report [--since 24h|7d|1h|<ISO>] [--feature <FC>]
2756
2901
  // [--format text|json] [--rubber-stamp-ms <N>]
2757
- // COMP-OBS-GATELOG: audit gate log report (Decision 5)
2902
+ // COMP-OBS-GATELOG: audit gate log report (Decision 5)
2903
+ // (`gate` and `gates` are aliases.)
2758
2904
  // ---------------------------------------------------------------------------
2759
2905
  const gatesSubcmd = args[0]
2760
2906
 
@@ -2828,8 +2974,132 @@ if (cmd === 'build') {
2828
2974
  process.exit(0)
2829
2975
  }
2830
2976
 
2831
- console.error(`Unknown gates subcommand: ${gatesSubcmd}`)
2832
- console.error('Usage: compose gates report [--since 24h] [--feature FC] [--format text|json] [--rubber-stamp-ms N]')
2977
+ if (gatesSubcmd === 'list' || gatesSubcmd === 'resolve') {
2978
+ // --- COMP-PARITY-1: CLI gate list/resolve over the :4001 vision endpoints ---
2979
+ const flagVal = (flag) => { const i = args.indexOf(flag); return i !== -1 && args[i + 1] ? args[i + 1] : null }
2980
+ const hasFlag = (flag) => args.includes(flag)
2981
+ const baseUrl = process.env.COMPOSE_URL || `http://127.0.0.1:${resolvePort()}`
2982
+
2983
+ // Tolerant workspace resolution (mirror `compose loops`): attach the header
2984
+ // when resolvable, otherwise send none (server soft-falls back to boot ws).
2985
+ let _wsId = null
2986
+ try {
2987
+ const wsId = getWorkspaceFlag(args)
2988
+ const ws = resolveWorkspace({ workspaceId: wsId === '__COMPOSE_WORKSPACE_ID__' ? null : wsId })
2989
+ _wsId = ws.id || null
2990
+ } catch { /* no header */ }
2991
+
2992
+ const reqJson = async (method, urlStr, body) => {
2993
+ const { default: http } = await import(urlStr.startsWith('https') ? 'https' : 'http')
2994
+ return new Promise((resolveP, reject) => {
2995
+ const u = new URL(urlStr)
2996
+ const data = body ? JSON.stringify(body) : null
2997
+ const headers = {}
2998
+ if (_wsId) headers['X-Compose-Workspace-Id'] = _wsId
2999
+ if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(data) }
3000
+ // Sensitive-token header — only consumed when capabilities.guardAuth is on.
3001
+ if (method === 'POST' && process.env.COMPOSE_API_TOKEN) headers['x-compose-token'] = process.env.COMPOSE_API_TOKEN
3002
+ const r = http.request({
3003
+ hostname: u.hostname,
3004
+ port: u.port || (urlStr.startsWith('https') ? 443 : 80),
3005
+ path: u.pathname + u.search,
3006
+ method,
3007
+ headers,
3008
+ }, (res) => {
3009
+ let buf = ''
3010
+ res.on('data', c => { buf += c })
3011
+ res.on('end', () => { try { resolveP({ status: res.statusCode, body: JSON.parse(buf) }) } catch { resolveP({ status: res.statusCode, body: buf }) } })
3012
+ })
3013
+ r.on('error', reject)
3014
+ if (data) r.end(data); else r.end()
3015
+ })
3016
+ }
3017
+
3018
+ const dieUnreachable = (err) => {
3019
+ if (err && (err.code === 'ECONNREFUSED' || /ECONNREFUSED/.test(err.message || ''))) {
3020
+ console.error(`compose server not reachable on :${resolvePort()} — start it with \`npm run dev:server\` (or \`npm run dev:watch\`)`)
3021
+ } else {
3022
+ console.error(`gate ${gatesSubcmd}: ${err.message}`)
3023
+ }
3024
+ process.exit(1)
3025
+ }
3026
+
3027
+ if (gatesSubcmd === 'list') {
3028
+ const itemId = flagVal('--item')
3029
+ const status = flagVal('--status') || 'pending'
3030
+ const format = flagVal('--format') || 'text'
3031
+ if (!['pending', 'all', 'resolved'].includes(status)) {
3032
+ console.error(`gate list: --status must be one of pending|all|resolved (got '${status}')`)
3033
+ process.exit(1)
3034
+ }
3035
+ if (!['text', 'json'].includes(format)) {
3036
+ console.error(`gate list: --format must be one of text|json (got '${format}')`)
3037
+ process.exit(1)
3038
+ }
3039
+ const params = new URLSearchParams()
3040
+ if (status !== 'pending') params.set('status', status)
3041
+ if (itemId) params.set('itemId', itemId)
3042
+ const qs = params.toString() ? `?${params.toString()}` : ''
3043
+ let resp
3044
+ try { resp = await reqJson('GET', `${baseUrl}/api/vision/gates${qs}`) } catch (e) { dieUnreachable(e) }
3045
+ if (resp.status !== 200) {
3046
+ console.error(`gate list failed (HTTP ${resp.status}): ${resp.body?.error || JSON.stringify(resp.body)}`)
3047
+ process.exit(1)
3048
+ }
3049
+ // The server only honors itemId on the pending path; filter client-side so
3050
+ // --item works uniformly across --status all|resolved too (Codex review).
3051
+ let gates = resp.body.gates || []
3052
+ if (itemId) gates = gates.filter(g => g.itemId === itemId)
3053
+ if (format === 'json') { console.log(JSON.stringify(gates, null, 2)); process.exit(0) }
3054
+ if (gates.length === 0) { console.log(`No ${status} gates.`); process.exit(0) }
3055
+ const ageOf = (iso) => {
3056
+ const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
3057
+ if (m < 60) return `${m}m`
3058
+ const h = Math.floor(m / 60)
3059
+ return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`
3060
+ }
3061
+ // Record-per-gate, not a fixed-width table: real gate ids are long
3062
+ // `<uuid>:<step>:<round>` strings, so columns smush together. Keep the full
3063
+ // copy-pasteable id on its own line (it's the arg to `gate resolve`).
3064
+ console.log(`${gates.length} ${status} gate${gates.length === 1 ? '' : 's'}:\n`)
3065
+ for (const g of gates) {
3066
+ console.log(`${g.id} [${g.status || 'pending'}]`)
3067
+ console.log(` item ${g.itemId || '·'} step ${g.stepId || '·'} ${g.fromPhase || '·'}→${g.toPhase || '·'} ${ageOf(g.createdAt)}\n`)
3068
+ }
3069
+ process.exit(0)
3070
+ }
3071
+
3072
+ if (gatesSubcmd === 'resolve') {
3073
+ const gateId = args[1] && !args[1].startsWith('-') ? args[1] : null
3074
+ if (!gateId) {
3075
+ console.error('usage: compose gate resolve <gateId> (--approve|--revise|--kill) [--comment <text>] [--reason <text>]')
3076
+ process.exit(1)
3077
+ }
3078
+ const outcomes = ['approve', 'revise', 'kill'].filter(o => hasFlag(`--${o}`))
3079
+ if (outcomes.length !== 1) {
3080
+ console.error('gate resolve: exactly one of --approve | --revise | --kill is required')
3081
+ process.exit(1)
3082
+ }
3083
+ const outcome = outcomes[0]
3084
+ const comment = flagVal('--comment') ?? flagVal('--reason') ?? undefined
3085
+ let resp
3086
+ try {
3087
+ resp = await reqJson('POST', `${baseUrl}/api/vision/gates/${encodeURIComponent(gateId)}/resolve`, { outcome, comment, resolvedBy: 'cli' })
3088
+ } catch (e) { dieUnreachable(e) }
3089
+ if (resp.status < 200 || resp.status >= 300) {
3090
+ console.error(`gate resolve failed (HTTP ${resp.status}): ${resp.body?.error || JSON.stringify(resp.body)}`)
3091
+ process.exit(1)
3092
+ }
3093
+ console.log(`Gate ${gateId} resolved: ${outcome}${comment ? ` — ${comment}` : ''}`)
3094
+ process.exit(0)
3095
+ }
3096
+ }
3097
+
3098
+ console.error(`Unknown gate subcommand: ${gatesSubcmd}`)
3099
+ console.error('Usage:')
3100
+ console.error(' compose gate list [--item <id>] [--status pending|all|resolved] [--format text|json]')
3101
+ console.error(' compose gate resolve <gateId> (--approve|--revise|--kill) [--comment <text>] [--reason <text>]')
3102
+ console.error(' compose gates report [--since 24h] [--feature FC] [--format text|json] [--rubber-stamp-ms N]')
2833
3103
  process.exit(1)
2834
3104
 
2835
3105
  } else if (cmd === 'loops') {