@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
@@ -144,6 +144,7 @@ export async function normalizeReviewResult(rawText, {
144
144
  confidenceGate = 7,
145
145
  lens = 'general',
146
146
  repairFn,
147
+ onRepairUsed,
147
148
  } = {}) {
148
149
  // Step 1: Try direct JSON parse
149
150
  let parsed = parseReviewJson(rawText);
@@ -158,6 +159,9 @@ export async function normalizeReviewResult(rawText, {
158
159
 
159
160
  if (repairText) {
160
161
  parsed = parseReviewJson(repairText);
162
+ // Only a parseable repair actually replaces the primary output — the
163
+ // caller uses this to decide which dispatch gets credited.
164
+ if (parsed && typeof onRepairUsed === 'function') onRepairUsed();
161
165
  }
162
166
  }
163
167
 
@@ -0,0 +1,65 @@
1
+ /**
2
+ * roadmap-errors.js — typed errors for the ROADMAP.md write path
3
+ * (COMP-CONFLICT-MERGE).
4
+ *
5
+ * Lives in its own module so both `roadmap-preservers.js` (which raises the
6
+ * unbalanced-marker error) and `roadmap-residue.js` (which raises the prose-loss
7
+ * error) can import them without an import cycle.
8
+ */
9
+
10
+ /**
11
+ * Regenerating ROADMAP.md would drop hand-authored content the generator cannot
12
+ * account for. Carries the lost lines with their nearest heading and a
13
+ * remediation naming the exact marker to add. This is the typed-error shape
14
+ * IDEA-2 asks for, scoped to one writer.
15
+ */
16
+ export class RoadmapProseLossError extends Error {
17
+ /** @param {Array<{lineNo:number, text:string, nearestHeading:string|null}>} lines */
18
+ constructor(lines) {
19
+ super(`ROADMAP.md regeneration would drop ${lines.length} hand-authored line(s)`);
20
+ this.name = 'RoadmapProseLossError';
21
+ this.code = 'ROADMAP_PROSE_LOSS';
22
+ this.lines = lines;
23
+ this.source_of_truth = 'ROADMAP.md';
24
+ this.remediation =
25
+ 'Wrap the lost lines in <!-- preserved-section: <id> --> … <!-- /preserved-section -->, ' +
26
+ 'or re-run with --protect to do that automatically, or --accept-loss to write anyway.';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * A preserved-section open marker with no matching close. Left unfixed, the
32
+ * content it was meant to protect is silently discarded on regen — a typo'd
33
+ * close deletes exactly what it was guarding.
34
+ */
35
+ export class RoadmapUnbalancedMarkerError extends Error {
36
+ /** @param {Array<{id:string, lineNo:number}>} markers */
37
+ constructor(markers) {
38
+ super(`ROADMAP.md has ${markers.length} unbalanced preserved-section marker(s)`);
39
+ this.name = 'RoadmapUnbalancedMarkerError';
40
+ this.code = 'ROADMAP_UNBALANCED_MARKER';
41
+ this.markers = markers;
42
+ this.source_of_truth = 'ROADMAP.md';
43
+ this.remediation =
44
+ 'Add a matching <!-- /preserved-section --> close for each open marker listed above.';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Two preserved-sections share an id. They are keyed by id in a Map, so the
50
+ * second overwrites the first and its content is dropped on regen — and because
51
+ * the content is inside markers, the residue check treats it as safe and never
52
+ * flags the loss. Fail loud instead.
53
+ */
54
+ export class RoadmapDuplicateMarkerError extends Error {
55
+ /** @param {Array<{id:string, lineNo:number}>} markers the DUPLICATE occurrences */
56
+ constructor(markers) {
57
+ super(`ROADMAP.md has ${markers.length} duplicate preserved-section id(s)`);
58
+ this.name = 'RoadmapDuplicateMarkerError';
59
+ this.code = 'ROADMAP_DUPLICATE_MARKER';
60
+ this.markers = markers;
61
+ this.source_of_truth = 'ROADMAP.md';
62
+ this.remediation =
63
+ 'Give each preserved-section a unique id — a repeated id silently drops all but the last block.';
64
+ }
65
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
16
16
  import { splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
17
+ import { RoadmapUnbalancedMarkerError, RoadmapDuplicateMarkerError } from './roadmap-errors.js';
17
18
 
18
19
  const FENCE_RE = /^```/;
19
20
  const TABLE_HEADER_RE = /^\|.*\|$/;
@@ -187,17 +188,26 @@ export function readAnonymousRows(text) {
187
188
  * Scan ROADMAP.md text and return a Map of preserved-section id → rawSource.
188
189
  *
189
190
  * rawSource includes both open and close markers and everything between.
190
- * Markers inside fenced code blocks are ignored. Unbalanced markers (open
191
- * without matching close) are excluded.
191
+ * Markers inside fenced code blocks are ignored.
192
+ *
193
+ * Unbalanced markers (open without matching close): with `strict: false`
194
+ * (default) they are excluded, matching every existing reader/parser call site.
195
+ * With `strict: true` — used by the CLI write path (COMP-CONFLICT-MERGE) — they
196
+ * raise `RoadmapUnbalancedMarkerError` instead of being silently dropped, because
197
+ * a typo'd close otherwise deletes exactly the content it was meant to protect.
192
198
  *
193
199
  * @param {string} text
200
+ * @param {object} [opts]
201
+ * @param {boolean} [opts.strict=false] raise on any unbalanced open marker
194
202
  * @returns {Map<string, string>}
195
203
  */
196
- export function readPreservedSections(text) {
204
+ export function readPreservedSections(text, { strict = false } = {}) {
197
205
  const out = new Map();
198
206
  let inFence = false;
199
207
  let openId = null;
200
208
  let openLineIdx = -1;
209
+ const unbalanced = [];
210
+ const duplicates = [];
201
211
  const lines = text.split('\n');
202
212
 
203
213
  // Track byte offsets to slice rawSource.
@@ -218,11 +228,16 @@ export function readPreservedSections(text) {
218
228
 
219
229
  const openMatch = line.match(PRESERVED_OPEN_RE);
220
230
  if (openMatch) {
231
+ // A new open before the previous one closed leaves the previous unbalanced.
232
+ if (openId !== null) unbalanced.push({ id: openId, lineNo: openLineIdx + 1 });
221
233
  openId = openMatch[1];
222
234
  openLineIdx = i;
223
235
  continue;
224
236
  }
225
237
  if (openId !== null && PRESERVED_CLOSE_RE.test(line)) {
238
+ // A second balanced section with the same id overwrites the first in the
239
+ // Map — silently dropping it on regen. Record the duplicate occurrence.
240
+ if (out.has(openId)) duplicates.push({ id: openId, lineNo: openLineIdx + 1 });
226
241
  const startOffset = lineOffsets[openLineIdx];
227
242
  const endOffset = lineOffsets[i + 1] - 1; // exclude trailing newline of close marker line
228
243
  out.set(openId, text.slice(startOffset, endOffset));
@@ -230,7 +245,12 @@ export function readPreservedSections(text) {
230
245
  openLineIdx = -1;
231
246
  }
232
247
  }
233
- // Unbalanced open is silently dropped (could log, but tests expect empty/missing).
248
+ if (openId !== null) unbalanced.push({ id: openId, lineNo: openLineIdx + 1 });
249
+ // Default: silently drop unbalanced opens / collapse duplicate ids (existing
250
+ // readers/parsers rely on this). Strict (CLI write path): raise, since either
251
+ // loses data.
252
+ if (strict && unbalanced.length > 0) throw new RoadmapUnbalancedMarkerError(unbalanced);
253
+ if (strict && duplicates.length > 0) throw new RoadmapDuplicateMarkerError(duplicates);
234
254
  return out;
235
255
  }
236
256
 
@@ -0,0 +1,299 @@
1
+ /**
2
+ * roadmap-residue.js — detect hand-authored content a ROADMAP.md regeneration
3
+ * would drop (COMP-CONFLICT-MERGE).
4
+ *
5
+ * `compose roadmap generate` renders feature rows from feature.json and preserves
6
+ * curated content through a whitelist of six readers. Content matching none of
7
+ * them is dropped, and the row-level losslessness check (`checkRoundtrip`) is
8
+ * blind to prose — so a curated block can vanish while the tool reports
9
+ * "lossless: true".
10
+ *
11
+ * Residue = a line present in the base text, non-trivial, absent from the
12
+ * generated output, and not explained by a legitimate regeneration (a feature
13
+ * row rewritten, a table header re-emitted, a phase heading whose status flipped).
14
+ *
15
+ * Compare the base against the FINAL canonical bytes (after all generation
16
+ * passes), never the first pass: the duplicate-heading loss only manifests on a
17
+ * later pass, so a first-pass diff passes cleanly and then writes the lossy file.
18
+ */
19
+
20
+ import { PRESERVED_OPEN_RE, PRESERVED_CLOSE_RE } from './roadmap-preservers.js';
21
+ import { splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
22
+ import { isFeatureCode } from './feature-code.js';
23
+ import { detectColumnLayout, splitRoadmapCells } from './roadmap-parser.js';
24
+ import { RoadmapProseLossError } from './roadmap-errors.js';
25
+
26
+ export { RoadmapProseLossError };
27
+
28
+ const FENCE_RE = /^```/;
29
+ const TABLE_ROW_RE = /^\|.+\|$/;
30
+ const HEADING_RE = /^#{1,6}\s/;
31
+ const DIVIDER_CELLS = (cells) => cells.length > 0 && cells.every((c) => /^[-:]+$/.test(c));
32
+
33
+ // buildKeyDocs (roadmap-gen.js) renders the `## Key Documents` table from feature
34
+ // designDocs: a `| Document | What it is |` header, a divider, then rows of the
35
+ // exact shape `| `<path>` | <CODE> design |`. Those lines are GENERATOR-OWNED — a
36
+ // designDoc change legitimately rewrites them — so they must never count as
37
+ // hand-authored loss, or `--protect` would freeze a stale generated link and
38
+ // default generate would refuse a legitimate regeneration.
39
+ //
40
+ // The row is generator-owned ONLY when its code is a REAL current feature code —
41
+ // identity, not shape. Shape alone is ambiguous: a curated row like
42
+ // `| `.../spec` | API-1 design |` looks identical to a generated one, so shape
43
+ // matching would silently drop it. Keying on the actual feature set means a
44
+ // curated row that references a non-feature "code" stays eligible (design path-2
45
+ // preserved), while a real feature's row — even after its designDoc changes — is
46
+ // correctly treated as the generator's. When the caller cannot supply the feature
47
+ // set (featureCodes = null), fall back to the shape check (looser, used only by
48
+ // pure-unit callers that control their inputs).
49
+ const KEY_DOCS_BLOCK = 'Key Documents';
50
+ const KEY_DOCS_HEADER_RE = /^\|\s*Document\s*\|\s*What it is\s*\|$/;
51
+ const TABLE_DIVIDER_RE = /^\|[\s|:-]+\|$/;
52
+ function isGeneratedKeyDocRow(text, featureCodes) {
53
+ const cells = splitRoadmapCells(text);
54
+ if (cells.length !== 2) return false;
55
+ if (!/^`[^`]+`$/.test(cells[0])) return false;
56
+ const m = cells[1].match(/^(\S+)\s+design$/);
57
+ if (!m || !isFeatureCode(m[1])) return false;
58
+ return featureCodes ? featureCodes.has(m[1]) : true;
59
+ }
60
+ const isGeneratedKeyDoc = (block, text, featureCodes) =>
61
+ block === KEY_DOCS_BLOCK &&
62
+ (KEY_DOCS_HEADER_RE.test(text) || TABLE_DIVIDER_RE.test(text) || isGeneratedKeyDocRow(text, featureCodes));
63
+
64
+ /**
65
+ * Stable block id for a heading line. A `## ` phase heading keys on its TITLE
66
+ * with the status suffix stripped, so a status flip (`— PLANNED` → `— COMPLETE`)
67
+ * does not re-key the prose beneath it and manufacture false residue. Any other
68
+ * heading keys on its trimmed text.
69
+ */
70
+ function blockIdForHeading(trimmed) {
71
+ const m = trimmed.match(PHASE_HEADING_TEXT_RE);
72
+ if (m && trimmed.startsWith('## ')) return splitPhaseHeading(m[1]).title;
73
+ return trimmed.replace(/^#{1,6}\s*/, '').trim();
74
+ }
75
+
76
+ /**
77
+ * Classify every line of a ROADMAP.md text, tracking the nearest heading (its
78
+ * stable block id) and table state. Only `kind: 'other'` lines — hand-authored
79
+ * prose, curated non-feature table rows — are eligible to be residue; a heading
80
+ * becomes eligible separately, when its phase no longer exists in the output.
81
+ *
82
+ * @returns {Array<{lineNo:number, text:string, block:string|null, kind:string}>}
83
+ * kind ∈ blank | fence | structural | heading | tableHeader | tableDivider | featureRow | other
84
+ */
85
+ function classifyLines(text) {
86
+ const lines = text.split('\n');
87
+ const out = [];
88
+ let inFence = false;
89
+ let inPreserved = false;
90
+ let block = null;
91
+ let inTable = false;
92
+ let columnLayout = null;
93
+ let curatedTable = false; // a non-feature table the writer does NOT regenerate
94
+ const endTable = () => { inTable = false; columnLayout = null; curatedTable = false; };
95
+
96
+ for (let i = 0; i < lines.length; i++) {
97
+ const raw = lines[i];
98
+ const trimmed = raw.trim();
99
+ const push = (kind) => out.push({ lineNo: i + 1, text: trimmed, block, kind });
100
+
101
+ if (FENCE_RE.test(raw)) { inFence = !inFence; endTable(); push('fence'); continue; }
102
+ if (inFence) { push('other'); continue; } // fenced prose survives only if re-emitted; treat as eligible
103
+
104
+ // Preserved-section content is emitted verbatim by the writer, so it always
105
+ // survives — never residue. Marker lines and their content are structural.
106
+ // (Duplicate/unbalanced ids, which DO lose content, are caught by the
107
+ // strict preserved-section guard before this check runs.)
108
+ if (PRESERVED_OPEN_RE.test(raw)) { inPreserved = true; endTable(); push('structural'); continue; }
109
+ if (PRESERVED_CLOSE_RE.test(raw)) { inPreserved = false; push('structural'); continue; }
110
+ if (inPreserved) { push('structural'); continue; }
111
+
112
+ if (HEADING_RE.test(trimmed)) {
113
+ block = blockIdForHeading(trimmed);
114
+ endTable();
115
+ const level = (trimmed.match(/^#+/)[0]).length;
116
+ out.push({ lineNo: i + 1, text: trimmed, block, kind: 'heading', level });
117
+ continue;
118
+ }
119
+ if (trimmed === '') { push('blank'); continue; }
120
+ if (trimmed === '---') { push('structural'); continue; }
121
+
122
+ if (TABLE_ROW_RE.test(trimmed)) {
123
+ const cells = splitRoadmapCells(trimmed);
124
+ if (!inTable && !DIVIDER_CELLS(cells)) {
125
+ // Header row. A feature/id-coded table (codeCol !== -1) is regenerated
126
+ // verbatim, so header + divider + rows are the generator's. A curated
127
+ // non-feature table (codeCol === -1, e.g. `| Document | ... |`) is
128
+ // hand-authored — its header and divider must travel with the block, so
129
+ // they are eligible, otherwise --protect wraps rows into a headerless,
130
+ // orphaned table and reports success.
131
+ inTable = true;
132
+ columnLayout = detectColumnLayout(cells);
133
+ curatedTable = !columnLayout || columnLayout.codeCol === -1;
134
+ push(curatedTable ? 'other' : 'tableHeader');
135
+ continue;
136
+ }
137
+ if (DIVIDER_CELLS(cells)) { push(curatedTable ? 'other' : 'tableDivider'); continue; }
138
+ // Data row: a resolvable feature code in a feature table is the generator's.
139
+ if (!curatedTable && columnLayout && isFeatureCode(cells[columnLayout.codeCol] ?? '')) {
140
+ push('featureRow'); continue;
141
+ }
142
+ push('other'); continue; // anonymous row / curated non-feature table row
143
+ }
144
+
145
+ endTable();
146
+ push('other');
147
+ }
148
+ return out;
149
+ }
150
+
151
+ const keyOf = (block, text) => `${block ?? ''}\u0000${text}`;
152
+
153
+ /**
154
+ * Lines present in `baseText` that `candidateText` fails to carry over.
155
+ *
156
+ * Occurrence-aware: membership ("does this line appear anywhere") cannot detect
157
+ * losing ONE of two identical lines, which is exactly the duplicate-block case.
158
+ * Keyed by (containing block, line text) and compared by count, so losing one
159
+ * occurrence of a repeated heading or curated row is still residue.
160
+ *
161
+ * @param {string} baseText
162
+ * @param {string} candidateText the FINAL canonical bytes that will be written
163
+ * @param {object} [opts]
164
+ * @param {Set<string>} [opts.featureCodes] current feature codes, so generator-owned
165
+ * Key Documents rows are recognised by identity rather than shape. Omit only from
166
+ * pure-unit callers that control their inputs.
167
+ * @returns {Array<{lineNo:number, text:string, nearestHeading:string|null}>}
168
+ */
169
+ export function computeResidue(baseText, candidateText, opts = {}) {
170
+ const featureCodes = opts.featureCodes ?? null;
171
+ const isGenKeyDoc = (block, text) => isGeneratedKeyDoc(block, text, featureCodes);
172
+ const baseRecs = classifyLines(baseText);
173
+ const candRecs = classifyLines(candidateText);
174
+
175
+ // Headings are handled by LEVEL:
176
+ // - `#`/`##` (phase-level): membership by stable block id. The generator
177
+ // INTENTIONALLY collapses duplicate `##` phase headings (self-healing
178
+ // dedupe), so occurrence-counting them false-positives on normalization and
179
+ // would make --protect wrap a phase heading and destroy it. A phase heading
180
+ // is residue only when its whole section vanished; lost content beneath a
181
+ // collapsed duplicate is still caught as prose residue.
182
+ // - `###`+ (sub-headings): NOT deduped by the generator (preserved verbatim in
183
+ // their phase block), so losing one is real loss — occurrence-count by
184
+ // (block, text) to catch a bare sub-heading dropped when an identically
185
+ // named one survives elsewhere.
186
+ const candidatePhaseBlocks = new Set();
187
+ const candSubHeadingCount = new Map();
188
+ const candCount = new Map(); // ELIGIBLE prose lines keyed by (block, text)
189
+ for (const r of candRecs) {
190
+ if (r.kind === 'blank') continue;
191
+ if (r.kind === 'heading') {
192
+ if (r.level <= 2) candidatePhaseBlocks.add(r.block);
193
+ else candSubHeadingCount.set(keyOf(r.block, r.text), (candSubHeadingCount.get(keyOf(r.block, r.text)) ?? 0) + 1);
194
+ continue;
195
+ }
196
+ // Count ONLY eligible prose, symmetric with baseContentCount below. Counting
197
+ // generator-owned lines (feature rows, dividers, generated key-docs) here would
198
+ // let a regenerated feature row "pay for" a lost base prose line of identical
199
+ // text under the same block — a silent loss.
200
+ if (r.kind !== 'other' || isGenKeyDoc(r.block, r.text)) continue;
201
+ const k = keyOf(r.block, r.text);
202
+ candCount.set(k, (candCount.get(k) ?? 0) + 1);
203
+ }
204
+
205
+ // Pre-count base occurrences so, for N identical lines with N-D survivors, we
206
+ // can flag the FIRST D and keep the last N-D. The loss mechanism (Map collision
207
+ // in readPhaseBlocks) keeps the LAST occurrence, so the lost ones are the
208
+ // earliest — flagging those makes the reported line accurate and lets --protect
209
+ // wrap the occurrence that actually disappears.
210
+ const baseContentCount = new Map();
211
+ const baseSubCount = new Map();
212
+ for (const r of baseRecs) {
213
+ if (r.kind === 'other' && !isGenKeyDoc(r.block, r.text)) {
214
+ const k = keyOf(r.block, r.text);
215
+ baseContentCount.set(k, (baseContentCount.get(k) ?? 0) + 1);
216
+ } else if (r.kind === 'heading' && r.level >= 3) {
217
+ const k = keyOf(r.block, r.text);
218
+ baseSubCount.set(k, (baseSubCount.get(k) ?? 0) + 1);
219
+ }
220
+ }
221
+
222
+ const flaggedContent = new Map();
223
+ const flaggedSub = new Map();
224
+ const residue = [];
225
+ const lose = (r) => residue.push({ lineNo: r.lineNo, text: r.text, nearestHeading: r.block });
226
+ const flagFirstD = (k, deficitBase, deficitCand, flagged) => {
227
+ const deficit = (deficitBase.get(k) ?? 0) - (deficitCand.get(k) ?? 0);
228
+ const seen = flagged.get(k) ?? 0;
229
+ if (seen < deficit) { flagged.set(k, seen + 1); return true; }
230
+ return false;
231
+ };
232
+ for (const r of baseRecs) {
233
+ if (r.kind === 'heading') {
234
+ if (r.level <= 2) {
235
+ if (!candidatePhaseBlocks.has(r.block)) lose(r); // whole section vanished
236
+ } else if (flagFirstD(keyOf(r.block, r.text), baseSubCount, candSubHeadingCount, flaggedSub)) {
237
+ lose(r);
238
+ }
239
+ continue;
240
+ }
241
+ if (r.kind !== 'other') continue;
242
+ if (isGenKeyDoc(r.block, r.text)) continue; // generator-owned, not hand-authored
243
+ if (flagFirstD(keyOf(r.block, r.text), baseContentCount, candCount, flaggedContent)) lose(r);
244
+ }
245
+ return residue;
246
+ }
247
+
248
+ /** Section id from a heading: lowercase slug, letter-initial, `[a-z][a-z0-9-]*`. */
249
+ function sectionSlug(heading, used) {
250
+ let base = String(heading ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
251
+ if (!base || !/^[a-z]/.test(base)) base = base ? `s-${base}` : 'preserved';
252
+ let id = base;
253
+ let n = 2;
254
+ while (used.has(id)) id = `${base}-${n++}`;
255
+ used.add(id);
256
+ return id;
257
+ }
258
+
259
+ /**
260
+ * Rewrite `baseText`, wrapping each contiguous run of residue lines in
261
+ * `<!-- preserved-section: <id> -->` … `<!-- /preserved-section -->` markers with
262
+ * ids derived from the run's nearest heading. Regenerating from the result
263
+ * preserves the previously-dropped content. Turns the error into a one-command fix.
264
+ *
265
+ * @param {string} baseText
266
+ * @param {Array<{lineNo:number, nearestHeading:string|null}>} residue
267
+ * @returns {string}
268
+ */
269
+ export function protectResidue(baseText, residue) {
270
+ if (!residue || residue.length === 0) return baseText;
271
+ const lines = baseText.split('\n');
272
+
273
+ // Group residue into contiguous line runs.
274
+ const sorted = [...residue].sort((a, b) => a.lineNo - b.lineNo);
275
+ const runs = [];
276
+ for (const r of sorted) {
277
+ const last = runs[runs.length - 1];
278
+ if (last && r.lineNo === last.end + 1) last.end = r.lineNo;
279
+ else runs.push({ start: r.lineNo, end: r.lineNo, heading: r.nearestHeading });
280
+ }
281
+
282
+ // Insert markers bottom-up so earlier line indices stay valid; within a run,
283
+ // splice the close (higher index) before the open (lower index). Seed the
284
+ // used-id set with ids ALREADY in the file, so a generated id can never collide
285
+ // with an existing preserved-section (which would then drop content on the next
286
+ // generate via the id-keyed Map).
287
+ const used = new Set();
288
+ for (const line of lines) {
289
+ const m = line.match(PRESERVED_OPEN_RE);
290
+ if (m) used.add(m[1]);
291
+ }
292
+ const ids = runs.map((run) => sectionSlug(run.heading, used));
293
+ for (let i = runs.length - 1; i >= 0; i--) {
294
+ const run = runs[i];
295
+ lines.splice(run.end, 0, '<!-- /preserved-section -->');
296
+ lines.splice(run.start - 1, 0, `<!-- preserved-section: ${ids[i]} -->`);
297
+ }
298
+ return lines.join('\n');
299
+ }