@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
package/bin/compose.js CHANGED
@@ -91,7 +91,12 @@ function resolveCwdWithWorkspace(args) {
91
91
  // ---------------------------------------------------------------------------
92
92
  import { parseTeamFlag } from '../lib/team-flag.js';
93
93
  import { loadDeps, checkExternalSkills, printDepReport, buildDepReport, checkExternalBinaries, printBinaryReport, buildBinaryReport } from '../lib/deps.js';
94
- import { checkLatestVersion } from '../lib/version-check.js';
94
+ import {
95
+ checkLatestVersion,
96
+ checkPackageVersion,
97
+ resolveStratumVersion,
98
+ formatDriftNudge,
99
+ } from '../lib/version-check.js';
95
100
  import { computeHooksStatus, formatHookStatusLines, HOOK_MARKERS } from '../lib/hooks-status.js';
96
101
 
97
102
  const [,, cmd, ...args] = process.argv
@@ -115,38 +120,10 @@ if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
115
120
  }
116
121
 
117
122
  if (!cmd || cmd === '--help' || cmd === '-h') {
118
- console.log('Usage: compose <command>')
119
- console.log('')
120
- console.log('Commands:')
121
- console.log(' start Start the compose app (UI + API) for this project')
122
- console.log(' remote Manage remote access: pair, list, revoke, status')
123
- console.log(' new Kickoff a product (research, brainstorm, roadmap, scaffold)')
124
- console.log(' import Scan existing project and generate structured analysis')
125
- console.log(' feature Add a single feature (folder, design seed, ROADMAP entry)')
126
- console.log(' build Run a feature through the headless lifecycle')
127
- console.log(' fix Run a bug through the headless bug-fix lifecycle')
128
- console.log(' gsd Per-task fresh-context dispatch from existing blueprint+Boundary Map')
129
- console.log(' pipeline View and edit the build pipeline')
130
- console.log(' roadmap Show roadmap status and next buildable features')
131
- console.log(' roadmap generate Regenerate ROADMAP.md from feature.json files')
132
- console.log(' roadmap migrate Extract ROADMAP.md entries into feature.json files')
133
- console.log(' roadmap check Verify feature.json and ROADMAP.md are in sync')
134
- console.log(' roadmap xref-sync Pull-reconcile feature.json external links to live state')
135
- console.log(' roadmap xref-push Push-write GitHub trackers to match expect= (dry-run; --apply to write)')
136
- console.log(' migrate-anon Promote historical anonymous ROADMAP rows to typed features (interactive)')
137
- console.log(' items List vision items from local state (no server)')
138
- console.log(' items show <id> Show detail for a specific vision item')
139
- console.log(' triage Analyze a feature and recommend build profile')
140
- console.log(' qa-scope Show affected routes from a feature\'s changed files')
141
- console.log(' context decisions Show the build decision log (--feature <FC>, --format text|json)')
142
- console.log(' gate list List pending gates (--item <id>, --status pending|all|resolved)')
143
- console.log(' gate resolve <id> Resolve a gate (--approve|--revise|--kill, --comment <text>)')
144
- console.log(' init Initialize Compose in the current project')
145
- console.log(' setup Install/sync global Compose skills (alias: sync)')
146
- console.log(' sync Re-sync global skills from this install (alias of setup)')
147
- console.log(' update Pull latest compose, reinstall deps, refresh global skill')
148
- console.log(' doctor Check external skill dependencies')
149
- console.log(' --version Print compose version, git SHA, and install root')
123
+ // Rendered from the single command table (lib/cli-commands.js) so --help can
124
+ // never silently omit a shipped command (COMP-AUDIT-13).
125
+ const { renderHelp } = await import('../lib/cli-commands.js')
126
+ console.log(renderHelp())
150
127
  process.exit(0)
151
128
  }
152
129
 
@@ -566,10 +543,24 @@ async function runInit(flags, cwdOverride) {
566
543
  }
567
544
  }
568
545
 
569
- // 8. Copy default pipeline specs if absent
546
+ // 8. Copy default pipeline specs if absent.
547
+ // COMP-PIPELINE-QUARANTINE follow-up: `bug-fix` belongs in this set. `compose
548
+ // fix` checks for pipelines/bug-fix.stratum.yaml, runs init when it is absent,
549
+ // and then fails with "Lifecycle spec not found" because init never copied it —
550
+ // so the command was unavailable in every fresh workspace independently of the
551
+ // spec's dialect. Each spec's `<name>.profiles.json` sidecar travels WITH it:
552
+ // loadPipelineProfiles fails open, so a spec copied without its sidecar runs on
553
+ // bare defaults and silently drops the tool restrictions it declares.
570
554
  const pipelinesDir = join(cwd, 'pipelines')
571
555
  mkdirSync(pipelinesDir, { recursive: true })
572
- for (const specName of ['build.stratum.yaml', 'build-quick.stratum.yaml', 'new.stratum.yaml', 'plan.stratum.yaml']) {
556
+ const DEFAULT_PIPELINES = [
557
+ 'build.stratum.yaml',
558
+ 'build-quick.stratum.yaml',
559
+ 'bug-fix.stratum.yaml',
560
+ 'new.stratum.yaml',
561
+ 'plan.stratum.yaml',
562
+ ]
563
+ for (const specName of DEFAULT_PIPELINES) {
573
564
  const dest = join(pipelinesDir, specName)
574
565
  if (!existsSync(dest)) {
575
566
  const src = join(PACKAGE_ROOT, 'pipelines', specName)
@@ -578,6 +569,12 @@ async function runInit(flags, cwdOverride) {
578
569
  console.log(`Copied default pipeline to ${dest}`)
579
570
  }
580
571
  }
572
+ const sidecarName = specName.replace(/\.stratum\.ya?ml$/, '.profiles.json')
573
+ const sidecarDest = join(pipelinesDir, sidecarName)
574
+ if (!existsSync(sidecarDest)) {
575
+ const sidecarSrc = join(PACKAGE_ROOT, 'pipelines', sidecarName)
576
+ if (existsSync(sidecarSrc)) copyFileSync(sidecarSrc, sidecarDest)
577
+ }
581
578
  }
582
579
 
583
580
  // 9. Sync all compose-owned skills to detected agents
@@ -738,17 +735,90 @@ async function runUpdate(flags) {
738
735
  } else {
739
736
  console.log('Stratum MCP wiring already current')
740
737
  }
738
+
739
+ // Healing .mcp.json only fixes what the NEXT client launch reads. An MCP
740
+ // server the agent already spawned keeps serving the old Stratum build for
741
+ // the rest of the session, so an update can look applied while every
742
+ // stratum_* tool still runs pre-update code. Say so explicitly — this has
743
+ // bitten us before (stale tool contracts surviving a "successful" upgrade).
744
+ if (!wiring.skipped) {
745
+ console.log('')
746
+ console.log('⚠ Restart your MCP client (e.g. /mcp reconnect, or restart Claude Code)')
747
+ console.log(' to pick up the new Stratum server — a running one keeps serving the old build.')
748
+ }
741
749
  }
742
750
 
743
751
  console.log('')
