@smartmemory/compose 0.3.6-beta → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,315 @@
1
+ /**
2
+ * lib/ideabox-cli.js — `compose ideabox …`, writing to the record store.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-1 (D18), rebased onto the shared operations in S3b-2
5
+ * (D20).
6
+ *
7
+ * Lifted out of a ~300-line inline `else if` block in `bin/compose.js` with no
8
+ * named symbol, in a 3,400-line file. The move is not cosmetic: it is what lets
9
+ * the cutover be driven in-process by a test instead of only through a
10
+ * subprocess, and the cutover is the part that needed testing most.
11
+ *
12
+ * WHAT THIS MODULE IS NOW
13
+ * -----------------------
14
+ * Argument parsing, presentation, and exit codes. Nothing else. Every mutation
15
+ * lives in `fluid/ideabox-ops.js`, which the REST API calls too — including the
16
+ * two invariants that used to be spelled out here and would have had to be
17
+ * re-remembered by every new surface:
18
+ *
19
+ * 1. the migration gate runs before any write, and
20
+ * 2. the projection is rewritten only after the record is durable.
21
+ *
22
+ * A surface that does not implement an invariant cannot forget it. That is the
23
+ * whole reason the ops module exists; see its header for the failure it prevents.
24
+ *
25
+ * Behaviour is preserved deliberately, including output wording, exit codes and
26
+ * flag parsing, because a cutover that also redesigns the commands cannot be
27
+ * reviewed: any difference becomes ambiguous between "intended" and
28
+ * "regression". The exceptions are documented — `normalizeTags` in the ops
29
+ * module, the killed-idea promote refusal (D22), and the new `resurrect`
30
+ * subcommand (D23).
31
+ */
32
+
33
+ import { existsSync } from 'node:fs';
34
+
35
+ import {
36
+ ideaboxContext,
37
+ addIdea,
38
+ addDiscussion,
39
+ killIdea,
40
+ promoteIdea,
41
+ resurrectIdea,
42
+ setPriority,
43
+ updateIdea,
44
+ IdeaboxConflict,
45
+ IdeaboxInvalid,
46
+ IdeaboxNotFound,
47
+ } from './fluid/ideabox-ops.js';
48
+ import { toMarkdownDate } from './fluid/ideabox-dates.js';
49
+ import { KIND } from './fluid/provider.js';
50
+ import { ensureIdeaboxMigrated } from './fluid/ideabox-migrate.js';
51
+ import { writeIdeaboxProjection } from './fluid/render-ideabox.js';
52
+
53
+ const USAGE = [
54
+ 'Usage: compose ideabox <subcommand>',
55
+ '',
56
+ 'Subcommands:',
57
+ ' add "<title>" Add a new idea',
58
+ ' list List all ideas',
59
+ ' promote <ID> Mark idea as PROMOTED (creates feature folder)',
60
+ ' kill <ID> "<reason>" Move idea to Killed Ideas',
61
+ ' resurrect <ID> Return a killed idea to the live set',
62
+ ' pri <ID> <P0|P1|P2> Set priority',
63
+ ' discuss <ID> "<comment>" Add a discussion comment',
64
+ ' triage [--lens <name>] Walk untriaged ideas and assign priorities',
65
+ ' render Rewrite the ideabox file from the records',
66
+ ];
67
+
68
+ const PRIORITIES = ['P0', 'P1', 'P2'];
69
+ const flagValue = (args, name) => {
70
+ const i = args.indexOf(name);
71
+ return i !== -1 ? args[i + 1] : undefined;
72
+ };
73
+
74
+ /** Status enum → the legacy display label the list output has always used. */
75
+ const displayStatus = (record) => {
76
+ if (record.status === 'promoted') return 'PROMOTED';
77
+ if (record.status === 'discussing') return 'DISCUSSING';
78
+ return 'NEW';
79
+ };
80
+
81
+ const displayPriority = (record) => record.priority ?? '—';
82
+
83
+ /**
84
+ * Turn an op failure into the terminal's contract: a message on stderr and a
85
+ * non-zero exit. Only the ops' own typed failures are handled — anything else
86
+ * is a bug or a broken store, and swallowing those into a tidy exit code is how
87
+ * a corrupt project looks healthy.
88
+ */
89
+ function reportOpFailure(err) {
90
+ if (err instanceof IdeaboxNotFound || err instanceof IdeaboxInvalid || err instanceof IdeaboxConflict) {
91
+ console.error(err.message);
92
+ return 1;
93
+ }
94
+ throw err;
95
+ }
96
+
97
+ /**
98
+ * @param {string} cwd project root
99
+ * @param {string[]} args argv after `ideabox`
100
+ * @param {object} [opts]
101
+ * @param {object} [opts.config] already-loaded `.compose/compose.json`
102
+ * @returns {Promise<number>} process exit code
103
+ */
104
+ export async function runIdeaboxCommand(cwd, args, opts = {}) {
105
+ const sub = args[0];
106
+ if (!sub || sub === '--help' || sub === '-h') {
107
+ for (const line of USAGE) console.log(line);
108
+ return 0;
109
+ }
110
+
111
+ const ctx = await ideaboxContext(cwd, { config: opts.config, origin: 'cli:ideabox' });
112
+ const { provider, ideaboxPath } = ctx;
113
+
114
+ try {
115
+ switch (sub) {
116
+ case 'add': {
117
+ const title = args.slice(1).find((a) => !a.startsWith('-')) || args[1];
118
+ if (!title) {
119
+ console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]');
120
+ return 1;
121
+ }
122
+ const { record, createdCluster } = await addIdea(ctx, {
123
+ title,
124
+ body: flagValue(args, '--desc') ?? '',
125
+ source: flagValue(args, '--source') ?? '',
126
+ tags: flagValue(args, '--tags') ?? [],
127
+ cluster: flagValue(args, '--cluster') ?? null,
128
+ });
129
+ if (createdCluster) console.log(`Created cluster ${createdCluster.handle}: ${createdCluster.title}`);
130
+ console.log(`Added ${record.handle}: ${record.title}`);
131
+ return 0;
132
+ }
133
+
134
+ case 'list': {
135
+ // Read-only paths still migrate: `list` on an upgraded project must show
136
+ // the user's real ideas, not an empty store. It is the same import
137
+ // either way, and running it lazily on first read rather than first
138
+ // write means the upgrade happens at the least destructive moment
139
+ // available.
140
+ if (!existsSync(ideaboxPath) && !(await provider.listRecords({ kind: KIND.IDEA })).length) {
141
+ console.log('No ideabox found. Run: compose ideabox add "<title>"');
142
+ return 0;
143
+ }
144
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
145
+ const ideas = await provider.listRecords({ kind: KIND.IDEA });
146
+ if (ideas.length === 0) {
147
+ console.log('No ideas yet.');
148
+ return 0;
149
+ }
150
+
151
+ const live = ideas.filter((i) => i.status !== 'killed');
152
+ const killed = ideas.filter((i) => i.status === 'killed');
153
+ const order = { P0: 0, P1: 1, P2: 2, '—': 3 };
154
+
155
+ for (const status of ['NEW', 'DISCUSSING', 'PROMOTED']) {
156
+ const group = live.filter((i) => displayStatus(i) === status);
157
+ if (!group.length) continue;
158
+ group.sort((a, b) => (order[displayPriority(a)] ?? 3) - (order[displayPriority(b)] ?? 3));
159
+ console.log(`\n[${status}]`);
160
+ for (const idea of group) {
161
+ const pri = displayPriority(idea) !== '—' ? ` [${displayPriority(idea)}]` : '';
162
+ const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : '';
163
+ console.log(` ${idea.handle}${pri} ${idea.title}${tags}`);
164
+ }
165
+ }
166
+
167
+ if (killed.length) {
168
+ console.log(`\n[KILLED] (${killed.length})`);
169
+ for (const idea of killed) {
170
+ console.log(` ${idea.handle} ${idea.title} — ${idea.killed?.reason ?? ''}`);
171
+ }
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ case 'promote': {
177
+ const id = args[1];
178
+ if (!id) {
179
+ console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]');
180
+ return 1;
181
+ }
182
+ const { record, featureCode, featurePath, createdFeature } =
183
+ await promoteIdea(ctx, id, args[2] || '');
184
+ if (createdFeature) console.log(`Created feature folder: ${featurePath}/`);
185
+ console.log(`Promoted ${record.handle} → ${featureCode}`);
186
+ return 0;
187
+ }
188
+
189
+ case 'kill': {
190
+ const id = args[1];
191
+ if (!id) {
192
+ console.error('Usage: compose ideabox kill <ID> "<reason>"');
193
+ return 1;
194
+ }
195
+ const reason = args[2] || '';
196
+ const { record, alreadyKilled } = await killIdea(ctx, id, reason);
197
+ if (alreadyKilled) {
198
+ console.log(`${record.handle} was already killed on ${toMarkdownDate(record.killed?.at)}: ${record.killed?.reason ?? ''}`);
199
+ } else {
200
+ console.log(`Killed ${record.handle}: ${reason}`);
201
+ }
202
+ return 0;
203
+ }
204
+
205
+ case 'resurrect': {
206
+ const id = args[1];
207
+ if (!id) {
208
+ console.error('Usage: compose ideabox resurrect <ID>');
209
+ return 1;
210
+ }
211
+ const { record } = await resurrectIdea(ctx, id);
212
+ console.log(`Resurrected ${record.handle}: ${record.title}`);
213
+ return 0;
214
+ }
215
+
216
+ case 'pri': {
217
+ const id = args[1];
218
+ const priority = args[2];
219
+ if (!id || !priority) {
220
+ console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>');
221
+ return 1;
222
+ }
223
+ if (!PRIORITIES.includes(priority.toUpperCase()) && priority !== '—') {
224
+ console.error(`Invalid priority: ${priority}. Use P0, P1, P2 or —`);
225
+ return 1;
226
+ }
227
+ const { record } = await setPriority(ctx, id, priority);
228
+ console.log(`Set ${record.handle} priority → ${priority}`);
229
+ return 0;
230
+ }
231
+
232
+ case 'discuss': {
233
+ const id = args[1];
234
+ const comment = args[2];
235
+ if (!id || !comment) {
236
+ console.error('Usage: compose ideabox discuss <ID> "<comment>"');
237
+ return 1;
238
+ }
239
+ await addDiscussion(ctx, id, { text: comment, author: 'human' });
240
+ console.log(`[${new Date().toISOString().slice(0, 10)}] human: ${comment}`);
241
+ return 0;
242
+ }
243
+
244
+ case 'triage': {
245
+ const lensName = flagValue(args, '--lens');
246
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
247
+ const ideas = await provider.listRecords({ kind: KIND.IDEA });
248
+ const untriaged = ideas.filter((i) => !i.priority && i.status === 'new');
249
+ if (!untriaged.length) {
250
+ console.log('No untriaged ideas.');
251
+ return 0;
252
+ }
253
+
254
+ if (lensName) {
255
+ const { loadLens } = await import('./ideabox.js');
256
+ if (!loadLens(cwd, lensName)) {
257
+ console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`);
258
+ } else {
259
+ console.log(`Using lens: ${lensName}`);
260
+ }
261
+ }
262
+
263
+ const { createInterface } = await import('node:readline');
264
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
265
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
266
+
267
+ let changed = false;
268
+ try {
269
+ for (const idea of untriaged) {
270
+ console.log(`\n${idea.handle}: ${idea.title}`);
271
+ if (idea.body) console.log(` ${idea.body.slice(0, 120)}`);
272
+ if (lensName) console.log(` [lens: ${lensName}]`);
273
+ const answer = (await ask(' Priority [P0/P1/P2/skip]: ')).trim().toUpperCase();
274
+ if (PRIORITIES.includes(answer)) {
275
+ // The record write only. Triage is a bulk pass, so it renders once
276
+ // at the end rather than rewriting the whole file per answer.
277
+ await provider.updateRecord(idea.handle, { priority: answer });
278
+ changed = true;
279
+ console.log(` Set ${idea.handle} → ${answer}`);
280
+ } else {
281
+ console.log(' Skipped');
282
+ }
283
+ }
284
+ } finally {
285
+ rl.close();
286
+ }
287
+
288
+ if (changed) {
289
+ await writeIdeaboxProjection(provider, ideaboxPath);
290
+ console.log('\nSaved.');
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ case 'render': {
296
+ // The repair path. Every mutation writes the record before the
297
+ // projection, so a failed render leaves durable canon and a stale file;
298
+ // this is how the file catches up without touching a record. Also the
299
+ // fix for a projection that was interrupted, and the way back from any
300
+ // hand edit.
301
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
302
+ await writeIdeaboxProjection(provider, ideaboxPath);
303
+ console.log(`Rendered ${ideaboxPath}`);
304
+ return 0;
305
+ }
306
+
307
+ default:
308
+ console.error(`Unknown ideabox subcommand: ${sub}`);
309
+ console.error('Run: compose ideabox --help');
310
+ return 1;
311
+ }
312
+ } catch (err) {
313
+ return reportOpFailure(err);
314
+ }
315
+ }
package/lib/ideabox.js CHANGED
@@ -51,7 +51,22 @@ const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
51
51
  const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
52
52
 
53
53
  // Matches a discussion entry: - [2026-04-10] author: text
54
- const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+(\w+):\s+(.+)$/
54
+ //
55
+ // The author is "everything up to the first colon", not `\w+`.
56
+ //
57
+ // COMP-FLUID-SEAM-GUARANTEES F7-1. `\w+` matched no real person's name: an entry
58
+ // written by `Jane Doe` rendered correctly into the file and then parsed to
59
+ // ZERO discussion entries, silently, taking the comment with it and breaking the
60
+ // `serialize(parse(projection))` fixed point the cutover rests on. It was
61
+ // unreachable while the CLI was the only writer (it always writes `human`) and
62
+ // became reachable the moment the REST API accepted an author from a request
63
+ // body (COMP-PLAN-IDEA-UNIFY S3b-2).
64
+ //
65
+ // Lazy, so the FIRST colon delimits: an author cannot contain one (the contract
66
+ // forbids it, since the file could not represent it), while the comment text
67
+ // routinely does — `- [2026-08-05] human: see this: it matters` keeps the whole
68
+ // sentence.
69
+ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
55
70
 
56
71
  // ---------------------------------------------------------------------------
57
72
  // parseIdeabox(markdown) → { ideas, killed, nextId }
@@ -67,11 +82,22 @@ export function parseIdeabox(markdown) {
67
82
 
68
83
  const ideas = []
69
84
  const killed = []
85
+ // Cluster-scoped data. An umbrella heading carries a hand-authored multi-
86
+ // sentence `**Theme:**` paragraph, which is information, not decoration —
87
+ // before this was captured it sat between the H3 and the first H4 where the
88
+ // loop had no `currentIdea`, so it was silently dropped and every
89
+ // parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
90
+ const clusters = []
91
+ const clusterIndex = new Map()
92
+ // Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
93
+ // instead of preserving it drops hand-authored convention bullets.
94
+ const preambleLines = []
70
95
 
71
96
  let inIdeasSection = false
72
97
  let inKilledSection = false
73
98
  let currentCluster = null
74
99
  let currentIdea = null
100
+ let seenAnySection = false
75
101
 
76
102
  function flushCurrentIdea() {
77
103
  if (!currentIdea) return
@@ -93,6 +119,7 @@ export function parseIdeabox(markdown) {
93
119
  flushCurrentIdea()
94
120
  inIdeasSection = true
95
121
  inKilledSection = false
122
+ seenAnySection = true
96
123
  currentCluster = null
97
124
  continue
98
125
  }
@@ -100,6 +127,7 @@ export function parseIdeabox(markdown) {
100
127
  flushCurrentIdea()
101
128
  inIdeasSection = false
102
129
  inKilledSection = true
130
+ seenAnySection = true
103
131
  currentCluster = null
104
132
  continue
105
133
  }
@@ -108,18 +136,48 @@ export function parseIdeabox(markdown) {
108
136
  flushCurrentIdea()
109
137
  inIdeasSection = false
110
138
  inKilledSection = false
139
+ // Still part of the preamble when it precedes the first real section —
140
+ // `## Conventions` lives here and was being dropped on the floor.
141
+ if (!seenAnySection) preambleLines.push(line)
111
142
  continue
112
143
  }
113
144
 
114
- if (!inIdeasSection && !inKilledSection) continue
145
+ if (!inIdeasSection && !inKilledSection) {
146
+ // Preamble = everything before the first section heading.
147
+ if (!seenAnySection) preambleLines.push(line)
148
+ continue
149
+ }
150
+
151
+ // Horizontal rules inside the ideas section are structural separators
152
+ // between umbrellas. They are regenerated by the serializer from the
153
+ // cluster list, so capturing them here would attach each one to the
154
+ // PRECEDING idea's extra lines and duplicate every rule on write.
155
+ if (/^---\s*$/.test(line)) {
156
+ continue
157
+ }
115
158
 
116
159
  // H3 = cluster heading
117
160
  if (/^###\s/.test(line)) {
118
161
  flushCurrentIdea()
119
162
  currentCluster = line.replace(/^###\s+/, '').trim()
163
+ if (!clusterIndex.has(currentCluster)) {
164
+ const entry = { name: currentCluster, theme: '', order: clusters.length }
165
+ clusters.push(entry)
166
+ clusterIndex.set(currentCluster, entry)
167
+ }
120
168
  continue
121
169
  }
122
170
 
171
+ // Cluster-scoped `**Theme:**` paragraph — appears after the H3 and before
172
+ // the first H4, i.e. exactly where there is no current idea to attach it to.
173
+ if (currentCluster && !currentIdea) {
174
+ const themeMatch = line.match(/^\*\*Theme:\*\*\s*(.*)$/)
175
+ if (themeMatch) {
176
+ clusterIndex.get(currentCluster).theme = themeMatch[1].trim()
177
+ continue
178
+ }
179
+ }
180
+
123
181
  // H4 = idea heading
124
182
  const headingMatch = line.match(IDEA_HEADING_RE)
125
183
  if (headingMatch) {
@@ -211,7 +269,11 @@ export function parseIdeabox(markdown) {
211
269
  const maxNum = allNums.length ? Math.max(...allNums) : 0
212
270
  const nextId = maxNum + 1
213
271
 
214
- return { ideas, killed, nextId }
272
+ // Trailing blank lines before `## Ideas` are structural, not content — the
273
+ // serializer re-adds the separator itself.
274
+ while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
275
+
276
+ return { ideas, killed, nextId, clusters, preamble: preambleLines.join('\n') }
215
277
  }
216
278
 
217
279
  function extractStatus(raw) {
@@ -232,7 +294,17 @@ function applyField(idea, key, val) {
232
294
  idea.priority = val.replace(/`/g, '').trim() || '—'
233
295
  break
234
296
  case 'Tags':
235
- idea.tags = (val.match(/#\w+/g) || [])
297
+ // Accepts BOTH spellings. The documented convention is `` `#ux` ``, but
298
+ // the real ideabox writes bare words (`stratum integrity research-
299
+ // influence`), and the old `/#\w+/g` matched none of them — so all 20
300
+ // ideas parsed with zero tags and every CLI mutation stripped the lot.
301
+ // Tokens are kept verbatim (a leading `#` is preserved, never added) so
302
+ // whichever spelling a file uses survives a round-trip unchanged.
303
+ idea.tags = val
304
+ .replace(/`/g, ' ')
305
+ .trim()
306
+ .split(/\s+/)
307
+ .filter(Boolean)
236
308
  break
237
309
  case 'Source':
238
310
  idea.source = val
@@ -280,28 +352,42 @@ function applyField(idea, key, val) {
280
352
  * @param {{ ideas: IdeaEntry[], killed: IdeaEntry[], nextId: number }} parsedData
281
353
  * @returns {string}
282
354
  */
283
- export function serializeIdeabox({ ideas, killed }) {
355
+ export function serializeIdeabox({ ideas, killed, clusters: clusterMeta = [], preamble = '' }) {
284
356
  const lines = []
285
357
 
286
- lines.push('# Ideabox')
287
- lines.push('')
288
- lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
289
- lines.push('')
290
- lines.push('## Conventions')
291
- lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
292
- lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
293
- lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
294
- lines.push('- **Source:** Where the idea came from')
295
- lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
296
- lines.push('')
358
+ if (preamble) {
359
+ // Round-trip the author's own preamble. Regenerating it from the template
360
+ // silently deletes any convention the project added (the real ideabox
361
+ // documents an `**Umbrella:**` rule the template has never known about).
362
+ lines.push(...preamble.split('\n'))
363
+ lines.push('')
364
+ } else {
365
+ lines.push('# Ideabox')
366
+ lines.push('')
367
+ lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
368
+ lines.push('')
369
+ lines.push('## Conventions')
370
+ lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
371
+ lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
372
+ lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
373
+ lines.push('- **Source:** Where the idea came from')
374
+ lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
375
+ lines.push('')
376
+ }
297
377
  lines.push('## Ideas')
298
378
  lines.push('')
299
- lines.push('<!-- Ideas grouped by potential feature cluster -->')
300
- lines.push('')
379
+ if (!clusterMeta.length) {
380
+ lines.push('<!-- Ideas grouped by potential feature cluster -->')
381
+ lines.push('')
382
+ }
301
383
 
302
384
  // Group active ideas by cluster
303
385
  const clusters = new Map()
304
386
  const unclustered = []
387
+ // Seed in declared cluster order so an empty cluster keeps its place and its
388
+ // theme rather than disappearing.
389
+ for (const c of clusterMeta) clusters.set(c.name, [])
390
+ const themeOf = new Map(clusterMeta.map((c) => [c.name, c.theme]))
305
391
  for (const idea of ideas) {
306
392
  if (idea.cluster) {
307
393
  if (!clusters.has(idea.cluster)) clusters.set(idea.cluster, [])
@@ -312,8 +398,17 @@ export function serializeIdeabox({ ideas, killed }) {
312
398
  }
313
399
 
314
400
  for (const [cluster, clusterIdeas] of clusters) {
401
+ if (clusterMeta.length) {
402
+ lines.push('---')
403
+ lines.push('')
404
+ }
315
405
  lines.push(`### ${cluster}`)
316
406
  lines.push('')
407
+ const theme = themeOf.get(cluster)
408
+ if (theme) {
409
+ lines.push(`**Theme:** ${theme}`)
410
+ lines.push('')
411
+ }
317
412
  for (const idea of clusterIdeas) {
318
413
  lines.push(...serializeIdea(idea))
319
414
  }
@@ -348,14 +443,19 @@ function serializeIdea(idea) {
348
443
 
349
444
  if (idea.source) out.push(`**Source:** ${idea.source}`)
350
445
  if (idea.description) out.push(`**Idea:** ${idea.description}`)
351
- if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
352
- if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
353
- if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
354
446
 
447
+ // Unrecognized field lines are emitted here, BEFORE the trailing known
448
+ // fields, because that is where they occur in practice: an idea that carries
449
+ // custom `**Idea (original):**` / `**Re-aim:**` blocks ends with `**Maps
450
+ // to:**`, and emitting extras last reordered the file on every write.
355
451
  for (const extra of (idea._extraLines || [])) {
356
452
  out.push(extra)
357
453
  }
358
454
 
455
+ if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
456
+ if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
457
+ if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
458
+
359
459
  // Discussion thread
360
460
  if (idea.discussion && idea.discussion.length > 0) {
361
461
  out.push('**Discussion:**')