@smartmemory/compose 0.3.7 → 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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. 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,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
 
@@ -568,10 +543,24 @@ async function runInit(flags, cwdOverride) {
568
543
  }
569
544
  }
570
545
 
571
- // 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.
572
554
  const pipelinesDir = join(cwd, 'pipelines')
573
555
  mkdirSync(pipelinesDir, { recursive: true })
574
- 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) {
575
564
  const dest = join(pipelinesDir, specName)
576
565
  if (!existsSync(dest)) {
577
566
  const src = join(PACKAGE_ROOT, 'pipelines', specName)
@@ -580,6 +569,12 @@ async function runInit(flags, cwdOverride) {
580
569
  console.log(`Copied default pipeline to ${dest}`)
581
570
  }
582
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
+ }
583
578
  }
584
579
 
585
580
  // 9. Sync all compose-owned skills to detected agents
@@ -757,11 +752,73 @@ async function runUpdate(flags) {
757
752
  console.log(`compose updated to v${getPkgVersion()}${style === 'git' ? ` @ ${getGitSha(root) || '?'}` : ''}`)
758
753
  }
759
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
+
760
816
  // ---------------------------------------------------------------------------
761
817
  // Command dispatch
762
818
  // ---------------------------------------------------------------------------
763
819
 
764
820
  if (cmd === 'init') {
821
+ await emitDriftNudge()
765
822
  await runInit(args)
766
823
  process.exit(0)
767
824
  }
@@ -1161,38 +1218,122 @@ _This is a seed design doc created by \`compose feature\`. The \`compose build\`
1161
1218
  process.exit(0)
1162
1219
  }
1163
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
+
1164
1237
  if (cmd === 'roadmap') {
1165
1238
  const subcmd = args[0]
1166
1239
 
1167
1240
  // compose roadmap generate — regenerate ROADMAP.md from feature.json files,
1168
1241
  // converging to a fixed point before finishing.
1169
1242
  if (subcmd === 'generate' || subcmd === 'gen') {
1170
- const { writeRoadmap } = await import('../lib/roadmap-gen.js')
1171
1243
  const { checkRoundtrip } = await import('../lib/roadmap-roundtrip.js')
1244
+ const { generateRoadmapFromBase } = await import('../lib/roadmap-gen.js')
1172
1245
  const { listFeatures } = await import('../lib/feature-json.js')
1173
- const { loadExternalPrefixes } = await import('../lib/project-paths.js')
1246
+ const { loadExternalPrefixes, resolveRoadmapPath, loadFeaturesDir } = await import('../lib/project-paths.js')
1174
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')
1175
1250
  const { root: cwd } = resolveCwdWithWorkspace(args)
1176
1251
  // 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.
1252
+ // of feature.json never regenerate or overwrite it.
1180
1253
  if (isNarrativeOwned(cwd)) {
1181
1254
  console.log('narrative-owned workspace (roadmap.narrative=true) — ROADMAP.md is hand-authored; generate skipped.')
1182
1255
  process.exit(0)
1183
1256
  }
1184
- 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')
1185
1261
  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`)
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
1195
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}`)
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`)
1196
1337
  process.exit(0)
1197
1338
  }
1198
1339
 