744
752
  console.log(`compose updated to v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
745
753
  }
746
754
 
755
+ /** Hard ceiling on how long the update nudge may delay a command. */
756
+ const NUDGE_BUDGET_MS = 4000
757
+
758
+ /** Stratum is enabled unless this cwd's workspace manifest explicitly opts out.
759
+ * This deliberately does not use resolveWorkspace(): the nudge runs before
760
+ * argument parsing, and workspace resolution may print errors and exit. */
761
+ function isStratumCapabilityEnabled() {
762
+ try {
763
+ const manifest = join(process.cwd(), '.compose', 'compose.json')
764
+ const config = JSON.parse(readFileSync(manifest, 'utf-8'))
765
+ return config?.capabilities?.stratum !== false
766
+ } catch {
767
+ // Absent, unreadable, or malformed means "enabled" for compatibility.
768
+ return true
769
+ }
770
+ }
771
+
772
+ /** Print a one-line update nudge at session entry points. NOTIFY ONLY —
773
+ * this must never mutate an installation. Compose has ~109 lazy `await import()`
774
+ * sites, so replacing files under a running process would have it load new
775
+ * modules into an old module graph; safe self-update needs a versioned install
776
+ * (COMP-UPDATE-VERSIONED-INSTALL), not a check here.
777
+ *
778
+ * Swallows everything: a courtesy line may never fail the command it precedes. */
779
+ async function emitDriftNudge() {
780
+ try {
781
+ // Only an interactive terminal pays for a registry lookup. Everything else
782
+ // — a spawned CLI, CI, a script — reads cache and stays silent on a miss.
783
+ //
784
+ // This is not an optimization, it is a correctness fix. The nudge sits on
785
+ // the startup path of init/build/plan, and the test suite alone spawns
786
+ // `compose init` dozens of times in parallel with a cold HOME. Every one of
787
+ // those fired a live fetch, and under that concurrency one failed to settle:
788
+ // node reported "Detected unsettled top-level await" at this call and exited
789
+ // non-zero, breaking `compose init` itself. A courtesy line must never be
790
+ // able to do that, and non-interactive callers were never its audience.
791
+ const cacheOnly = !process.stdout.isTTY
792
+ const stratumCurrent = isStratumCapabilityEnabled()
793
+ ? resolveStratumVersion(PACKAGE_ROOT)
794
+ : null
795
+ const work = Promise.all([
796
+ checkPackageVersion('@smartmemory/compose', getPkgVersion(), { cacheOnly }),
797
+ stratumCurrent
798
+ ? checkPackageVersion('@smartmemory/stratum', stratumCurrent, { cacheOnly })
799
+ : Promise.resolve(null),
800
+ ])
801
+ // Belt and braces: whatever happens below, this await settles. The timer is
802
+ // ref'd deliberately — an unref'd one would let the loop drain and reproduce
803
+ // the very unsettled-await exit this guards against.
804
+ const [composeInfo, stratumInfo] = await Promise.race([
805
+ work,
806
+ new Promise((resolve) => setTimeout(() => resolve([null, null]), NUDGE_BUDGET_MS)),
807
+ ])
808
+ for (const line of formatDriftNudge({ compose: composeInfo, stratum: stratumInfo })) {
809
+ console.log(line)
810
+ }
811
+ } catch {
812
+ // never break the command this is attached to
813
+ }
814
+ }
815
+
747
816
  // ---------------------------------------------------------------------------
748
817
  // Command dispatch
749
818
  // ---------------------------------------------------------------------------
750
819
 
751
820
  if (cmd === 'init') {
821
+ await emitDriftNudge()
752
822
  await runInit(args)
753
823
  process.exit(0)
754
824
  }
@@ -1148,38 +1218,122 @@ _This is a seed design doc created by \`compose feature\`. The \`compose build\`
1148
1218
  process.exit(0)
1149
1219
  }
1150
1220
 
1221
+ if (cmd === 'guard' && ['descriptors', 'sign', 'enrol'].includes(args[0])) {
1222
+ // New signing verbs stay above the canon-guard dispatcher below. They resolve
1223
+ // the target workspace; canon-guard intentionally continues to dogfood this
1224
+ // package root for its own hook state.
1225
+ const sub = args[0]
1226
+ const { root: cwd } = resolveCwdWithWorkspace(args)
1227
+ const { runGuardDescriptors, runGuardSign, runGuardEnrol } = await import('../lib/guard-cli.js')
1228
+ const principalAt = args.indexOf('--principal')
1229
+ const principal = principalAt === -1 ? undefined : (args[principalAt + 1] ?? '')
1230
+ const result = sub === 'descriptors' ? await runGuardDescriptors(cwd)
1231
+ : sub === 'sign' ? await runGuardSign(cwd)
1232
+ : await runGuardEnrol(principal === undefined ? {} : { principal }, { emit: (line) => console.log(line) })
1233
+ for (const line of result.lines) console.log(line)
1234
+ process.exit(result.status === 'refused' ? 1 : 0)
1235
+ }
1236
+
1151
1237
  if (cmd === 'roadmap') {
1152
1238
  const subcmd = args[0]
1153
1239
 
1154
1240
  // compose roadmap generate — regenerate ROADMAP.md from feature.json files,
1155
1241
  // converging to a fixed point before finishing.
1156
1242
  if (subcmd === 'generate' || subcmd === 'gen') {
1157
- const { writeRoadmap } = await import('../lib/roadmap-gen.js')
1158
1243
  const { checkRoundtrip } = await import('../lib/roadmap-roundtrip.js')
1244
+ const { generateRoadmapFromBase } = await import('../lib/roadmap-gen.js')
1159
1245
  const { listFeatures } = await import('../lib/feature-json.js')
1160
- const { loadExternalPrefixes } = await import('../lib/project-paths.js')
1246
+ const { loadExternalPrefixes, resolveRoadmapPath, loadFeaturesDir } = await import('../lib/project-paths.js')
1161
1247
  const { isNarrativeOwned } = await import('../lib/roadmap-config.js')
1248
+ const { readPreservedSections } = await import('../lib/roadmap-preservers.js')
1249
+ const { computeResidue, protectResidue, RoadmapProseLossError } = await import('../lib/roadmap-residue.js')
1162
1250
  const { root: cwd } = resolveCwdWithWorkspace(args)
1163
1251
  // Narrative-owned workspaces (#39): ROADMAP.md is hand-authored, not a render
1164
- // of feature.json. writeRoadmap already no-ops, but the canonicalization pass
1165
- // below would still overwrite the file (or crash if it's absent) — skip the
1166
- // whole generate path here so the hand-authored file is never touched.
1252
+ // of feature.json never regenerate or overwrite it.
1167
1253
  if (isNarrativeOwned(cwd)) {
1168
1254
  console.log('narrative-owned workspace (roadmap.narrative=true) — ROADMAP.md is hand-authored; generate skipped.')
1169
1255
  process.exit(0)
1170
1256
  }
1171
- const path = writeRoadmap(cwd)
1257
+ const path = resolveRoadmapPath(cwd)
1258
+ const base = existsSync(path) ? readFileSync(path, 'utf-8') : ''
1259
+ const acceptLoss = args.includes('--accept-loss')
1260
+ const protect = args.includes('--protect')
1172
1261
  const externalPrefixes = loadExternalPrefixes(cwd)
1173
- // checkRoundtrip's now:'0000-00-00' is only used to detect/canonicalize
1174
- // structural non-convergence — once the file has headings, readPreamble
1175
- // preserves the existing preamble date verbatim, so no sentinel date leaks.
1176
- const rt = checkRoundtrip(readFileSync(path, 'utf-8'), listFeatures(cwd), { now: '0000-00-00', externalPrefixes })
1177
- if (!rt.fixedPoint) {
1178
- writeFileSync(path, rt.canonical)
1179
- console.log(`Generated ${path} (canonicalized over ${rt.passes} passes)`)
1180
- } else {
1181
- console.log(`Generated ${path} from feature.json files`)
1262
+ const now = new Date().toISOString().slice(0, 10)
1263
+
1264
+ // Guard 1 (COMP-CONFLICT-MERGE): structural preserved-section marker defects
1265
+ // silently drop the content they were meant to protect — an unbalanced open
1266
+ // (typo'd close) or a duplicate id (Map collision). Fail loud, write nothing.
1267
+ try {
1268
+ readPreservedSections(base, { strict: true })
1269
+ } catch (err) {
1270
+ if (err?.code === 'ROADMAP_UNBALANCED_MARKER') {
1271
+ console.error(`Refusing to write ${path}: unbalanced preserved-section marker(s):`)
1272
+ for (const m of err.markers) console.error(` line ${m.lineNo}: open "${m.id}" has no matching close`)
1273
+ console.error(err.remediation)
1274
+ process.exit(1)
1275
+ }
1276
+ if (err?.code === 'ROADMAP_DUPLICATE_MARKER') {
1277
+ console.error(`Refusing to write ${path}: duplicate preserved-section id(s):`)
1278
+ for (const m of err.markers) console.error(` line ${m.lineNo}: id "${m.id}" is used more than once`)
1279
+ console.error(err.remediation)
1280
+ process.exit(1)
1281
+ }
1282
+ throw err
1283
+ }
1284
+
1285
+ // Honor a configured non-default features directory (paths.features), else a
1286
+ // project using one regenerates from the wrong/empty feature set.
1287
+ const featuresDir = loadFeaturesDir(cwd)
1288
+ const features = listFeatures(cwd, featuresDir)
1289
+
1290
+ // Emit the phase-override drift warning + roadmap_drift audit event, which the
1291
+ // old writeRoadmap-first path produced and the pure roundtrip path suppresses.
1292
+ generateRoadmapFromBase(base, features, { cwd, featuresDir, now })
1293
+
1294
+ // Guard 2: compute the FINAL canonical bytes (fixed point over all passes)
1295
+ // BEFORE writing, then diff the base against exactly what will be written.
1296
+ // The duplicate-heading loss only surfaces on a later pass, so checking the
1297
+ // first candidate would pass cleanly and then write the lossy result.
1298
+ const featureCodes = new Set(features.map(f => f.code))
1299
+ const rt = checkRoundtrip(base, features, { now, externalPrefixes, featuresDir })
1300
+ let finalText = rt.canonical
1301
+ let residue = computeResidue(base, finalText, { featureCodes })
1302
+
1303
+ if (residue.length > 0 && protect) {
1304
+ const protectedBase = protectResidue(base, residue)
1305
+ const rt2 = checkRoundtrip(protectedBase, features, { now, externalPrefixes, featuresDir })
1306
+ const stillLost = computeResidue(protectedBase, rt2.canonical, { featureCodes })
1307
+ if (stillLost.length === 0) {
1308
+ writeFileSync(path, rt2.canonical)
1309
+ console.log(`Generated ${path} — wrapped ${residue.length} line(s) in preserved-section markers`)
1310
+ process.exit(0)
1311
+ }
1312
+ // --protect could not fully account for the loss; fall through to the error
1313
+ // with the residual set so the operator sees what remains.
1314
+ finalText = rt2.canonical
1315
+ residue = stillLost
1316
+ }
1317
+
1318
+ if (residue.length > 0 && !acceptLoss) {
1319
+ const err = new RoadmapProseLossError(residue)
1320
+ console.error(`Refusing to write ${path}: ${err.message}:`)
1321
+ for (const r of err.lines) {
1322
+ console.error(` line ${r.lineNo} (under "${r.nearestHeading ?? '(preamble)'}"): ${r.text}`)
1323
+ }
1324
+ console.error('')
1325
+ console.error(err.remediation)
1326
+ process.exit(1)
1327
+ }
1328
+
1329
+ if (residue.length > 0 && acceptLoss) {
1330
+ console.log(`--accept-loss: dropping ${residue.length} hand-authored line(s):`)
1331
+ for (const r of residue) console.log(` line ${r.lineNo}: ${r.text}`)
1182
1332
  }
1333
+
1334
+ writeFileSync(path, finalText)
1335
+ if (!rt.fixedPoint) console.log(`Generated ${path} (canonicalized over ${rt.passes} passes)`)
1336
+ else console.log(`Generated ${path} from feature.json files`)
1183
1337
  process.exit(0)
1184
1338
  }
1185
1339
 
@@ -1579,7 +1733,7 @@ if (cmd === 'record-completion') {
1579
1733
  //
1580
1734
  // Flags:
1581
1735
  // --commit-sha=<sha> required; full 40-char hex SHA (Decision 9)
1582
- // --tests-pass=<bool> default true
1736
+ // --tests-pass=<bool> REQUIRED (true|false) — no default (COMP-COMPLETION-GATE)
1583
1737
  // --notes=<string> optional
1584
1738
  // --files-changed-from-stdin read newline-separated paths from stdin
1585
1739
  // --no-status set_status: false (don't flip status to COMPLETE)
@@ -1624,7 +1778,8 @@ if (cmd === 'record-completion') {
1624
1778
  console.log('')
1625
1779
  console.log('Options:')
1626
1780
  console.log(' --commit-sha=<sha> Full 40-char hex SHA (required)')
1627
- console.log(' --tests-pass=<bool> Whether tests passed (default: true)')
1781
+ console.log(' --tests-pass=<bool> Whether tests passed — REQUIRED, no default')
1782
+ console.log(' (configure guard.testCommand to attest by running instead)')
1628
1783
  console.log(' --notes=<string> Optional provenance notes')
1629
1784
  console.log(' --files-changed-from-stdin Read newline-separated repo-relative paths from stdin')
1630
1785
  console.log(' --no-status Do not flip feature status to COMPLETE')
@@ -1647,8 +1802,13 @@ if (cmd === 'record-completion') {
1647
1802
  process.exit(1)
1648
1803
  }
1649
1804
 
1650
- // Parse tests-pass: default true
1651
- let testsPass = true
1805
+ // Parse tests-pass. COMP-COMPLETION-GATE (BREAKING): there is NO default.
1806
+ // This used to default to `true`, which meant every completion recorded from
1807
+ // the CLI attested a test run the operator may never have made — the record
1808
+ // said "tests passed" on the strength of nothing. An attestation nobody made
1809
+ // is worse than an absent one, so the flag is now required unless a
1810
+ // `guard.testCommand` is configured to attest by running.
1811
+ let testsPass
1652
1812
  if (flags['tests-pass'] !== undefined) {
1653
1813
  const tp = flags['tests-pass']
1654
1814
  if (tp === 'false' || tp === false) testsPass = false
@@ -1685,7 +1845,48 @@ if (cmd === 'record-completion') {
1685
1845
  const { root: cwd } = resolveCwdWithWorkspace(args)
1686
1846
  const { recordCompletion } = await import('../lib/completion-writer.js')
1687
1847
  try {
1688
- const result = await recordCompletion(cwd, completionArgs)
1848
+ let result
1849
+ if (completionArgs.set_status === false) {
1850
+ // --no-status records evidence without completing, so it does not drive
1851
+ // the guard. tests_pass still has no default; the writer requires a boolean.
1852
+ if (testsPass === undefined) {
1853
+ console.error(
1854
+ 'Error: --tests-pass is required (true|false). It no longer defaults to true — ' +
1855
+ 'a completion record must not attest a test run that was never made.'
1856
+ )
1857
+ process.exit(1)
1858
+ }
1859
+ result = await recordCompletion(cwd, completionArgs)
1860
+ } else {
1861
+ const { completionGate } = await import('../lib/completion-gate.js')
1862
+ const gated = await completionGate({
1863
+ featureCode: featureCode,
1864
+ commitSha: commitSha,
1865
+ testsPass: testsPass,
1866
+ filesChanged: filesChanged,
1867
+ notes: flags['notes'] || undefined,
1868
+ force: flags['force'] === true || undefined,
1869
+ idempotencyKey: flags['idempotency-key'] || undefined,
1870
+ workspaceRoot: cwd,
1871
+ })
1872
+ if (!gated.ok) {
1873
+ console.error(`Refused at ${gated.refusedAt}:\n - ${gated.reasons.join('\n - ')}`)
1874
+ if (gated.refusedAt === 'evidence' && testsPass === undefined) {
1875
+ console.error(
1876
+ '\nHint: --tests-pass no longer defaults to true. Pass --tests-pass true|false, ' +
1877
+ 'or configure guard.testCommand in .compose/compose.json so the test run itself attests.'
1878
+ )
1879
+ }
1880
+ process.exit(1)
1881
+ }
1882
+ result = gated.result
1883
+ if (gated.partial) {
1884
+ console.warn(
1885
+ `Completed, but a projection failed — ` +
1886
+ gated.failures.map(f => `${f.step}: ${f.message} (recover: ${f.recover})`).join('; ')
1887
+ )
1888
+ }
1889
+ }
1689
1890
  console.log(JSON.stringify({
1690
1891
  completion_id: result.completion_id,
1691
1892
  idempotent: result.idempotent,
@@ -1898,6 +2099,276 @@ if (cmd === 'hooks') {
1898
2099
  process.exit(1)
1899
2100
  }
1900
2101
 
2102
+ if (cmd === 'guard') {
2103
+ // compose guard {install,uninstall,status,verify} — COMP-CANON-GUARD S4/S5.
2104
+ // Manages the write-time PreToolUse hook registration in .claude/settings.json.
2105
+ // Scoped to compose's own checkout (dogfooding): the hook script uses a
2106
+ // relative import into lib/, so it only works where .claude/ and lib/ are
2107
+ // siblings — this repo. Cross-project install is design Open Question 2.
2108
+ const sub = args[0] || 'status'
2109
+ const { readFileSync: rfSync, writeFileSync: wfSync, existsSync: exSync, mkdirSync: mkSync } = await import('fs')
2110
+ const { join: pjoin } = await import('path')
2111
+ const { installGuardHook, uninstallGuardHook, guardHookStatus, HOOK_COMMAND, HOOK_MATCHER } =
2112
+ await import('../lib/canon-guard.js')
2113
+
2114
+ const projectRoot = PACKAGE_ROOT
2115
+ const claudeDir = pjoin(projectRoot, '.claude')
2116
+ const settingsPath = pjoin(claudeDir, 'settings.json')
2117
+ const hookScript = pjoin(claudeDir, 'hooks', 'canon-guard.mjs')
2118
+
2119
+ function readSettings() {
2120
+ if (!exSync(settingsPath)) return {}
2121
+ try {
2122
+ return JSON.parse(rfSync(settingsPath, 'utf-8'))
2123
+ } catch (e) {
2124
+ console.error(`Error: ${settingsPath} is not valid JSON: ${e.message}`)
2125
+ process.exit(1)
2126
+ }
2127
+ }
2128
+ function writeSettings(obj) {
2129
+ mkSync(claudeDir, { recursive: true })
2130
+ wfSync(settingsPath, JSON.stringify(obj, null, 2) + '\n')
2131
+ }
2132
+
2133
+ if (sub === 'init') {
2134
+ // Establish the first record baseline (trust-on-first-use). This exists
2135
+ // BECAUSE `verify --fix` correctly refuses to stamp records: without a
2136
+ // separate, deliberate bootstrap there would be no way to create the very
2137
+ // first manifest, and drift detection would report every record as `added`
2138
+ // forever. Kept distinct so the one-time act of trusting the current records
2139
+ // is explicit and auditable rather than a side effect of a repair flag.
2140
+ // Reject unrecognised args rather than silently ignoring them: `init` WRITES
2141
+ // a baseline, so a mistyped or unsupported scope flag must never quietly
2142
+ // baseline the wrong workspace.
2143
+ const initExtra = args.slice(1).filter((a) => a !== '--cwd' && !a.startsWith('--cwd='))
2144
+ if (args.slice(1).length > 0) {
2145
+ console.error('Usage: compose guard init')
2146
+ console.error('`guard init` baselines the workspace it is run IN — cd there instead of passing a scope flag.')
2147
+ if (initExtra.length !== args.slice(1).length) {
2148
+ console.error('(--cwd is not supported here: it would write the baseline to a different repo than the one you named.)')
2149
+ }
2150
+ process.exit(1)
2151
+ }
2152
+
2153
+ const { root: cwd } = resolveCwdWithWorkspace(args)
2154
+ const { computeRecordHashes, initManifestExclusive, manifestPathFor, recordFileSet } =
2155
+ await import('../lib/judgment-attest.js')
2156
+
2157
+ // `init` WRITES trust, so be explicit about the destination. The shared
2158
+ // resolver honours COMPOSE_TARGET, which can point somewhere other than the
2159
+ // shell's cwd — never let that be silent for this command.
2160
+ if (resolve(cwd) !== resolve(process.cwd())) {
2161
+ console.log(`Baselining resolved workspace: ${cwd}`)
2162
+ console.log('(resolved via COMPOSE_TARGET / workspace config, not your current directory)')
2163
+ }
2164
+
2165
+ const records = recordFileSet(cwd)
2166
+ if (records.length === 0) {
2167
+ console.log('No judgment records found — nothing to baseline.')
2168
+ process.exit(0)
2169
+ }
2170
+
2171
+ // Existence is decided by the FILESYSTEM and the write is exclusive (O_EXCL).
2172
+ // A parsed-value check was wrong twice over: a manifest whose contents are
2173
+ // literally `null` parses to null and would read as "absent" (overwriting a
2174
+ // real baseline), and a check-then-write race let two initializers both pass
2175
+ // the check. Re-baselining over an existing manifest is the laundering step,
2176
+ // so it must fail on the write itself, not on an advisory look.
2177
+ let hashes
2178
+ try {
2179
+ hashes = computeRecordHashes(cwd)
2180
+ } catch (e) {
2181
+ if (e?.code === 'JUDGMENT_RECORD_MALFORMED') {
2182
+ console.error(`Refusing to baseline: ${e.message}`)
2183
+ console.error('A malformed record cannot be trusted as-is — fix or remove it, then re-run.')
2184
+ process.exit(1)
2185
+ }
2186
+ throw e
2187
+ }
2188
+
2189
+ try {
2190
+ initManifestExclusive(cwd, hashes)
2191
+ } catch (e) {
2192
+ if (e?.code === 'EEXIST') {
2193
+ console.error(`A judgment record baseline already exists: ${manifestPathFor(cwd)}`)
2194
+ console.error('`guard init` will not overwrite it — that would launder any raw record edit.')
2195
+ console.error('Use `compose guard verify --fix` for projection drift, or the judgment_* tools to change records.')
2196
+ process.exit(1)
2197
+ }
2198
+ throw e
2199
+ }
2200
+
2201
+ console.log(`Baselined ${records.length} judgment record${records.length === 1 ? '' : 's'}.`)
2202
+ console.log('These records are trusted AS-IS: there is no prior attestation to verify them against.')
2203
+ console.log('From here, drift detection reports any careless change that does not go through the judgment tools.')
2204
+ console.log(`Commit ${manifestPathFor(cwd)} so the baseline travels with the repo and re-baselining shows up as a reviewable diff.`)
2205
+ process.exit(0)
2206
+ }
2207
+
2208
+ if (sub === 'verify') {
2209
+ const { root: cwd } = resolveCwdWithWorkspace(args)
2210
+ const verifyArgs = args.slice(1)
2211
+ const fix = verifyArgs.includes('--fix')
2212
+ if (verifyArgs.some((arg) => arg !== '--fix') || verifyArgs.filter((arg) => arg === '--fix').length > 1) {
2213
+ console.error('Usage: compose guard verify [--fix]')
2214
+ process.exit(1)
2215
+ }
2216
+
2217
+ const { verifyJudgmentCanon } = await import('../lib/judgment-verify.js')
2218
+ const { regenerateProjections } = await import('../lib/judgment-gen.js')
2219
+ const { computeRecordHashes, writeManifest } = await import('../lib/judgment-attest.js')
2220
+
2221
+ function formatProjectionFinding(finding) {
2222
+ const match = /^(.*) \(([^)]+)\)$/.exec(finding)
2223
+ return match ? `${match[1]} [${match[2]}]` : finding
2224
+ }
2225
+
2226
+ function printDrift(result) {
2227
+ console.error('Judgment canon drift detected:')
2228
+ if (result.treeDrift.length > 0) {
2229
+ console.error(' Tree drift (records-anchored file set):')
2230
+ for (const finding of result.treeDrift) {
2231
+ console.error(` - ${finding.path} [${finding.kind}]`)
2232
+ }
2233
+ }
2234
+ if (result.projectionDrift.length > 0) {
2235
+ console.error(' Projection drift (records-anchored):')
2236
+ for (const finding of result.projectionDrift) {
2237
+ console.error(` - ${formatProjectionFinding(finding)}`)
2238
+ }
2239
+ }
2240
+ if (result.recordDrift.length > 0) {
2241
+ console.error(' Record drift detection (careless changes only):')
2242
+ for (const finding of result.recordDrift) {
2243
+ console.error(` - ${finding.path} [${finding.kind}]`)
2244
+ }
2245
+ }
2246
+ }
2247
+
2248
+ let result = await verifyJudgmentCanon(cwd)
2249
+
2250
+ if (fix) {
2251
+ const projectionDriftBefore = result.projectionDrift
2252
+ if (projectionDriftBefore.length > 0) {
2253
+ regenerateProjections(cwd)
2254
+
2255
+ // `--fix` NEVER writes the record manifest. The earlier "refresh it when
2256
+ // records already passed" branch was both unnecessary and unsafe:
2257
+ // unnecessary because a passing record set already matches the manifest,
2258
+ // and unsafe because verifyJudgmentCanon releases the judgment lock when
2259
+ // it returns, so a raw edit landing between that verdict and the rewrite
2260
+ // would be stamped — laundering a hand-edit through the repair flag,
2261
+ // exactly what R1 forbids. Projections are derived and safe to
2262
+ // regenerate; records are not, so `--fix` simply does not touch them.
2263
+ if (result.recordDrift.length > 0) {
2264
+ console.log('Record drift was deliberately not fixed: --fix cannot bless record edits; use the judgment tools to make record changes.')
2265
+ }
2266
+
2267
+ result = await verifyJudgmentCanon(cwd)
2268
+ const remainingProjectionDrift = new Set(result.projectionDrift)
2269
+ const repaired = projectionDriftBefore.filter((finding) => !remainingProjectionDrift.has(finding))
2270
+ if (repaired.length > 0) {
2271
+ console.log('Fixed projection drift:')
2272
+ for (const finding of repaired) {
2273
+ console.log(` - ${formatProjectionFinding(finding)}`)
2274
+ }
2275
+ }
2276
+ } else {
2277
+ console.log('No projection drift to fix.')
2278
+ if (result.recordDrift.length > 0) {
2279
+ console.log('Record drift was deliberately not fixed: --fix cannot bless record edits; use the judgment tools to make record changes.')
2280
+ }
2281
+ }
2282
+
2283
+ if (result.treeDrift.length > 0) {
2284
+ console.log('Tree drift was not fixed: --fix only regenerates derived projections.')
2285
+ }
2286
+ }
2287
+
2288
+ if (result.ok) {
2289
+ console.log('Judgment canon drift detection passed.')
2290
+ process.exit(0)
2291
+ }
2292
+
2293
+ printDrift(result)
2294
+ process.exit(1)
2295
+ }
2296
+
2297
+ if (sub === 'install') {
2298
+ if (!exSync(hookScript)) {
2299
+ console.error(`Error: hook script missing at ${hookScript}`)
2300
+ console.error('It is a tracked source file — ensure your checkout includes .claude/hooks/canon-guard.mjs')
2301
+ process.exit(1)
2302
+ }
2303
+ const { settings, changed } = installGuardHook(readSettings())
2304
+ if (changed) {
2305
+ writeSettings(settings)
2306
+ console.log('Installed canon-guard PreToolUse hook in .claude/settings.json')
2307
+ } else {
2308
+ console.log('canon-guard hook already installed (current).')
2309
+ }
2310
+ console.log(` matcher: ${HOOK_MATCHER}`)
2311
+ console.log(` command: ${HOOK_COMMAND}`)
2312
+ process.exit(0)
2313
+ }
2314
+
2315
+ if (sub === 'uninstall') {
2316
+ const { settings, changed } = uninstallGuardHook(readSettings())
2317
+ if (changed) {
2318
+ writeSettings(settings)
2319
+ console.log('Removed canon-guard hook from .claude/settings.json')
2320
+ } else {
2321
+ console.log('No canon-guard hook installed.')
2322
+ }
2323
+ process.exit(0)
2324
+ }
2325
+
2326
+ if (!sub || sub === 'status') {
2327
+ const st = guardHookStatus(readSettings())
2328
+ const scriptState = exSync(hookScript) ? 'present' : 'MISSING'
2329
+ if (st.state === 'installed') console.log('canon-guard: installed (current)')
2330
+ else if (st.state === 'stale') console.log('canon-guard: installed (stale — re-run `compose guard install`)')
2331
+ else console.log('canon-guard: absent — run `compose guard install`')
2332
+ console.log(` hook script: ${scriptState} (${hookScript})`)
2333
+ // Derived from the registry, never hardcoded: a literal string here
2334
+ // silently under-reported the guarded set as soon as COMP-CANON-OVERRIDE
2335
+ // widened it. "Claude-runtime only" is not modesty — Bash and Codex writes
2336
+ // never reach this hook (drift detection, not enforcement).
2337
+ const { guardedDisplaysFor } = await import('../lib/canon-registry.js')
2338
+ for (const pattern of guardedDisplaysFor('hook')) {
2339
+ console.log(` guards: ${pattern} (Write|Edit|NotebookEdit) — Claude-runtime only`)
2340
+ }
2341
+ // `compose guard status` must keep working outside a workspace (it always
2342
+ // has, for the canon-guard hook state above); only the newer `signing:`
2343
+ // block needs a resolved workspace, so resolve it tolerantly here instead
2344
+ // of going through resolveCwdWithWorkspace -> dieOnWorkspaceError.
2345
+ let signingWorkspaceRoot = null
2346
+ try {
2347
+ const wsId = getWorkspaceFlag(args)
2348
+ signingWorkspaceRoot = resolveWorkspace({ workspaceId: wsId === '__COMPOSE_WORKSPACE_ID__' ? null : wsId }).root
2349
+ } catch { /* no workspace resolved — signing block is skipped below */ }
2350
+
2351
+ if (!signingWorkspaceRoot) {
2352
+ console.log('signing: (no workspace resolved — run inside a compose workspace or pass --workspace)')
2353
+ if (args.includes('--prune')) process.exit(1)
2354
+ process.exit(0)
2355
+ }
2356
+
2357
+ const { pruneGuardGenerations, signingStatusLines } = await import('../lib/guard-cli.js')
2358
+ if (args.includes('--prune')) {
2359
+ const pruned = await pruneGuardGenerations(signingWorkspaceRoot)
2360
+ for (const line of pruned.lines) console.log(line)
2361
+ if (pruned.status === 'refused') process.exit(1)
2362
+ }
2363
+ const signing = await signingStatusLines(signingWorkspaceRoot, { coverage: args.includes('--coverage') })
2364
+ for (const line of signing.lines) console.log(line)
2365
+ process.exit(0)
2366
+ }
2367
+
2368
+ console.error(`Unknown guard subcommand: "${sub}". Use: install | uninstall | status [--prune] [--coverage] | init | verify [--fix] | enrol | sign | descriptors`)
2369
+ process.exit(1)
2370
+ }
2371
+
1901
2372
  if (cmd === 'validate') {
1902
2373
  // compose validate [--scope=feature|project] [--code=CODE] [--block-on=error|warning|info] [--json]
1903
2374
  let scope = 'project'
@@ -2105,6 +2576,7 @@ if (cmd === 'experiment') {
2105
2576
  }
2106
2577
 
2107
2578
  if (cmd === 'build') {
2579
+ await emitDriftNudge()
2108
2580
  // Parse --cwd <path> for cross-repo builds
2109
2581
  let agentWorkDir = null
2110
2582
  const cwdIdx = args.indexOf('--cwd')
@@ -2138,6 +2610,29 @@ if (cmd === 'build') {
2138
2610
  console.error('Error: --template requires a name argument')
2139
2611
  process.exit(1)
2140
2612
  }
2613
+ // COMP-PIPELINE-QUARANTINE round 3: refuse templates that belong to another
2614
+ // command's runner. `compose build` is feature mode, which sends
2615
+ // {featureCode, description, implementer_agent, reviewer_agent}; these four
2616
+ // specs declare a different runner's envelope (task / projectName+intent /
2617
+ // gateCommands), so selecting one here resolves the spec and then fails at
2618
+ // plan time with an unhelpful error. Point at the command that can drive it.
2619
+ const MODE_BOUND_TEMPLATES = {
2620
+ 'bug-fix': 'compose fix <bug-code>',
2621
+ plan: 'compose plan "<intent>"',
2622
+ new: 'compose new',
2623
+ gsd: 'compose gsd <feature-code>',
2624
+ }
2625
+ // Compare the BASENAME: resolveTemplatePath builds `<cwd>/pipelines/<name>.stratum.yaml`
2626
+ // via join(), which normalizes away a leading `./`, so `--template ./bug-fix`
2627
+ // resolved the same spec while slipping past an exact-string check.
2628
+ const templateKey = basename(String(templateValue)).replace(/\.stratum\.ya?ml$/, '')
2629
+ if (MODE_BOUND_TEMPLATES[templateKey]) {
2630
+ console.error(
2631
+ `Error: --template ${templateValue} is not a build template — it belongs to ` +
2632
+ `\`${MODE_BOUND_TEMPLATES[templateKey]}\`, which supplies the inputs it declares.`
2633
+ )
2634
+ process.exit(1)
2635
+ }
2141
2636
  templateName = templateValue
