@smartmemory/compose 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
package/bin/compose.js CHANGED
@@ -90,8 +90,13 @@ function resolveCwdWithWorkspace(args) {
90
90
  // --team flag (COMP-TEAMS)
91
91
  // ---------------------------------------------------------------------------
92
92
  import { parseTeamFlag } from '../lib/team-flag.js';
93
- import { loadDeps, checkExternalSkills, printDepReport, buildDepReport, checkExternalBinaries, printBinaryReport, buildBinaryReport } from '../lib/deps.js';
94
- import { checkLatestVersion } from '../lib/version-check.js';
93
+ import { loadDeps, checkExternalSkills, printDepReport, buildDepReport, checkExternalBinaries, printBinaryReport, buildBinaryReport, installMissingPlugins, printInstallReport } from '../lib/deps.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,40 +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(' metrics [--since <duration|ISO>] [--feature <code>] [--json] Report dispatch, settlement, and triage metrics')
141
- console.log(' qa-scope Show affected routes from a feature\'s changed files')
142
- console.log(' context decisions Show the build decision log (--feature <FC>, --format text|json)')
143
- console.log(' gate list List pending gates (--item <id>, --status pending|all|resolved)')
144
- console.log(' gate resolve <id> Resolve a gate (--approve|--revise|--kill, --comment <text>)')
145
- console.log(' init Initialize Compose in the current project')
146
- console.log(' setup Install/sync global Compose skills (alias: sync)')
147
- console.log(' sync Re-sync global skills from this install (alias of setup)')
148
- console.log(' update Pull latest compose, reinstall deps, refresh global skill')
149
- console.log(' doctor Check external skill dependencies')
150
- console.log(' guard Manage the canon guard and drift detection (install|uninstall|status|init|verify [--fix])')
151
- 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())
152
127
  process.exit(0)
153
128
  }
154
129
 