@@ -1592,7 +1733,7 @@ if (cmd === 'record-completion') {
1592
1733
  //
1593
1734
  // Flags:
1594
1735
  // --commit-sha=<sha> required; full 40-char hex SHA (Decision 9)
1595
- // --tests-pass=<bool> default true
1736
+ // --tests-pass=<bool> REQUIRED (true|false) — no default (COMP-COMPLETION-GATE)
1596
1737
  // --notes=<string> optional
1597
1738
  // --files-changed-from-stdin read newline-separated paths from stdin
1598
1739
  // --no-status set_status: false (don't flip status to COMPLETE)
@@ -1637,7 +1778,8 @@ if (cmd === 'record-completion') {
1637
1778
  console.log('')
1638
1779
  console.log('Options:')
1639
1780
  console.log(' --commit-sha=<sha> Full 40-char hex SHA (required)')
1640
- 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)')
1641
1783
  console.log(' --notes=<string> Optional provenance notes')
1642
1784
  console.log(' --files-changed-from-stdin Read newline-separated repo-relative paths from stdin')
1643
1785
  console.log(' --no-status Do not flip feature status to COMPLETE')
@@ -1660,8 +1802,13 @@ if (cmd === 'record-completion') {
1660
1802
  process.exit(1)
1661
1803
  }
1662
1804
 
1663
- // Parse tests-pass: default true
1664
- 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
1665
1812
  if (flags['tests-pass'] !== undefined) {
1666
1813
  const tp = flags['tests-pass']
1667
1814
  if (tp === 'false' || tp === false) testsPass = false
@@ -1698,7 +1845,48 @@ if (cmd === 'record-completion') {
1698
1845
  const { root: cwd } = resolveCwdWithWorkspace(args)
1699
1846
  const { recordCompletion } = await import('../lib/completion-writer.js')
1700
1847
  try {
1701
- 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
+ }
1702
1890
  console.log(JSON.stringify({
1703
1891
  completion_id: result.completion_id,
1704
1892
  idempotent: result.idempotent,
@@ -2142,11 +2330,42 @@ if (cmd === 'guard') {
2142
2330
  else if (st.state === 'stale') console.log('canon-guard: installed (stale — re-run `compose guard install`)')
2143
2331
  else console.log('canon-guard: absent — run `compose guard install`')
2144
2332
  console.log(` hook script: ${scriptState} (${hookScript})`)
2145
- console.log(` guards: docs/judgment/** (Write|Edit|NotebookEdit) Claude-runtime only`)
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)
2146
2365
  process.exit(0)
2147
2366
  }
2148
2367
 
2149
- console.error(`Unknown guard subcommand: "${sub}". Use: install | uninstall | status | init | verify [--fix]`)
2368
+ console.error(`Unknown guard subcommand: "${sub}". Use: install | uninstall | status [--prune] [--coverage] | init | verify [--fix] | enrol | sign | descriptors`)
2150
2369
  process.exit(1)
2151
2370
  }
2152
2371
 
@@ -2357,6 +2576,7 @@ if (cmd === 'experiment') {
2357
2576
  }
2358
2577
 
2359
2578
  if (cmd === 'build') {
2579
+ await emitDriftNudge()
2360
2580
  // Parse --cwd <path> for cross-repo builds
2361
2581
  let agentWorkDir = null
2362
2582
  const cwdIdx = args.indexOf('--cwd')
@@ -2390,6 +2610,29 @@ if (cmd === 'build') {
2390
2610
  console.error('Error: --template requires a name argument')
2391
2611
  process.exit(1)
2392
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
+ }
2393
2636
  templateName = templateValue
2394
2637
  }
2395
2638
  if (teamTemplate && !templateName) {
@@ -2437,10 +2680,16 @@ if (cmd === 'build') {
2437
2680
  }
2438
2681
  }
2439
2682
 
2683
+ const { root: buildCwd } = resolveCwdWithWorkspace(args)
2684
+
2440
2685
  // Multiple codes: compose build FEAT-1 FEAT-2 FEAT-3
2441
2686
  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)
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)
2444
2693
  const isBatch = all || isPrefix || isMulti
2445
2694
 
2446
2695
  if (abort && isBatch) {
@@ -2513,7 +2762,6 @@ if (cmd === 'build') {
2513
2762
  // and there's no bundled preset fallback — so when --quick is requested and
2514
2763
  // that file is absent, treat it as init-needed (runInit re-seeds it) rather
2515
2764
  // than letting runBuild fail later with "Lifecycle spec not found".
2516
- const { root: buildCwd } = resolveCwdWithWorkspace(args)
2517
2765
  const needsInit =
2518
2766
  !existsSync(join(buildCwd, '.compose', 'compose.json')) ||
2519
2767
  !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml')) ||
@@ -2626,7 +2874,11 @@ if (cmd === 'build') {
2626
2874
  const { root: fixCwd } = resolveCwdWithWorkspace(args)
2627
2875
  if (!existsSync(join(fixCwd, '.compose', 'compose.json')) || !existsSync(join(fixCwd, 'pipelines', 'bug-fix.stratum.yaml'))) {
2628
2876
  console.log('Running compose init...\n')
2629
- 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)
2630
2882
  console.log('')
2631
2883
  }
2632
2884
 
@@ -2703,6 +2955,7 @@ if (cmd === 'build') {
2703
2955
  })
2704
2956
  })
2705
2957
  } else if (cmd === 'plan') {
2958
+ await emitDriftNudge()
2706
2959
  // compose plan "<intent>" — runs the plan.stratum.yaml product-planning
2707
2960
  // lifecycle (explore_design → plan → ship). Thin delegation to runBuild() with
2708
2961
  // template='plan', mode='plan'. Derives a PLAN-<slug> code from the intent
@@ -3053,306 +3306,82 @@ if (cmd === 'build') {
3053
3306
  }
3054
3307
  process.exit(1)
3055
3308
  })
3056
- } else if (cmd === 'ideabox') {
3309
+ } else if (cmd === 'judgment') {
3057
3310
  // ---------------------------------------------------------------------------
3058
- // 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.
3059
3314
  // ---------------------------------------------------------------------------
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)
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)
3108
3321
  }
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)
3322
+ const slug = args[1]
3323
+ if (!slug || slug.startsWith('--')) {
3324
+ console.error('Usage: compose judgment trace <slug> [--json]')
3325
+ process.exit(1)
3139
3326
  }
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.')
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))
3149
3333
  process.exit(0)
3150
3334
  }
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}`)
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}`)
3179
3352
  }
3353
+ anc = anc.supersedes; depth++
3180
3354
  }
3355
+ if (t.cycle) console.error(` WARNING: supersession cycle — ${t.cycle}`)
3356
+ for (const w of t.warnings) console.error(` WARNING: ${w}`)
3181
3357
  process.exit(0)
3358
+ } catch (err) {
3359
+ console.error(err?.message || String(err))
3360
+ process.exit(1)
3182
3361
  }
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)
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 {}
3292
3377
  }
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)
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)
3350
3384
  }
3351
-
3352
- console.error(`Unknown ideabox subcommand: ${ibSubcmd}`)
3353
- console.error('Run: compose ideabox --help')
3354
- process.exit(1)
3355
-
3356
3385
  } else if (cmd === 'qa-scope') {
3357
3386
  // ---------------------------------------------------------------------------
3358
3387
  // compose qa-scope <featureCode>
@@ -3477,6 +3506,96 @@ if (cmd === 'build') {
3477
3506
  process.exit(1)
3478
3507
  }
3479
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
+
3480
3599
  } else if (cmd === 'gates' || cmd === 'gate') {
3481
3600
  // ---------------------------------------------------------------------------
3482
3601
  // compose gate list [--item <id>] [--status pending|all|resolved] [--format text|json]