2142
2637
  }
2143
2638
  if (teamTemplate && !templateName) {
@@ -2185,10 +2680,16 @@ if (cmd === 'build') {
2185
2680
  }
2186
2681
  }
2187
2682
 
2683
+ const { root: buildCwd } = resolveCwdWithWorkspace(args)
2684
+
2188
2685
  // Multiple codes: compose build FEAT-1 FEAT-2 FEAT-3
2189
2686
  const isMulti = featureCodes.length > 1
2190
- // Single prefix: compose build STRAT-COMP (no trailing digit)
2191
- const isPrefix = featureCodes.length === 1 && featureCode && !/\d$/.test(featureCode)
2687
+ // Single prefix: compose build STRAT-COMP (no trailing digit). An exact
2688
+ // feature on disk always wins over the prefix heuristic — codes like
2689
+ // COMP-SEMVER-STRICT have no trailing digit but are single features.
2690
+ const isExactFeature = featureCodes.length === 1 && featureCode &&
2691
+ existsSync(join(buildCwd, 'docs', 'features', featureCode, 'feature.json'))
2692
+ const isPrefix = featureCodes.length === 1 && featureCode && !isExactFeature && !/\d$/.test(featureCode)
2192
2693
  const isBatch = all || isPrefix || isMulti
2193
2694
 