@@ -197,7 +172,7 @@ function detectAgents() {
197
172
  * - PACKAGE_ROOT/.claude/skills/* (compose skill)
198
173
  * - PACKAGE_ROOT/skills/* (stratum base skill)
199
174
  */
200
- function syncSkills(agents) {
175
+ function syncSkills(agents, setupFlags = []) {
201
176
  // Collect source skills: { name -> sourcePath }
202
177
  const sourceSkills = new Map()
203
178
  const skillSourceDirs = [
@@ -296,12 +271,28 @@ function syncSkills(agents) {
296
271
  // actionable install hints. Soft check: warnings only, exit code unaffected.
297
272
  const deps = loadDeps(PACKAGE_ROOT)
298
273
  if (deps) {
299
- const result = checkExternalSkills(deps)
274
+ let result = checkExternalSkills(deps)
275
+ // COMP-DEPS-AUTOINSTALL: close the loop rather than printing a hint and
276
+ // running degraded. Only required deps with a `plugin` spec are installed.
277
+ // The report printed below is the POST-install scan, so what the user reads
278
+ // is the state they actually end up in, never the state we started from.
279
+ const installReport = installMissingPlugins(result, { enabled: pluginInstallEnabled(setupFlags) })
280
+ printInstallReport(installReport)
281
+ if (installReport.installed.length > 0) result = checkExternalSkills(deps)
300
282
  printDepReport(result)
301
283
  printBinaryReport(checkExternalBinaries(deps))
302
284
  }
303
285
  }
304
286
 
287
+ /**
288
+ * COMP-DEPS-AUTOINSTALL opt-out. Off for `--no-install-deps` and for
289
+ * COMPOSE_NO_PLUGIN_INSTALL=1 (CI, sandboxes, anyone who manages plugins by hand).
290
+ */
291
+ function pluginInstallEnabled(flags = []) {
292
+ if (process.env.COMPOSE_NO_PLUGIN_INSTALL === '1') return false
293
+ return !flags.includes('--no-install-deps')
294
+ }
295
+
305
296
  // ---------------------------------------------------------------------------
306
297
  // compose doctor — re-run the external dep check
307
298
  // ---------------------------------------------------------------------------
@@ -568,10 +559,24 @@ async function runInit(flags, cwdOverride) {
568
559
  }
569
560
  }
570
561
 
571
- // 8. Copy default pipeline specs if absent
562
+ // 8. Copy default pipeline specs if absent.
563
+ // COMP-PIPELINE-QUARANTINE follow-up: `bug-fix` belongs in this set. `compose
564
+ // fix` checks for pipelines/bug-fix.stratum.yaml, runs init when it is absent,
565
+ // and then fails with "Lifecycle spec not found" because init never copied it —
566
+ // so the command was unavailable in every fresh workspace independently of the
567
+ // spec's dialect. Each spec's `<name>.profiles.json` sidecar travels WITH it:
568
+ // loadPipelineProfiles fails open, so a spec copied without its sidecar runs on
569
+ // bare defaults and silently drops the tool restrictions it declares.
572
570
  const pipelinesDir = join(cwd, 'pipelines')
573
571
  mkdirSync(pipelinesDir, { recursive: true })
574
- for (const specName of ['build.stratum.yaml', 'build-quick.stratum.yaml', 'new.stratum.yaml', 'plan.stratum.yaml']) {
572
+ const DEFAULT_PIPELINES = [
573
+ 'build.stratum.yaml',
574
+ 'build-quick.stratum.yaml',
575
+ 'bug-fix.stratum.yaml',
576
+ 'new.stratum.yaml',
577
+ 'plan.stratum.yaml',
578
+ ]
579
+ for (const specName of DEFAULT_PIPELINES) {
575
580
  const dest = join(pipelinesDir, specName)
576
581
  if (!existsSync(dest)) {
577
582
  const src = join(PACKAGE_ROOT, 'pipelines', specName)
@@ -580,10 +585,16 @@ async function runInit(flags, cwdOverride) {
580
585
  console.log(`Copied default pipeline to ${dest}`)
581
586
  }
582
587
  }
588
+ const sidecarName = specName.replace(/\.stratum\.ya?ml$/, '.profiles.json')
589
+ const sidecarDest = join(pipelinesDir, sidecarName)
590
+ if (!existsSync(sidecarDest)) {
591
+ const sidecarSrc = join(PACKAGE_ROOT, 'pipelines', sidecarName)
592
+ if (existsSync(sidecarSrc)) copyFileSync(sidecarSrc, sidecarDest)
593
+ }
583
594
  }
584
595
 
585
596
  // 9. Sync all compose-owned skills to detected agents
586
- syncSkills(agents)
597
+ syncSkills(agents, flags)
587
598
 
588
599
  // 10. Summary
589
600
  console.log('')
@@ -597,7 +608,7 @@ async function runInit(flags, cwdOverride) {
597
608
  // compose setup — user-global skill setup
598
609
  // ---------------------------------------------------------------------------
599
610
 
600
- function runSetup() {
611
+ function runSetup(setupFlags = []) {
601
612
  // 1. Sync all compose-owned skills to detected agents.
602
613
  // If none detected, fall back to Claude — setup is unconditional
603
614
  // "install global skill" per its help text.
@@ -605,7 +616,7 @@ function runSetup() {
605
616
  if (agents.length === 0) {
606
617
  agents = [{ name: 'claude', skillDir: join(homedir(), '.claude', 'skills', 'stratum') }]
607
618
  }
608
- syncSkills(agents)
619
+ syncSkills(agents, setupFlags)
609
620
 
610
621
  }
611
622
 
@@ -718,7 +729,7 @@ async function runUpdate(flags) {
718
729
  // Refresh global skills.
719
730
  console.log('')
720
731
  console.log('Refreshing global skill installation...')
721
- runSetup()
732
+ runSetup(args)
722
733
 
723
734
  // If invoked from inside a Compose project, refresh project artifacts too
724
735
  if (existsSync(join(cwd, '.compose', 'compose.json'))) {
@@ -757,11 +768,73 @@ async function runUpdate(flags) {
757
768
  console.log(`compose updated to v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
758
769
  }
759
770
 
771
+ /** Hard ceiling on how long the update nudge may delay a command. */
772
+ const NUDGE_BUDGET_MS = 4000
773
+
774
+ /** Stratum is enabled unless this cwd's workspace manifest explicitly opts out.
775
+ * This deliberately does not use resolveWorkspace(): the nudge runs before
776
+ * argument parsing, and workspace resolution may print errors and exit. */
777
+ function isStratumCapabilityEnabled() {
778
+ try {
779
+ const manifest = join(process.cwd(), '.compose', 'compose.json')
780
+ const config = JSON.parse(readFileSync(manifest, 'utf-8'))
781
+ return config?.capabilities?.stratum !== false
782
+ } catch {
783
+ // Absent, unreadable, or malformed means "enabled" for compatibility.
784
+ return true
785
+ }
786
+ }
787
+
788
+ /** Print a one-line update nudge at session entry points. NOTIFY ONLY —
789
+ * this must never mutate an installation. Compose has ~109 lazy `await import()`
790
+ * sites, so replacing files under a running process would have it load new
791
+ * modules into an old module graph; safe self-update needs a versioned install
792
+ * (COMP-UPDATE-VERSIONED-INSTALL), not a check here.
793
+ *
794
+ * Swallows everything: a courtesy line may never fail the command it precedes. */
795
+ async function emitDriftNudge() {
796
+ try {
797
+ // Only an interactive terminal pays for a registry lookup. Everything else
798
+ // — a spawned CLI, CI, a script — reads cache and stays silent on a miss.
799
+ //
800
+ // This is not an optimization, it is a correctness fix. The nudge sits on
801
+ // the startup path of init/build/plan, and the test suite alone spawns
802
+ // `compose init` dozens of times in parallel with a cold HOME. Every one of
803
+ // those fired a live fetch, and under that concurrency one failed to settle:
804
+ // node reported "Detected unsettled top-level await" at this call and exited
805
+ // non-zero, breaking `compose init` itself. A courtesy line must never be
806
+ // able to do that, and non-interactive callers were never its audience.
807
+ const cacheOnly = !process.stdout.isTTY
808
+ const stratumCurrent = isStratumCapabilityEnabled()
809
+ ? resolveStratumVersion(PACKAGE_ROOT)
810
+ : null
811
+ const work = Promise.all([
812
+ checkPackageVersion('@smartmemory/compose', getPkgVersion(), { cacheOnly }),
813
+ stratumCurrent
814
+ ? checkPackageVersion('@smartmemory/stratum', stratumCurrent, { cacheOnly })
815
+ : Promise.resolve(null),
816
+ ])
817
+ // Belt and braces: whatever happens below, this await settles. The timer is
818
+ // ref'd deliberately — an unref'd one would let the loop drain and reproduce
819
+ // the very unsettled-await exit this guards against.
820
+ const [composeInfo, stratumInfo] = await Promise.race([
821
+ work,
822
+ new Promise((resolve) => setTimeout(() => resolve([null, null]), NUDGE_BUDGET_MS)),
823
+ ])
824
+ for (const line of formatDriftNudge({ compose: composeInfo, stratum: stratumInfo })) {
825
+ console.log(line)
826
+ }
827
+ } catch {
828
+ // never break the command this is attached to
829
+ }
830
+ }
831
+
760
832
  // ---------------------------------------------------------------------------
761
833
  // Command dispatch
762
834
  // ---------------------------------------------------------------------------
763
835
 
764
836
  if (cmd === 'init') {
837
+ await emitDriftNudge()
765
838
  await runInit(args)
766
839
  process.exit(0)
767
840
  }
@@ -771,7 +844,7 @@ if (cmd === 'setup' || cmd === 'sync') {
771
844
  // agent skill dirs. The name `sync` better signals
772
845
  // the idempotent "reconcile local skills with this install" job (run it after
773
846
  // editing skills locally, when there's no new version to `update` to).
774
- runSetup()
847
+ runSetup(args)
775
848
  process.exit(0)
776
849
  }
777
850
 
@@ -825,7 +898,7 @@ if (cmd === 'migrate-anon') {
825
898
  if (cmd === 'install') {
826
899
  // Backwards-compat: run both init + setup
827
900
  await runInit(args)
828
- runSetup()
901
+ runSetup(args)
829
902
  process.exit(0)
830
903
  }
831
904
 
@@ -1161,38 +1234,122 @@ _This is a seed design doc created by \`compose feature\`. The \`compose build\`
1161
1234
  process.exit(0)
1162
1235
  }
1163
1236
 
1237
+ if (cmd === 'guard' && ['descriptors', 'sign', 'enrol'].includes(args[0])) {
1238
+ // New signing verbs stay above the canon-guard dispatcher below. They resolve
1239
+ // the target workspace; canon-guard intentionally continues to dogfood this
1240
+ // package root for its own hook state.
1241
+ const sub = args[0]
1242
+ const { root: cwd } = resolveCwdWithWorkspace(args)
1243
+ const { runGuardDescriptors, runGuardSign, runGuardEnrol } = await import('../lib/guard-cli.js')
1244
+ const principalAt = args.indexOf('--principal')
1245
+ const principal = principalAt === -1 ? undefined : (args[principalAt + 1] ?? '')
1246
+ const result = sub === 'descriptors' ? await runGuardDescriptors(cwd)
1247
+ : sub === 'sign' ? await runGuardSign(cwd)
1248
+ : await runGuardEnrol(principal === undefined ? {} : { principal }, { emit: (line) => console.log(line) })
1249
+ for (const line of result.lines) console.log(line)
1250
+ process.exit(result.status === 'refused' ? 1 : 0)
1251
+ }
1252
+
1164
1253
  if (cmd === 'roadmap') {
1165
1254
  const subcmd = args[0]
1166
1255
 
1167
1256
  // compose roadmap generate — regenerate ROADMAP.md from feature.json files,
1168
1257
  // converging to a fixed point before finishing.
1169
1258
  if (subcmd === 'generate' || subcmd === 'gen') {
1170
- const { writeRoadmap } = await import('../lib/roadmap-gen.js')
1171
1259
  const { checkRoundtrip } = await import('../lib/roadmap-roundtrip.js')
1260
+ const { generateRoadmapFromBase } = await import('../lib/roadmap-gen.js')
1172
1261
  const { listFeatures } = await import('../lib/feature-json.js')
1173
- const { loadExternalPrefixes } = await import('../lib/project-paths.js')
1262
+ const { loadExternalPrefixes, resolveRoadmapPath, loadFeaturesDir } = await import('../lib/project-paths.js')
1174
1263
  const { isNarrativeOwned } = await import('../lib/roadmap-config.js')
1264
+ const { readPreservedSections } = await import('../lib/roadmap-preservers.js')
1265
+ const { computeResidue, protectResidue, RoadmapProseLossError } = await import('../lib/roadmap-residue.js')
1175
1266
  const { root: cwd } = resolveCwdWithWorkspace(args)
1176
1267
  // Narrative-owned workspaces (#39): ROADMAP.md is hand-authored, not a render
1177
- // of feature.json. writeRoadmap already no-ops, but the canonicalization pass
1178
- // below would still overwrite the file (or crash if it's absent) — skip the
1179
- // whole generate path here so the hand-authored file is never touched.
1268
+ // of feature.json never regenerate or overwrite it.
1180
1269
  if (isNarrativeOwned(cwd)) {
1181
1270
  console.log('narrative-owned workspace (roadmap.narrative=true) — ROADMAP.md is hand-authored; generate skipped.')
1182
1271
  process.exit(0)
1183
1272
  }
1184
- const path = writeRoadmap(cwd)
1273
+ const path = resolveRoadmapPath(cwd)
1274
+ const base = existsSync(path) ? readFileSync(path, 'utf-8') : ''
1275
+ const acceptLoss = args.includes('--accept-loss')
1276
+ const protect = args.includes('--protect')
1185
1277
  const externalPrefixes = loadExternalPrefixes(cwd)
1186
- // checkRoundtrip's now:'0000-00-00' is only used to detect/canonicalize
1187
- // structural non-convergence — once the file has headings, readPreamble
1188
- // preserves the existing preamble date verbatim, so no sentinel date leaks.
1189
- const rt = checkRoundtrip(readFileSync(path, 'utf-8'), listFeatures(cwd), { now: '0000-00-00', externalPrefixes })
1190
- if (!rt.fixedPoint) {
1191
- writeFileSync(path, rt.canonical)
1192
- console.log(`Generated ${path} (canonicalized over ${rt.passes} passes)`)
1193
- } else {
1194
- console.log(`Generated ${path} from feature.json files`)
1278
+ const now = new Date().toISOString().slice(0, 10)
1279
+
1280
+ // Guard 1 (COMP-CONFLICT-MERGE): structural preserved-section marker defects
1281
+ // silently drop the content they were meant to protect — an unbalanced open
1282
+ // (typo'd close) or a duplicate id (Map collision). Fail loud, write nothing.
1283
+ try {
1284
+ readPreservedSections(base, { strict: true })
1285
+ } catch (err) {
1286
+ if (err?.code === 'ROADMAP_UNBALANCED_MARKER') {
1287
+ console.error(`Refusing to write ${path}: unbalanced preserved-section marker(s):`)
1288
+ for (const m of err.markers) console.error(` line ${m.lineNo}: open "${m.id}" has no matching close`)
1289
+ console.error(err.remediation)
1290
+ process.exit(1)
1291
+ }
1292
+ if (err?.code === 'ROADMAP_DUPLICATE_MARKER') {
1293
+ console.error(`Refusing to write ${path}: duplicate preserved-section id(s):`)
1294
+ for (const m of err.markers) console.error(` line ${m.lineNo}: id "${m.id}" is used more than once`)
1295
+ console.error(err.remediation)
1296
+ process.exit(1)
1297
+ }
1298
+ throw err
1299
+ }
1300
+
1301
+ // Honor a configured non-default features directory (paths.features), else a
1302
+ // project using one regenerates from the wrong/empty feature set.
1303
+ const featuresDir = loadFeaturesDir(cwd)
1304
+ const features = listFeatures(cwd, featuresDir)
1305
+
1306
+ // Emit the phase-override drift warning + roadmap_drift audit event, which the
1307
+ // old writeRoadmap-first path produced and the pure roundtrip path suppresses.
1308
+ generateRoadmapFromBase(base, features, { cwd, featuresDir, now })
1309
+
1310
+ // Guard 2: compute the FINAL canonical bytes (fixed point over all passes)
1311
+ // BEFORE writing, then diff the base against exactly what will be written.
1312
+ // The duplicate-heading loss only surfaces on a later pass, so checking the
1313
+ // first candidate would pass cleanly and then write the lossy result.
1314
+ const featureCodes = new Set(features.map(f => f.code))
1315
+ const rt = checkRoundtrip(base, features, { now, externalPrefixes, featuresDir })
1316
+ let finalText = rt.canonical
1317
+ let residue = computeResidue(base, finalText, { featureCodes })
1318
+
1319
+ if (residue.length > 0 && protect) {
1320
+ const protectedBase = protectResidue(base, residue)
1321
+ const rt2 = checkRoundtrip(protectedBase, features, { now, externalPrefixes, featuresDir })
1322
+ const stillLost = computeResidue(protectedBase, rt2.canonical, { featureCodes })
1323
+ if (stillLost.length === 0) {
1324
+ writeFileSync(path, rt2.canonical)
1325
+ console.log(`Generated ${path} — wrapped ${residue.length} line(s) in preserved-section markers`)
1326
+ process.exit(0)
1327
+ }
1328
+ // --protect could not fully account for the loss; fall through to the error
1329
+ // with the residual set so the operator sees what remains.
1330
+ finalText = rt2.canonical
1331
+ residue = stillLost
1195
1332
  }
1333
+
1334
+ if (residue.length > 0 && !acceptLoss) {
1335
+ const err = new RoadmapProseLossError(residue)
1336
+ console.error(`Refusing to write ${path}: ${err.message}:`)
1337
+ for (const r of err.lines) {
1338
+ console.error(` line ${r.lineNo} (under "${r.nearestHeading ?? '(preamble)'}"): ${r.text}`)
1339
+ }
1340
+ console.error('')
1341
+ console.error(err.remediation)
1342
+ process.exit(1)
1343
+ }
1344
+
1345
+ if (residue.length > 0 && acceptLoss) {
1346
+ console.log(`--accept-loss: dropping ${residue.length} hand-authored line(s):`)
1347
+ for (const r of residue) console.log(` line ${r.lineNo}: ${r.text}`)
1348
+ }
1349
+
1350
+ writeFileSync(path, finalText)
1351
+ if (!rt.fixedPoint) console.log(`Generated ${path} (canonicalized over ${rt.passes} passes)`)
1352
+ else console.log(`Generated ${path} from feature.json files`)
1196
1353
  process.exit(0)
1197
1354
  }
1198
1355
 
@@ -1592,7 +1749,7 @@ if (cmd === 'record-completion') {
1592
1749
  //
1593
1750
  // Flags:
1594
1751
  // --commit-sha=<sha> required; full 40-char hex SHA (Decision 9)
1595
- // --tests-pass=<bool> default true
1752
+ // --tests-pass=<bool> REQUIRED (true|false) — no default (COMP-COMPLETION-GATE)
1596
1753
  // --notes=<string> optional
1597
1754
  // --files-changed-from-stdin read newline-separated paths from stdin
1598
1755
  // --no-status set_status: false (don't flip status to COMPLETE)
@@ -1637,7 +1794,8 @@ if (cmd === 'record-completion') {
1637
1794
  console.log('')
1638
1795
  console.log('Options:')
1639
1796
  console.log(' --commit-sha=<sha> Full 40-char hex SHA (required)')
1640
- console.log(' --tests-pass=<bool> Whether tests passed (default: true)')
1797
+ console.log(' --tests-pass=<bool> Whether tests passed — REQUIRED, no default')
1798
+ console.log(' (configure guard.testCommand to attest by running instead)')
1641
1799
  console.log(' --notes=<string> Optional provenance notes')
1642
1800
  console.log(' --files-changed-from-stdin Read newline-separated repo-relative paths from stdin')
1643
1801
  console.log(' --no-status Do not flip feature status to COMPLETE')
@@ -1660,8 +1818,13 @@ if (cmd === 'record-completion') {
1660
1818
  process.exit(1)
1661
1819
  }
1662
1820
 
1663
- // Parse tests-pass: default true
1664
- let testsPass = true
1821
+ // Parse tests-pass. COMP-COMPLETION-GATE (BREAKING): there is NO default.
1822
+ // This used to default to `true`, which meant every completion recorded from
1823
+ // the CLI attested a test run the operator may never have made — the record
1824
+ // said "tests passed" on the strength of nothing. An attestation nobody made
1825
+ // is worse than an absent one, so the flag is now required unless a
1826
+ // `guard.testCommand` is configured to attest by running.
1827
+ let testsPass
1665
1828
  if (flags['tests-pass'] !== undefined) {
1666
1829
  const tp = flags['tests-pass']
1667
1830
  if (tp === 'false' || tp === false) testsPass = false
@@ -1698,7 +1861,48 @@ if (cmd === 'record-completion') {
1698
1861
  const { root: cwd } = resolveCwdWithWorkspace(args)
1699
1862
  const { recordCompletion } = await import('../lib/completion-writer.js')
1700
1863
  try {
1701
- const result = await recordCompletion(cwd, completionArgs)
1864
+ let result
1865
+ if (completionArgs.set_status === false) {
1866
+ // --no-status records evidence without completing, so it does not drive
1867
+ // the guard. tests_pass still has no default; the writer requires a boolean.
1868
+ if (testsPass === undefined) {
1869
+ console.error(
1870
+ 'Error: --tests-pass is required (true|false). It no longer defaults to true — ' +
1871
+ 'a completion record must not attest a test run that was never made.'
1872
+ )
1873
+ process.exit(1)
1874
+ }
1875
+ result = await recordCompletion(cwd, completionArgs)
1876
+ } else {
1877
+ const { completionGate } = await import('../lib/completion-gate.js')
1878
+ const gated = await completionGate({
1879
+ featureCode: featureCode,
1880
+ commitSha: commitSha,
1881
+ testsPass: testsPass,
1882
+ filesChanged: filesChanged,
1883
+ notes: flags['notes'] || undefined,
1884
+ force: flags['force'] === true || undefined,
1885
+ idempotencyKey: flags['idempotency-key'] || undefined,
1886
+ workspaceRoot: cwd,
1887
+ })
1888
+ if (!gated.ok) {
1889
+ console.error(`Refused at ${gated.refusedAt}:\n - ${gated.reasons.join('\n - ')}`)
1890
+ if (gated.refusedAt === 'evidence' && testsPass === undefined) {
1891
+ console.error(
1892
+ '\nHint: --tests-pass no longer defaults to true. Pass --tests-pass true|false, ' +
1893
+ 'or configure guard.testCommand in .compose/compose.json so the test run itself attests.'
1894
+ )
1895
+ }
1896
+ process.exit(1)
1897
+ }
1898
+ result = gated.result
1899
+ if (gated.partial) {
1900
+ console.warn(
1901
+ `Completed, but a projection failed — ` +
1902
+ gated.failures.map(f => `${f.step}: ${f.message} (recover: ${f.recover})`).join('; ')
1903
+ )
1904
+ }
1905
+ }
1702
1906
  console.log(JSON.stringify({
1703
1907
  completion_id: result.completion_id,
1704
1908
  idempotent: result.idempotent,
@@ -2142,11 +2346,42 @@ if (cmd === 'guard') {
2142
2346
  else if (st.state === 'stale') console.log('canon-guard: installed (stale — re-run `compose guard install`)')
2143
2347
  else console.log('canon-guard: absent — run `compose guard install`')
2144
2348
  console.log(` hook script: ${scriptState} (${hookScript})`)
2145
- console.log(` guards: docs/judgment/** (Write|Edit|NotebookEdit) Claude-runtime only`)
2349
+ // Derived from the registry, never hardcoded: a literal string here
2350
+ // silently under-reported the guarded set as soon as COMP-CANON-OVERRIDE
2351
+ // widened it. "Claude-runtime only" is not modesty — Bash and Codex writes
2352
+ // never reach this hook (drift detection, not enforcement).
2353
+ const { guardedDisplaysFor } = await import('../lib/canon-registry.js')
2354
+ for (const pattern of guardedDisplaysFor('hook')) {
2355
+ console.log(` guards: ${pattern} (Write|Edit|NotebookEdit) — Claude-runtime only`)
2356
+ }
2357
+ // `compose guard status` must keep working outside a workspace (it always
2358
+ // has, for the canon-guard hook state above); only the newer `signing:`
2359
+ // block needs a resolved workspace, so resolve it tolerantly here instead
2360
+ // of going through resolveCwdWithWorkspace -> dieOnWorkspaceError.
2361
+ let signingWorkspaceRoot = null
2362
+ try {
2363
+ const wsId = getWorkspaceFlag(args)
2364
+ signingWorkspaceRoot = resolveWorkspace({ workspaceId: wsId === '__COMPOSE_WORKSPACE_ID__' ? null : wsId }).root
2365
+ } catch { /* no workspace resolved — signing block is skipped below */ }
2366
+
2367
+ if (!signingWorkspaceRoot) {
2368
+ console.log('signing: (no workspace resolved — run inside a compose workspace or pass --workspace)')
2369
+ if (args.includes('--prune')) process.exit(1)
2370
+ process.exit(0)
2371
+ }
2372
+
2373
+ const { pruneGuardGenerations, signingStatusLines } = await import('../lib/guard-cli.js')
2374
+ if (args.includes('--prune')) {
2375
+ const pruned = await pruneGuardGenerations(signingWorkspaceRoot)
2376
+ for (const line of pruned.lines) console.log(line)
2377
+ if (pruned.status === 'refused') process.exit(1)
2378
+ }
2379
+ const signing = await signingStatusLines(signingWorkspaceRoot, { coverage: args.includes('--coverage') })
2380
+ for (const line of signing.lines) console.log(line)
2146
2381
  process.exit(0)
2147
2382
  }
2148
2383
 
2149
- console.error(`Unknown guard subcommand: "${sub}". Use: install | uninstall | status | init | verify [--fix]`)
2384
+ console.error(`Unknown guard subcommand: "${sub}". Use: install | uninstall | status [--prune] [--coverage] | init | verify [--fix] | enrol | sign | descriptors`)
2150
2385
  process.exit(1)
2151
2386
  }
2152
2387
 
@@ -2357,6 +2592,7 @@ if (cmd === 'experiment') {
2357
2592
  }
2358
2593
 
2359
2594
  if (cmd === 'build') {
2595
+ await emitDriftNudge()
2360
2596
  // Parse --cwd <path> for cross-repo builds
2361
2597
  let agentWorkDir = null
2362
2598
  const cwdIdx = args.indexOf('--cwd')
@@ -2390,6 +2626,29 @@ if (cmd === 'build') {
2390
2626
  console.error('Error: --template requires a name argument')
2391
2627
  process.exit(1)
2392
2628
  }
2629
+ // COMP-PIPELINE-QUARANTINE round 3: refuse templates that belong to another
2630
+ // command's runner. `compose build` is feature mode, which sends
2631
+ // {featureCode, description, implementer_agent, reviewer_agent}; these four
2632
+ // specs declare a different runner's envelope (task / projectName+intent /
2633
+ // gateCommands), so selecting one here resolves the spec and then fails at
2634
+ // plan time with an unhelpful error. Point at the command that can drive it.
2635
+ const MODE_BOUND_TEMPLATES = {
2636
+ 'bug-fix': 'compose fix <bug-code>',
2637
+ plan: 'compose plan "<intent>"',
2638
+ new: 'compose new',
2639
+ gsd: 'compose gsd <feature-code>',
2640
+ }
2641
+ // Compare the BASENAME: resolveTemplatePath builds `<cwd>/pipelines/<name>.stratum.yaml`
2642
+ // via join(), which normalizes away a leading `./`, so `--template ./bug-fix`
2643
+ // resolved the same spec while slipping past an exact-string check.
2644
+ const templateKey = basename(String(templateValue)).replace(/\.stratum\.ya?ml$/, '')
2645
+ if (MODE_BOUND_TEMPLATES[templateKey]) {
2646
+ console.error(
2647
+ `Error: --template ${templateValue} is not a build template — it belongs to ` +
2648
+ `\`${MODE_BOUND_TEMPLATES[templateKey]}\`, which supplies the inputs it declares.`
2649
+ )
2650
+ process.exit(1)
2651
+ }
2393
2652
  templateName = templateValue
2394
2653
  }
2395
2654
  if (teamTemplate && !templateName) {
@@ -2437,10 +2696,16 @@ if (cmd === 'build') {
2437
2696
  }
2438
2697
  }
2439
2698
 
2699
+ const { root: buildCwd } = resolveCwdWithWorkspace(args)
2700
+
2440
2701
  // Multiple codes: compose build FEAT-1 FEAT-2 FEAT-3
2441
2702
  const isMulti = featureCodes.length > 1
2442
- // Single prefix: compose build STRAT-COMP (no trailing digit)
2443
- const isPrefix = featureCodes.length === 1 && featureCode && !/\d$/.test(featureCode)
2703
+ // Single prefix: compose build STRAT-COMP (no trailing digit). An exact
2704
+ // feature on disk always wins over the prefix heuristic — codes like
2705
+ // COMP-SEMVER-STRICT have no trailing digit but are single features.
2706
+ const isExactFeature = featureCodes.length === 1 && featureCode &&
2707
+ existsSync(join(buildCwd, 'docs', 'features', featureCode, 'feature.json'))
2708
+ const isPrefix = featureCodes.length === 1 && featureCode && !isExactFeature && !/\d$/.test(featureCode)
2444
2709
  const isBatch = all || isPrefix || isMulti
2445
2710
 
2446
2711
  if (abort && isBatch) {
@@ -2513,7 +2778,6 @@ if (cmd === 'build') {
2513
2778
  // and there's no bundled preset fallback — so when --quick is requested and
2514
2779
  // that file is absent, treat it as init-needed (runInit re-seeds it) rather
2515
2780
  // than letting runBuild fail later with "Lifecycle spec not found".
2516
- const { root: buildCwd } = resolveCwdWithWorkspace(args)
2517
2781
  const needsInit =
2518
2782
  !existsSync(join(buildCwd, '.compose', 'compose.json')) ||
2519
2783
  !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml')) ||
@@ -2626,7 +2890,11 @@ if (cmd === 'build') {
2626
2890
  const { root: fixCwd } = resolveCwdWithWorkspace(args)
2627
2891
  if (!existsSync(join(fixCwd, '.compose', 'compose.json')) || !existsSync(join(fixCwd, 'pipelines', 'bug-fix.stratum.yaml'))) {
2628
2892
  console.log('Running compose init...\n')
2629
- await runInit(args.filter(a => a.startsWith('--')))
2893
+ // Thread the RESOLVED workspace root, exactly as the build path does. Without
2894
+ // it runInit seeds process.cwd(), which differs from fixCwd when `compose fix`
2895
+ // is invoked from a subdirectory — the guard checks fixCwd but the seed lands
2896
+ // elsewhere, so the run still fails with the same missing-spec error.
2897
+ await runInit(args.filter(a => a.startsWith('--')), fixCwd)
2630
2898
  console.log('')
2631
2899
  }
2632
2900
 
@@ -2703,6 +2971,7 @@ if (cmd === 'build') {
2703
2971
  })
2704
2972
  })
2705
2973
  } else if (cmd === 'plan') {
2974
+ await emitDriftNudge()
2706
2975
  // compose plan "<intent>" — runs the plan.stratum.yaml product-planning
2707
2976
  // lifecycle (explore_design → plan → ship). Thin delegation to runBuild() with
2708
2977
  // template='plan', mode='plan'. Derives a PLAN-<slug> code from the intent
@@ -3053,306 +3322,82 @@ if (cmd === 'build') {
3053
3322
  }
3054
3323
  process.exit(1)
3055
3324
  })
3056
- } else if (cmd === 'ideabox') {
3325
+ } else if (cmd === 'judgment') {
3057
3326
  // ---------------------------------------------------------------------------
3058
- // compose ideaboxidea management CLI
3327
+ // compose judgment trace <slug> causal ancestry of a position
3328
+ // (COMP-JUDGMENT-PRECEDENT slice A). Read-only. The store has always persisted
3329
+ // revision chains and the `supersedes` ref; nothing surfaced them.
3059
3330
  // ---------------------------------------------------------------------------
3060
- const ibSubcmd = args[0]
3061
- const { root: ibCwd } = resolveCwdWithWorkspace(args)
3062
-
3063
- // Resolve compose config (paths, etc.)
3064
- function loadComposeConfig(cwd) {
3065
- const cfgPath = join(cwd, '.compose', 'compose.json')
3066
- if (existsSync(cfgPath)) {
3067
- try { return JSON.parse(readFileSync(cfgPath, 'utf-8')) } catch {}
3068
- }
3069
- return {}
3070
- }
3071
- function getIdeaboxRelPath(cwd) {
3072
- return loadComposeConfig(cwd)?.paths?.ideabox || 'docs/product/ideabox.md'
3073
- }
3074
- const ibConfig = loadComposeConfig(ibCwd)
3075
-
3076
- const {
3077
- parseIdeabox: _parseIdeabox,
3078
- serializeIdeabox: _serializeIdeabox,
3079
- addIdea: _addIdea,
3080
- promoteIdea: _promoteIdea,
3081
- killIdea: _killIdea,
3082
- setPriority: _setPriority,
3083
- loadLens: _loadLens,
3084
- readIdeabox: _readIdeabox,
3085
- writeIdeabox: _writeIdeabox,
3086
- addDiscussion: _addDiscussion,
3087
- } = await import('../lib/ideabox.js')
3088
-
3089
- const ibRelPath = getIdeaboxRelPath(ibCwd)
3090
- // ibFullPath is used for direct file ops (existsSync/mkdirSync/writeFileSync/
3091
- // display); resolve it absolute-safe. ibRelPath stays relative because the
3092
- // lib readIdeabox/writeIdeabox readers re-join it under ibCwd.
3093
- const { resolveIdeaboxPath: _resolveIdeaboxPathCli } = await import('../lib/project-paths.js')
3094
- const ibFullPath = _resolveIdeaboxPathCli(ibCwd)
3095
-
3096
- if (!ibSubcmd || ibSubcmd === '--help' || ibSubcmd === '-h') {
3097
- console.log('Usage: compose ideabox <subcommand>')
3098
- console.log('')
3099
- console.log('Subcommands:')
3100
- console.log(' add "<title>" Add a new idea')
3101
- console.log(' list List all ideas')
3102
- console.log(' promote <ID> Mark idea as PROMOTED (creates feature folder)')
3103
- console.log(' kill <ID> "<reason>" Move idea to Killed Ideas')
3104
- console.log(' pri <ID> <P0|P1|P2> Set priority')
3105
- console.log(' discuss <ID> "<comment>" Add a discussion comment')
3106
- console.log(' triage [--lens <name>] Walk untriaged ideas and assign priorities')
3107
- process.exit(0)
3331
+ // `args` excludes the command itself (const [,, cmd, ...args]), so the
3332
+ // subcommand is args[0] same shape as `subcmd = args[0]` elsewhere.
3333
+ const sub = args[0]
3334
+ if (sub !== 'trace') {
3335
+ console.log('Usage: compose judgment trace <slug> [--json]')
3336
+ process.exit(sub ? 1 : 0)
3108
3337
  }
3109
-
3110
- if (ibSubcmd === 'add') {
3111
- const title = args.slice(1).find(a => !a.startsWith('-')) || args[1]
3112
- if (!title) {
3113
- console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]')
3114
- process.exit(1)
3115
- }
3116
- // Parse optional flags
3117
- const sourceIdx = args.indexOf('--source')
3118
- const descIdx = args.indexOf('--desc')
3119
- const clusterIdx = args.indexOf('--cluster')
3120
- const tagsIdx = args.indexOf('--tags')
3121
- const source = sourceIdx !== -1 ? args[sourceIdx + 1] : ''
3122
- const description = descIdx !== -1 ? args[descIdx + 1] : ''
3123
- const cluster = clusterIdx !== -1 ? args[clusterIdx + 1] : null
3124
- const tagsRaw = tagsIdx !== -1 ? args[tagsIdx + 1] : ''
3125
- const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim().startsWith('#') ? t.trim() : `#${t.trim()}`) : []
3126
-
3127
- if (!existsSync(ibFullPath)) {
3128
- const { IDEABOX_TEMPLATE } = await import('../lib/ideabox.js')
3129
- mkdirSync(dirname(ibFullPath), { recursive: true })
3130
- writeFileSync(ibFullPath, IDEABOX_TEMPLATE)
3131
- }
3132
-
3133
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3134
- _addIdea(parsed, { title, description, source, tags, cluster })
3135
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3136
- const newIdea = parsed.ideas[parsed.ideas.length - 1]
3137
- console.log(`Added ${newIdea.id}: ${newIdea.title}`)
3138
- process.exit(0)
3338
+ const slug = args[1]
3339
+ if (!slug || slug.startsWith('--')) {
3340
+ console.error('Usage: compose judgment trace <slug> [--json]')
3341
+ process.exit(1)
3139
3342
  }
3140
-
3141
- if (ibSubcmd === 'list') {
3142
- if (!existsSync(ibFullPath)) {
3143
- console.log('No ideabox found. Run: compose ideabox add "<title>"')
3144
- process.exit(0)
3145
- }
3146
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3147
- if (parsed.ideas.length === 0 && parsed.killed.length === 0) {
3148
- console.log('No ideas yet.')
3343
+ const { root: jCwd } = resolveCwdWithWorkspace(args)
3344
+ const { getJudgmentTrace } = await import('../lib/judgment-writer.js')
3345
+ try {
3346
+ const t = await getJudgmentTrace(jCwd, slug)
3347
+ if (args.includes('--json')) {
3348
+ console.log(JSON.stringify(t, null, 2))
3149
3349
  process.exit(0)
3150
3350
  }
3151
-
3152
- // Group by status then priority
3153
- const byStatus = {}
3154
- for (const idea of parsed.ideas) {
3155
- const s = idea.status.startsWith('PROMOTED') ? 'PROMOTED' : idea.status
3156
- if (!byStatus[s]) byStatus[s] = []
3157
- byStatus[s].push(idea)
3158
- }
3159
-
3160
- const statusOrder = ['NEW', 'DISCUSSING', 'PROMOTED']
3161
- const priorityOrder = { P0: 0, P1: 1, P2: 2, '—': 3 }
3162
-
3163
- for (const status of statusOrder) {
3164
- const group = byStatus[status]
3165
- if (!group || group.length === 0) continue
3166
- group.sort((a, b) => (priorityOrder[a.priority] ?? 3) - (priorityOrder[b.priority] ?? 3))
3167
- console.log(`\n[${status}]`)
3168
- for (const idea of group) {
3169
- const pri = idea.priority !== '—' ? ` [${idea.priority}]` : ''
3170
- const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : ''
3171
- console.log(` ${idea.id}${pri} ${idea.title}${tags}`)
3172
- }
3173
- }
3174
-
3175
- if (parsed.killed.length > 0) {
3176
- console.log(`\n[KILLED] (${parsed.killed.length})`)
3177
- for (const idea of parsed.killed) {
3178
- console.log(` ${idea.id} ${idea.title} — ${idea.killedReason}`)
3351
+ console.log(`${t.slug} [${t.status}]`)
3352
+ for (const r of t.revisions) {
3353
+ const marks = [r.retracted ? 'retracted' : null, r.conviction ? `conviction=${r.conviction}` : null]
3354
+ .filter(Boolean).join(' ')
3355
+ const d = Array.isArray(r.delta) && r.delta.length ? ` (${r.delta.join('; ')})` : ''
3356
+ console.log(` r${r.rev} ${r.written_at ?? '—'} ${r.claim_count} claim(s)${marks ? ' ' + marks : ''}${d}`)
3357
+ }
3358
+ if (Array.isArray(t.supersededBy) && t.supersededBy.length) {
3359
+ console.log(` superseded by: ${t.supersededBy.map(s => `${s.slug}#r${s.rev}`).join(', ')}`)
3360
+ }
3361
+ let anc = t.supersedes, depth = 1
3362
+ while (anc) {
3363
+ const pin = anc.pinned ? `#r${anc.rev}` : ''
3364
+ console.log(`${' '.repeat(depth)}↳ supersedes ${anc.slug}${pin} [${anc.status}]`)
3365
+ for (const r of anc.revisions) {
3366
+ const d = Array.isArray(r.delta) && r.delta.length ? ` (${r.delta.join('; ')})` : ''
3367
+ console.log(`${' '.repeat(depth + 1)}r${r.rev} ${r.written_at ?? '—'} conviction=${r.conviction ?? '—'}${d}`)
3179
3368
  }
3369
+ anc = anc.supersedes; depth++
3180
3370
  }
3371
+ if (t.cycle) console.error(` WARNING: supersession cycle — ${t.cycle}`)
3372
+ for (const w of t.warnings) console.error(` WARNING: ${w}`)
3181
3373
  process.exit(0)
3374
+ } catch (err) {
3375
+ console.error(err?.message || String(err))
3376
+ process.exit(1)
3182
3377
  }
3183
-
3184
- if (ibSubcmd === 'promote') {
3185
- const ideaId = args[1]
3186
- if (!ideaId) {
3187
- console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]')
3188
- process.exit(1)
3189
- }
3190
- const featureCode = args[2] || ''
3191
-
3192
- if (!existsSync(ibFullPath)) {
3193
- console.error(`Ideabox not found at ${ibFullPath}`)
3194
- process.exit(1)
3195
- }
3196
-
3197
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3198
- const idea = parsed.ideas.find(i => i.id.toUpperCase() === ideaId.toUpperCase())
3199
- if (!idea) {
3200
- console.error(`Idea not found: ${ideaId}`)
3201
- process.exit(1)
3202
- }
3203
-
3204
- // Generate feature code if not provided
3205
- let resolvedCode = featureCode
3206
- if (!resolvedCode) {
3207
- // Derive a slug from the title
3208
- const slug = idea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '')
3209
- resolvedCode = `IDEA-${idea.num}-${slug}`.toUpperCase()
3210
- }
3211
-
3212
- // Create feature folder if missing — respect paths.features (may be
3213
- // relocated outside ibCwd — COMP-PATHS-EXTERNAL; resolve absolute, no re-root)
3214
- const featuresBase = resolveFeaturesPathFromConfig(ibCwd, ibConfig)
3215
- const featuresDir = join(featuresBase, resolvedCode)
3216
- if (!existsSync(featuresDir)) {
3217
- // COMP-MCP-VALIDATE-1: route through the validated writer (schema-guarded)
3218
- // instead of a raw writeFileSync.
3219
- const { writeFeature } = await import('../lib/feature-json.js')
3220
- writeFeature(ibCwd, {
3221
- code: resolvedCode,
3222
- description: idea.title,
3223
- status: 'PLANNED',
3224
- promotedFrom: ideaId,
3225
- createdAt: new Date().toISOString(),
3226
- }, featuresBase)
3227
- console.log(`Created feature folder: ${join(featuresBase, resolvedCode)}/`)
3228
- }
3229
-
3230
- _promoteIdea(parsed, ideaId, resolvedCode)
3231
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3232
- console.log(`Promoted ${ideaId} → ${resolvedCode}`)
3233
- process.exit(0)
3234
- }
3235
-
3236
- if (ibSubcmd === 'kill') {
3237
- const ideaId = args[1]
3238
- const reason = args[2] || ''
3239
- if (!ideaId) {
3240
- console.error('Usage: compose ideabox kill <ID> "<reason>"')
3241
- process.exit(1)
3242
- }
3243
- if (!existsSync(ibFullPath)) {
3244
- console.error(`Ideabox not found at ${ibFullPath}`)
3245
- process.exit(1)
3246
- }
3247
-
3248
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3249
- _killIdea(parsed, ideaId, reason)
3250
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3251
- console.log(`Killed ${ideaId}: ${reason}`)
3252
- process.exit(0)
3253
- }
3254
-
3255
- if (ibSubcmd === 'pri') {
3256
- const ideaId = args[1]
3257
- const priority = args[2]
3258
- if (!ideaId || !priority) {
3259
- console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>')
3260
- process.exit(1)
3261
- }
3262
- if (!existsSync(ibFullPath)) {
3263
- console.error(`Ideabox not found at ${ibFullPath}`)
3264
- process.exit(1)
3265
- }
3266
-
3267
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3268
- _setPriority(parsed, ideaId, priority)
3269
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3270
- console.log(`Set ${ideaId} priority → ${priority}`)
3271
- process.exit(0)
3272
- }
3273
-
3274
- if (ibSubcmd === 'discuss') {
3275
- const ideaId = args[1]
3276
- const comment = args[2]
3277
- if (!ideaId || !comment) {
3278
- console.error('Usage: compose ideabox discuss <ID> "<comment>"')
3279
- process.exit(1)
3280
- }
3281
- if (!existsSync(ibFullPath)) {
3282
- console.error(`Ideabox not found at ${ibFullPath}`)
3283
- process.exit(1)
3284
- }
3285
-
3286
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3287
- _addDiscussion(parsed, ideaId, 'human', comment)
3288
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3289
- const today = new Date().toISOString().slice(0, 10)
3290
- console.log(`[${today}] human: ${comment}`)
3291
- process.exit(0)
3378
+ } else if (cmd === 'ideabox') {
3379
+ // ---------------------------------------------------------------------------
3380
+ // compose ideabox — idea management CLI
3381
+ //
3382
+ // The implementation lives in lib/ideabox-cli.js (COMP-PLAN-IDEA-UNIFY S3b-1).
3383
+ // It was ~300 lines inline here, which made the cutover to the record store
3384
+ // testable only by spawning a subprocess. Behaviour is unchanged; the backing
3385
+ // store is not every mutation now writes a record and re-renders
3386
+ // docs/product/ideabox.md as a projection of them.
3387
+ // ---------------------------------------------------------------------------
3388
+ const { root: ibCwd } = resolveCwdWithWorkspace(args)
3389
+ let ibConfig = {}
3390
+ const ibCfgPath = join(ibCwd, '.compose', 'compose.json')
3391
+ if (existsSync(ibCfgPath)) {
3392
+ try { ibConfig = JSON.parse(readFileSync(ibCfgPath, 'utf-8')) } catch {}
3292
3393
  }
3293
-
3294
- if (ibSubcmd === 'triage') {
3295
- const lensIdx = args.indexOf('--lens')
3296
- const lensName = lensIdx !== -1 ? args[lensIdx + 1] : null
3297
-
3298
- if (!existsSync(ibFullPath)) {
3299
- console.log('No ideabox found. Run: compose ideabox add "<title>" first.')
3300
- process.exit(0)
3301
- }
3302
-
3303
- const parsed = _readIdeabox(ibCwd, ibRelPath)
3304
- const untriaged = parsed.ideas.filter(i => i.priority === '—' && i.status === 'NEW')
3305
-
3306
- if (untriaged.length === 0) {
3307
- console.log('No untriaged ideas.')
3308
- process.exit(0)
3309
- }
3310
-
3311
- let lens = null
3312
- if (lensName) {
3313
- lens = _loadLens(ibCwd, lensName)
3314
- if (!lens) {
3315
- console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`)
3316
- } else {
3317
- console.log(`Using lens: ${lensName}`)
3318
- }
3319
- }
3320
-
3321
- // Interactive triage using readline
3322
- const { createInterface } = await import('node:readline')
3323
- const rl = createInterface({ input: process.stdin, output: process.stdout })
3324
- const question = (q) => new Promise(resolve => rl.question(q, resolve))
3325
-
3326
- let changed = false
3327
- for (const idea of untriaged) {
3328
- console.log(`\n${idea.id}: ${idea.title}`)
3329
- if (idea.description) console.log(` ${idea.description.slice(0, 120)}`)
3330
- if (lens) console.log(` [lens: ${lensName}]`)
3331
-
3332
- const ans = await question(' Priority [P0/P1/P2/skip]: ')
3333
- const p = ans.trim().toUpperCase()
3334
- if (['P0', 'P1', 'P2'].includes(p)) {
3335
- _setPriority(parsed, idea.id, p)
3336
- changed = true
3337
- console.log(` Set ${idea.id} → ${p}`)
3338
- } else {
3339
- console.log(' Skipped')
3340
- }
3341
- }
3342
-
3343
- rl.close()
3344
-
3345
- if (changed) {
3346
- _writeIdeabox(ibCwd, ibRelPath, parsed)
3347
- console.log('\nSaved.')
3348
- }
3349
- process.exit(0)
3394
+ const { runIdeaboxCommand } = await import('../lib/ideabox-cli.js')
3395
+ try {
3396
+ process.exit(await runIdeaboxCommand(ibCwd, args, { config: ibConfig }))
3397
+ } catch (err) {
3398
+ console.error(err?.message || String(err))
3399
+ process.exit(1)
3350
3400
  }
3351
-
3352
- console.error(`Unknown ideabox subcommand: ${ibSubcmd}`)
3353
- console.error('Run: compose ideabox --help')
3354
- process.exit(1)
3355
-
3356
3401
  } else if (cmd === 'qa-scope') {
3357
3402
  // ---------------------------------------------------------------------------
3358
3403
  // compose qa-scope <featureCode>
@@ -3477,6 +3522,96 @@ if (cmd === 'build') {
3477
3522
  process.exit(1)
3478
3523
  }
3479
3524
 
3525
+ } else if (cmd === 'lineage') {
3526
+ // ---------------------------------------------------------------------------
3527
+ // compose lineage <stamp|stale|show> --feature <FC> [--changed <file>] [--format text|json]
3528
+ // COMP-PROV-LINEAGE: W3C PROV-O artifact lineage (vocabulary only, no RDF).
3529
+ // stamp — materialise wasGeneratedBy/wasDerivedFrom markers into artifacts
3530
+ // stale — reachability query: given a --changed artifact, list stale descendants
3531
+ // show — print the derivation graph for the feature's canonical artifacts
3532
+ // See docs/features/COMP-PROV-LINEAGE/. Read-only except `stamp`; no server.
3533
+ // ---------------------------------------------------------------------------
3534
+ const lineageSub = args[0]
3535
+ const flagVal = (flag) => {
3536
+ const i = args.indexOf(flag)
3537
+ return i !== -1 && args[i + 1] ? args[i + 1] : null
3538
+ }
3539
+ const feature = flagVal('--feature')
3540
+ const format = flagVal('--format') || 'text'
3541
+ if (!feature || !['stamp', 'stale', 'show'].includes(lineageSub)) {
3542
+ console.error('usage: compose lineage <stamp|stale|show> --feature <FC> [--changed <file>] [--format text|json]')
3543
+ process.exit(1)
3544
+ }
3545
+ const { isFeatureCode } = await import('../lib/feature-code.js')
3546
+ if (!isFeatureCode(feature)) {
3547
+ console.error(`Invalid feature code: ${JSON.stringify(feature)} (must match ${'^[A-Z][A-Z0-9-]*[A-Z0-9]$'})`)
3548
+ process.exit(1)
3549
+ }
3550
+ const { join } = await import('node:path')
3551
+ const { existsSync } = await import('node:fs')
3552
+ const { resolveFeaturesPath } = await import('../lib/project-paths.js')
3553
+ const {
3554
+ stampFeatureLineage, findStaleDescendants, buildDerivationGraph,
3555
+ } = await import('../lib/lineage.js')
3556
+ const featureDir = join(resolveFeaturesPath(process.cwd()), feature)
3557
+ if (!existsSync(featureDir)) {
3558
+ console.error(`No feature folder at ${featureDir}`)
3559
+ process.exit(1)
3560
+ }
3561
+
3562
+ if (lineageSub === 'stamp') {
3563
+ const results = stampFeatureLineage(featureDir)
3564
+ if (format === 'json') {
3565
+ console.log(JSON.stringify(results, null, 2))
3566
+ } else if (results.length === 0) {
3567
+ console.log(`No canonical artifacts found in ${feature}.`)
3568
+ } else {
3569
+ const changed = results.filter(r => r.changed).length
3570
+ console.log(`Stamped lineage on ${feature} — ${changed} of ${results.length} artifact(s) updated:\n`)
3571
+ for (const r of results) {
3572
+ const from = r.wasDerivedFrom.length ? r.wasDerivedFrom.join(', ') : '(origin)'
3573
+ console.log(` ${r.changed ? '✓' : '·'} ${r.file} wasGeneratedBy=${r.wasGeneratedBy ?? '?'} wasDerivedFrom=${from}`)
3574
+ }
3575
+ }
3576
+ } else if (lineageSub === 'stale') {
3577
+ const changedFile = flagVal('--changed')
3578
+ if (!changedFile) {
3579
+ console.error('compose lineage stale: --changed <file> is required')
3580
+ process.exit(1)
3581
+ }
3582
+ // Must be a bare filename inside the feature folder — no path traversal.
3583
+ if (changedFile.includes('/') || changedFile.includes('\\') || changedFile.includes('..')) {
3584
+ console.error(`compose lineage stale: --changed must be a bare filename, got ${JSON.stringify(changedFile)}`)
3585
+ process.exit(1)
3586
+ }
3587
+ const results = findStaleDescendants(featureDir, changedFile)
3588
+ if (format === 'json') {
3589
+ console.log(JSON.stringify(results, null, 2))
3590
+ } else {
3591
+ const stale = results.filter(r => r.stale)
3592
+ if (results.length === 0) {
3593
+ console.log(`${changedFile} has no downstream artifacts in ${feature}.`)
3594
+ } else if (stale.length === 0) {
3595
+ console.log(`No stale descendants of ${changedFile} — all ${results.length} downstream artifact(s) are newer.`)
3596
+ } else {
3597
+ console.log(`${stale.length} artifact(s) downstream of ${changedFile} are STALE (older than the change):\n`)
3598
+ for (const r of stale) console.log(` ⚠ ${r.file}`)
3599
+ }
3600
+ }
3601
+ } else if (lineageSub === 'show') {
3602
+ const graph = buildDerivationGraph(featureDir)
3603
+ if (format === 'json') {
3604
+ console.log(JSON.stringify(Object.fromEntries(graph), null, 2))
3605
+ } else if (graph.size === 0) {
3606
+ console.log(`No canonical artifacts found in ${feature}.`)
3607
+ } else {
3608
+ console.log(`Derivation graph for ${feature}:\n`)
3609
+ for (const [file, parents] of graph) {
3610
+ console.log(parents.length ? ` ${file} ⟵ ${parents.join(', ')}` : ` ${file} (origin)`)
3611
+ }
3612
+ }
3613
+ }
3614
+
3480
3615
  } else if (cmd === 'gates' || cmd === 'gate') {
3481
3616
  // ---------------------------------------------------------------------------
3482
3617
  // compose gate list [--item <id>] [--status pending|all|resolved] [--format text|json]