2194
2695
  if (abort && isBatch) {
@@ -2261,7 +2762,6 @@ if (cmd === 'build') {
2261
2762
  // and there's no bundled preset fallback — so when --quick is requested and
2262
2763
  // that file is absent, treat it as init-needed (runInit re-seeds it) rather
2263
2764
  // than letting runBuild fail later with "Lifecycle spec not found".
2264
- const { root: buildCwd } = resolveCwdWithWorkspace(args)
2265
2765
  const needsInit =
2266
2766
  !existsSync(join(buildCwd, '.compose', 'compose.json')) ||
2267
2767
  !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml')) ||
@@ -2374,7 +2874,11 @@ if (cmd === 'build') {
2374
2874
  const { root: fixCwd } = resolveCwdWithWorkspace(args)
2375
2875
  if (!existsSync(join(fixCwd, '.compose', 'compose.json')) || !existsSync(join(fixCwd, 'pipelines', 'bug-fix.stratum.yaml'))) {
2376
2876
  console.log('Running compose init...\n')
2377
- await runInit(args.filter(a => a.startsWith('--')))
2877
+ // Thread the RESOLVED workspace root, exactly as the build path does. Without
2878
+ // it runInit seeds process.cwd(), which differs from fixCwd when `compose fix`
2879
+ // is invoked from a subdirectory — the guard checks fixCwd but the seed lands
2880
+ // elsewhere, so the run still fails with the same missing-spec error.
2881
+ await runInit(args.filter(a => a.startsWith('--')), fixCwd)
2378
2882
  console.log('')
2379
2883
  }
2380
2884
 
@@ -2451,6 +2955,7 @@ if (cmd === 'build') {
2451
2955
  })
2452
2956
  })
2453
2957
  } else if (cmd === 'plan') {
2958
+ await emitDriftNudge()
2454
2959
  // compose plan "<intent>" — runs the plan.stratum.yaml product-planning
2455
2960
  // lifecycle (explore_design → plan → ship). Thin delegation to runBuild() with
2456
2961
  // template='plan', mode='plan'. Derives a PLAN-<slug> code from the intent
@@ -2801,306 +3306,82 @@ if (cmd === 'build') {
2801
3306
  }
2802
3307
  process.exit(1)
2803
3308
  })
2804
- } else if (cmd === 'ideabox') {
3309
+ } else if (cmd === 'judgment') {
2805
3310
  // ---------------------------------------------------------------------------
2806
- // compose ideaboxidea management CLI
3311
+ // compose judgment trace <slug> causal ancestry of a position
3312
+ // (COMP-JUDGMENT-PRECEDENT slice A). Read-only. The store has always persisted
3313
+ // revision chains and the `supersedes` ref; nothing surfaced them.
2807
3314
  // ---------------------------------------------------------------------------
2808
- const ibSubcmd = args[0]
2809
- const { root: ibCwd } = resolveCwdWithWorkspace(args)
2810
-
2811
- // Resolve compose config (paths, etc.)
2812
- function loadComposeConfig(cwd) {
2813
- const cfgPath = join(cwd, '.compose', 'compose.json')
2814
- if (existsSync(cfgPath)) {
2815
- try { return JSON.parse(readFileSync(cfgPath, 'utf-8')) } catch {}
2816
- }
2817
- return {}
2818
- }
2819
- function getIdeaboxRelPath(cwd) {
2820
- return loadComposeConfig(cwd)?.paths?.ideabox || 'docs/product/ideabox.md'
2821
- }
2822
- const ibConfig = loadComposeConfig(ibCwd)
2823
-
2824
- const {
2825
- parseIdeabox: _parseIdeabox,
2826
- serializeIdeabox: _serializeIdeabox,
2827
- addIdea: _addIdea,
2828
- promoteIdea: _promoteIdea,
2829
- killIdea: _killIdea,
2830
- setPriority: _setPriority,
2831
- loadLens: _loadLens,
2832
- readIdeabox: _readIdeabox,
2833
- writeIdeabox: _writeIdeabox,
2834
- addDiscussion: _addDiscussion,
2835
- } = await import('../lib/ideabox.js')
2836
-
2837
- const ibRelPath = getIdeaboxRelPath(ibCwd)
2838
- // ibFullPath is used for direct file ops (existsSync/mkdirSync/writeFileSync/
2839
- // display); resolve it absolute-safe. ibRelPath stays relative because the
2840
- // lib readIdeabox/writeIdeabox readers re-join it under ibCwd.
2841
- const { resolveIdeaboxPath: _resolveIdeaboxPathCli } = await import('../lib/project-paths.js')
2842
- const ibFullPath = _resolveIdeaboxPathCli(ibCwd)
2843
-
2844
- if (!ibSubcmd || ibSubcmd === '--help' || ibSubcmd === '-h') {
2845
- console.log('Usage: compose ideabox <subcommand>')
2846
- console.log('')
2847
- console.log('Subcommands:')
2848
- console.log(' add "<title>" Add a new idea')
2849
- console.log(' list List all ideas')
2850
- console.log(' promote <ID> Mark idea as PROMOTED (creates feature folder)')
2851
- console.log(' kill <ID> "<reason>" Move idea to Killed Ideas')
2852
- console.log(' pri <ID> <P0|P1|P2> Set priority')
2853
- console.log(' discuss <ID> "<comment>" Add a discussion comment')
2854
- console.log(' triage [--lens <name>] Walk untriaged ideas and assign priorities')
2855
- process.exit(0)
3315
+ // `args` excludes the command itself (const [,, cmd, ...args]), so the
3316
+ // subcommand is args[0] same shape as `subcmd = args[0]` elsewhere.
3317
+ const sub = args[0]
3318
+ if (sub !== 'trace') {
3319
+ console.log('Usage: compose judgment trace <slug> [--json]')
3320
+ process.exit(sub ? 1 : 0)
2856
3321
  }
2857
-
2858
- if (ibSubcmd === 'add') {
2859
- const title = args.slice(1).find(a => !a.startsWith('-')) || args[1]
2860
- if (!title) {
2861
- console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]')
2862
- process.exit(1)
2863
- }
2864
- // Parse optional flags
2865
- const sourceIdx = args.indexOf('--source')
2866
- const descIdx = args.indexOf('--desc')
2867
- const clusterIdx = args.indexOf('--cluster')
2868
- const tagsIdx = args.indexOf('--tags')
2869
- const source = sourceIdx !== -1 ? args[sourceIdx + 1] : ''
2870
- const description = descIdx !== -1 ? args[descIdx + 1] : ''
2871
- const cluster = clusterIdx !== -1 ? args[clusterIdx + 1] : null
2872
- const tagsRaw = tagsIdx !== -1 ? args[tagsIdx + 1] : ''
2873
- const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim().startsWith('#') ? t.trim() : `#${t.trim()}`) : []
2874
-
2875
- if (!existsSync(ibFullPath)) {
2876
- const { IDEABOX_TEMPLATE } = await import('../lib/ideabox.js')
2877
- mkdirSync(dirname(ibFullPath), { recursive: true })
2878
- writeFileSync(ibFullPath, IDEABOX_TEMPLATE)
2879
- }
2880
-
2881
- const parsed = _readIdeabox(ibCwd, ibRelPath)
2882
- _addIdea(parsed, { title, description, source, tags, cluster })
2883
- _writeIdeabox(ibCwd, ibRelPath, parsed)
2884
- const newIdea = parsed.ideas[parsed.ideas.length - 1]
2885
- console.log(`Added ${newIdea.id}: ${newIdea.title}`)
2886
- process.exit(0)
3322
+ const slug = args[1]
3323
+ if (!slug || slug.startsWith('--')) {
3324
+ console.error('Usage: compose judgment trace <slug> [--json]')
3325
+ process.exit(1)
2887
3326
  }
2888
-
2889
- if (ibSubcmd === 'list') {
2890
- if (!existsSync(ibFullPath)) {
2891
- console.log('No ideabox found. Run: compose ideabox add "<title>"')
2892
- process.exit(0)
2893
- }
2894
- const parsed = _readIdeabox(ibCwd, ibRelPath)
2895
- if (parsed.ideas.length === 0 && parsed.killed.length === 0) {
2896
- console.log('No ideas yet.')
3327
+ const { root: jCwd } = resolveCwdWithWorkspace(args)
3328
+ const { getJudgmentTrace } = await import('../lib/judgment-writer.js')
3329
+ try {
3330
+ const t = await getJudgmentTrace(jCwd, slug)
3331
+ if (args.includes('--json')) {
3332
+ console.log(JSON.stringify(t, null, 2))
2897
3333
  process.exit(0)
2898
3334
  }
2899
-
2900
- // Group by status then priority
2901
- const byStatus = {}
2902
- for (const idea of parsed.ideas) {
2903
- const s = idea.status.startsWith('PROMOTED') ? 'PROMOTED' : idea.status
2904
- if (!byStatus[s]) byStatus[s] = []
2905
- byStatus[s].push(idea)
2906
- }
2907
-
2908
- const statusOrder = ['NEW', 'DISCUSSING', 'PROMOTED']
2909
- const priorityOrder = { P0: 0, P1: 1, P2: 2, '—': 3 }
2910
-
2911
- for (const status of statusOrder) {
2912
- const group = byStatus[status]
2913
- if (!group || group.length === 0) continue
2914
- group.sort((a, b) => (priorityOrder[a.priority] ?? 3) - (priorityOrder[b.priority] ?? 3))
2915
- console.log(`\n[${status}]`)
2916
- for (const idea of group) {
2917
- const pri = idea.priority !== '—' ? ` [${idea.priority}]` : ''
2918
- const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : ''
2919
- console.log(` ${idea.id}${pri} ${idea.title}${tags}`)
3335
+ console.log(`${t.slug} [${t.status}]`)
3336
+ for (const r of t.revisions) {
3337
+ const marks = [r.retracted ? 'retracted' : null, r.conviction ? `conviction=${r.conviction}` : null]
3338
+ .filter(Boolean).join(' ')
3339
+ const d = Array.isArray(r.delta) && r.delta.length ? ` (${r.delta.join('; ')})` : ''
3340
+ console.log(` r${r.rev} ${r.written_at ?? '—'} ${r.claim_count} claim(s)${marks ? ' ' + marks : ''}${d}`)
3341
+ }
3342
+ if (Array.isArray(t.supersededBy) && t.supersededBy.length) {
3343
+ console.log(` superseded by: ${t.supersededBy.map(s => `${s.slug}#r${s.rev}`).join(', ')}`)
3344
+ }
3345
+ let anc = t.supersedes, depth = 1
3346
+ while (anc) {
3347
+ const pin = anc.pinned ? `#r${anc.rev}` : ''
3348
+ console.log(`${' '.repeat(depth)}↳ supersedes ${anc.slug}${pin} [${anc.status}]`)
3349
+ for (const r of anc.revisions) {
3350
+ const d = Array.isArray(r.delta) && r.delta.length ? ` (${r.delta.join('; ')})` : ''
3351
+ console.log(`${' '.repeat(depth + 1)}r${r.rev} ${r.written_at ?? '—'} conviction=${r.conviction ?? '—'}${d}`)
2920
3352
  }
3353
+ anc = anc.supersedes; depth++
2921
3354
  }
2922
-
2923
- if (parsed.killed.length > 0) {
2924
- console.log(`\n[KILLED] (${parsed.killed.length})`)
2925
- for (const idea of parsed.killed) {
2926
- console.log(` ${idea.id} ${idea.title} — ${idea.killedReason}`)
2927
- }
2928
- }
2929
- process.exit(0)
2930
- }
2931
-
2932
- if (ibSubcmd === 'promote') {
2933
- const ideaId = args[1]
2934
- if (!ideaId) {
2935
- console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]')
2936
- process.exit(1)
2937
- }
2938
- const featureCode = args[2] || ''
2939
-
2940
- if (!existsSync(ibFullPath)) {
2941
- console.error(`Ideabox not found at ${ibFullPath}`)
2942
- process.exit(1)
2943
- }
2944
-
2945
- const parsed = _readIdeabox(ibCwd, ibRelPath)
2946
- const idea = parsed.ideas.find(i => i.id.toUpperCase() === ideaId.toUpperCase())
2947
- if (!idea) {
2948
- console.error(`Idea not found: ${ideaId}`)
2949
- process.exit(1)
2950
- }
2951
-
2952
- // Generate feature code if not provided
2953
- let resolvedCode = featureCode
2954
- if (!resolvedCode) {
2955
- // Derive a slug from the title
2956
- const slug = idea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '')
2957
- resolvedCode = `IDEA-${idea.num}-${slug}`.toUpperCase()
2958
- }
2959
-
2960
- // Create feature folder if missing — respect paths.features (may be
2961
- // relocated outside ibCwd — COMP-PATHS-EXTERNAL; resolve absolute, no re-root)
2962
- const featuresBase = resolveFeaturesPathFromConfig(ibCwd, ibConfig)
2963
- const featuresDir = join(featuresBase, resolvedCode)
2964
- if (!existsSync(featuresDir)) {
2965
- // COMP-MCP-VALIDATE-1: route through the validated writer (schema-guarded)
2966
- // instead of a raw writeFileSync.
2967
- const { writeFeature } = await import('../lib/feature-json.js')
2968
- writeFeature(ibCwd, {
2969
- code: resolvedCode,
2970
- description: idea.title,
2971
- status: 'PLANNED',
2972
- promotedFrom: ideaId,
2973
- createdAt: new Date().toISOString(),
2974
- }, featuresBase)
2975
- console.log(`Created feature folder: ${join(featuresBase, resolvedCode)}/`)
2976
- }
2977
-
2978
- _promoteIdea(parsed, ideaId, resolvedCode)
2979
- _writeIdeabox(ibCwd, ibRelPath, parsed)
2980
- console.log(`Promoted ${ideaId} → ${resolvedCode}`)
2981
- process.exit(0)
2982
- }
2983
-
2984
- if (ibSubcmd === 'kill') {
2985
- const ideaId = args[1]
2986
- const reason = args[2] || ''
2987
- if (!ideaId) {
2988
- console.error('Usage: compose ideabox kill <ID> "<reason>"')
2989
- process.exit(1)
2990
- }
2991
- if (!existsSync(ibFullPath)) {
2992
- console.error(`Ideabox not found at ${ibFullPath}`)
2993
- process.exit(1)
2994
- }
2995
-
2996
- const parsed = _readIdeabox(ibCwd, ibRelPath)
2997
- _killIdea(parsed, ideaId, reason)
2998
- _writeIdeabox(ibCwd, ibRelPath, parsed)
2999
- console.log(`Killed ${ideaId}: ${reason}`)
3000
- process.exit(0)
3001
- }
3002
-
3003
- if (ibSubcmd === 'pri') {
3004
- const ideaId = args[1]
3005
- const priority = args[2]
3006
- if (!ideaId || !priority) {
3007
- console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>')
3008
- process.exit(1)
3009
- }
3010
- if (!existsSync(ibFullPath)) {
3011
- console.error(`Ideabox not found at ${ibFullPath}`)
3012
- process.exit(1)
3013
- }
3014
-
3015
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3016
- _setPriority(parsed, ideaId, priority)
3017
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3018
- console.log(`Set ${ideaId} priority → ${priority}`)
3355
+ if (t.cycle) console.error(` WARNING: supersession cycle — ${t.cycle}`)
3356
+ for (const w of t.warnings) console.error(` WARNING: ${w}`)
3019
3357
  process.exit(0)
3358
+ } catch (err) {
3359
+ console.error(err?.message || String(err))
3360
+ process.exit(1)
3020
3361
  }
3021
-
3022
- if (ibSubcmd === 'discuss') {
3023
- const ideaId = args[1]
3024
- const comment = args[2]
3025
- if (!ideaId || !comment) {
3026
- console.error('Usage: compose ideabox discuss <ID> "<comment>"')
3027
- process.exit(1)
3028
- }
3029
- if (!existsSync(ibFullPath)) {
3030
- console.error(`Ideabox not found at ${ibFullPath}`)
3031
- process.exit(1)
3032
- }
3033
-
3034
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3035
- _addDiscussion(parsed, ideaId, 'human', comment)
3036
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3037
- const today = new Date().toISOString().slice(0, 10)
3038
- console.log(`[${today}] human: ${comment}`)
3039
- process.exit(0)
3362
+ } else if (cmd === 'ideabox') {
3363
+ // ---------------------------------------------------------------------------
3364
+ // compose ideabox — idea management CLI
3365
+ //
3366
+ // The implementation lives in lib/ideabox-cli.js (COMP-PLAN-IDEA-UNIFY S3b-1).
3367
+ // It was ~300 lines inline here, which made the cutover to the record store
3368
+ // testable only by spawning a subprocess. Behaviour is unchanged; the backing
3369
+ // store is not — every mutation now writes a record and re-renders
3370
+ // docs/product/ideabox.md as a projection of them.
3371
+ // ---------------------------------------------------------------------------
3372
+ const { root: ibCwd } = resolveCwdWithWorkspace(args)
3373
+ let ibConfig = {}
3374
+ const ibCfgPath = join(ibCwd, '.compose', 'compose.json')
3375
+ if (existsSync(ibCfgPath)) {
3376
+ try { ibConfig = JSON.parse(readFileSync(ibCfgPath, 'utf-8')) } catch {}
3040
3377
  }
3041
-
3042
- if (ibSubcmd === 'triage') {
3043
- const lensIdx = args.indexOf('--lens')
3044
- const lensName = lensIdx !== -1 ? args[lensIdx + 1] : null
3045
-
3046
- if (!existsSync(ibFullPath)) {
3047
- console.log('No ideabox found. Run: compose ideabox add "<title>" first.')
3048
- process.exit(0)
3049
- }
3050
-
3051
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3052
- const untriaged = parsed.ideas.filter(i => i.priority === '—' && i.status === 'NEW')
3053
-
3054
- if (untriaged.length === 0) {
3055
- console.log('No untriaged ideas.')
3056
- process.exit(0)
3057
- }
3058
-
3059
- let lens = null
3060
- if (lensName) {
3061
- lens = _loadLens(ibCwd, lensName)
3062
- if (!lens) {
3063
- console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`)
3064
- } else {
3065
- console.log(`Using lens: ${lensName}`)
3066
- }
3067
- }
3068
-
3069
- // Interactive triage using readline
3070
- const { createInterface } = await import('node:readline')
3071
- const rl = createInterface({ input: process.stdin, output: process.stdout })
3072
- const question = (q) => new Promise(resolve => rl.question(q, resolve))
3073
-
3074
- let changed = false
3075
- for (const idea of untriaged) {
3076
- console.log(`\n${idea.id}: ${idea.title}`)
3077
- if (idea.description) console.log(` ${idea.description.slice(0, 120)}`)
3078
- if (lens) console.log(` [lens: ${lensName}]`)
3079
-
3080
- const ans = await question(' Priority [P0/P1/P2/skip]: ')
3081
- const p = ans.trim().toUpperCase()
3082
- if (['P0', 'P1', 'P2'].includes(p)) {
3083
- _setPriority(parsed, idea.id, p)
3084
- changed = true
3085
- console.log(` Set ${idea.id} → ${p}`)
3086
- } else {
3087
- console.log(' Skipped')
3088
- }
3089
- }
3090
-
3091
- rl.close()
3092
-
3093
- if (changed) {
3094
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3095
- console.log('\nSaved.')
3096
- }
3097
- process.exit(0)
3378
+ const { runIdeaboxCommand } = await import('../lib/ideabox-cli.js')
3379
+ try {
3380
+ process.exit(await runIdeaboxCommand(ibCwd, args, { config: ibConfig }))
3381
+ } catch (err) {
3382
+ console.error(err?.message || String(err))
3383
+ process.exit(1)
3098
3384
  }
3099
-
3100
- console.error(`Unknown ideabox subcommand: ${ibSubcmd}`)
3101
- console.error('Run: compose ideabox --help')
3102
- process.exit(1)
3103
-
3104
3385
  } else if (cmd === 'qa-scope') {
3105
3386
  // ---------------------------------------------------------------------------
3106
3387
  // compose qa-scope <featureCode>
@@ -3225,6 +3506,96 @@ if (cmd === 'build') {
3225
3506
  process.exit(1)
3226
3507
  }
3227
3508
 
3509
+ } else if (cmd === 'lineage') {
3510
+ // ---------------------------------------------------------------------------
3511
+ // compose lineage <stamp|stale|show> --feature <FC> [--changed <file>] [--format text|json]
3512
+ // COMP-PROV-LINEAGE: W3C PROV-O artifact lineage (vocabulary only, no RDF).
3513
+ // stamp — materialise wasGeneratedBy/wasDerivedFrom markers into artifacts
3514
+ // stale — reachability query: given a --changed artifact, list stale descendants
3515
+ // show — print the derivation graph for the feature's canonical artifacts
3516
+ // See docs/features/COMP-PROV-LINEAGE/. Read-only except `stamp`; no server.
3517
+ // ---------------------------------------------------------------------------
3518
+ const lineageSub = args[0]
3519
+ const flagVal = (flag) => {
3520
+ const i = args.indexOf(flag)
3521
+ return i !== -1 && args[i + 1] ? args[i + 1] : null
3522
+ }
3523
+ const feature = flagVal('--feature')
3524
+ const format = flagVal('--format') || 'text'
3525
+ if (!feature || !['stamp', 'stale', 'show'].includes(lineageSub)) {
3526
+ console.error('usage: compose lineage <stamp|stale|show> --feature <FC> [--changed <file>] [--format text|json]')
3527
+ process.exit(1)
3528
+ }
3529
+ const { isFeatureCode } = await import('../lib/feature-code.js')
3530
+ if (!isFeatureCode(feature)) {
3531
+ console.error(`Invalid feature code: ${JSON.stringify(feature)} (must match ${'^[A-Z][A-Z0-9-]*[A-Z0-9]$'})`)
3532
+ process.exit(1)
3533
+ }
3534
+ const { join } = await import('node:path')
3535
+ const { existsSync } = await import('node:fs')
3536
+ const { resolveFeaturesPath } = await import('../lib/project-paths.js')
3537
+ const {
3538
+ stampFeatureLineage, findStaleDescendants, buildDerivationGraph,
3539
+ } = await import('../lib/lineage.js')
3540
+ const featureDir = join(resolveFeaturesPath(process.cwd()), feature)
3541
+ if (!existsSync(featureDir)) {
3542
+ console.error(`No feature folder at ${featureDir}`)
3543
+ process.exit(1)
3544
+ }
3545
+
3546
+ if (lineageSub === 'stamp') {
3547
+ const results = stampFeatureLineage(featureDir)
3548
+ if (format === 'json') {
3549
+ console.log(JSON.stringify(results, null, 2))
3550
+ } else if (results.length === 0) {
3551
+ console.log(`No canonical artifacts found in ${feature}.`)
3552
+ } else {
3553
+ const changed = results.filter(r => r.changed).length
3554
+ console.log(`Stamped lineage on ${feature} — ${changed} of ${results.length} artifact(s) updated:\n`)
3555
+ for (const r of results) {
3556
+ const from = r.wasDerivedFrom.length ? r.wasDerivedFrom.join(', ') : '(origin)'
3557
+ console.log(` ${r.changed ? '✓' : '·'} ${r.file} wasGeneratedBy=${r.wasGeneratedBy ?? '?'} wasDerivedFrom=${from}`)
3558
+ }
3559
+ }
3560
+ } else if (lineageSub === 'stale') {
3561
+ const changedFile = flagVal('--changed')
3562
+ if (!changedFile) {
3563
+ console.error('compose lineage stale: --changed <file> is required')
3564
+ process.exit(1)
3565
+ }
3566
+ // Must be a bare filename inside the feature folder — no path traversal.
3567
+ if (changedFile.includes('/') || changedFile.includes('\\') || changedFile.includes('..')) {
3568
+ console.error(`compose lineage stale: --changed must be a bare filename, got ${JSON.stringify(changedFile)}`)
3569
+ process.exit(1)
3570
+ }
3571
+ const results = findStaleDescendants(featureDir, changedFile)
3572
+ if (format === 'json') {
3573
+ console.log(JSON.stringify(results, null, 2))
3574
+ } else {
3575
+ const stale = results.filter(r => r.stale)
3576
+ if (results.length === 0) {
3577
+ console.log(`${changedFile} has no downstream artifacts in ${feature}.`)
3578
+ } else if (stale.length === 0) {
3579
+ console.log(`No stale descendants of ${changedFile} — all ${results.length} downstream artifact(s) are newer.`)
3580
+ } else {
3581
+ console.log(`${stale.length} artifact(s) downstream of ${changedFile} are STALE (older than the change):\n`)
3582
+ for (const r of stale) console.log(` ⚠ ${r.file}`)
3583
+ }
3584
+ }
3585
+ } else if (lineageSub === 'show') {
3586
+ const graph = buildDerivationGraph(featureDir)
3587
+ if (format === 'json') {
3588
+ console.log(JSON.stringify(Object.fromEntries(graph), null, 2))
3589
+ } else if (graph.size === 0) {
3590
+ console.log(`No canonical artifacts found in ${feature}.`)
3591
+ } else {
3592
+ console.log(`Derivation graph for ${feature}:\n`)
3593
+ for (const [file, parents] of graph) {
3594
+ console.log(parents.length ? ` ${file} ⟵ ${parents.join(', ')}` : ` ${file} (origin)`)
3595
+ }
3596
+ }
3597
+ }
3598
+
3228
3599
  } else if (cmd === 'gates' || cmd === 'gate') {
3229
3600
  // ---------------------------------------------------------------------------
3230
3601
  // compose gate list [--item <id>] [--status pending|all|resolved] [--format text|json]
@@ -3822,6 +4193,42 @@ if (cmd === 'build') {
3822
4193
  console.error(' compose smartmemory sync [--dry-run] [--feature <CODE>]')
3823
4194
  process.exit(1)
3824
4195
 
4196
+ } else if (cmd === 'metrics') {
4197
+ // ---------------------------------------------------------------------------
4198
+ // compose metrics [--since <duration|ISO>] [--feature <code>] [--json]
4199
+ // ---------------------------------------------------------------------------
4200
+ const { root: cwd } = resolveCwdWithWorkspace(args)
4201
+ let since = null
4202
+ let feature = null
4203
+ let json = false
4204
+
4205
+ for (let index = 0; index < args.length; index++) {
4206
+ const arg = args[index]
4207
+ if (arg === '--json') {
4208
+ json = true
4209
+ continue
4210
+ }
4211
+ if (arg === '--since' || arg === '--feature') {
4212
+ const value = args[index + 1]
4213
+ if (!value || value.startsWith('--')) {
4214
+ console.error(`compose metrics: ${arg} requires a value`)
4215
+ process.exit(1)
4216
+ }
4217
+ if (arg === '--since') since = value
4218
+ else feature = value
4219
+ index++
4220
+ continue
4221
+ }
4222
+ console.error(`compose metrics: unknown flag ${arg}`)
4223
+ process.exit(1)
4224
+ }
4225
+
4226
+ const { collectDispatchMetrics, renderDispatchMetrics } = await import('../lib/dispatch-metrics.js')
4227
+ const report = collectDispatchMetrics(cwd, { since, feature })
4228
+ if (json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
4229
+ else process.stdout.write(renderDispatchMetrics(report))
4230
+ process.exit(0)
4231
+
3825
4232
  } else {
3826
4233
  console.error(`Unknown command: ${cmd}`)
3827
4234
  process.exit(1)