@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,214 @@
1
+ /**
2
+ * lib/fluid/record-shape.js — record rules that belong to the seam, not to a provider.
3
+ *
4
+ * Two rules lived as private members of `LocalFluidProvider` while it was the
5
+ * only provider, and the moment a second one exists they are drift generators:
6
+ *
7
+ * 1. `normalizeRecord()` — the contract-default filling and defensive cloning
8
+ * that `_normalize` did (COMP-FOH C12).
9
+ * 2. `assertPatchable()` — the `UNPATCHABLE` refusal that `updateRecord` did
10
+ * (COMP-FOH C16).
11
+ *
12
+ * WHY THESE ARE SHARED AND NOT COPIED
13
+ * -----------------------------------
14
+ * Both encode a promise the *seam* makes, not a choice a provider gets to make.
15
+ * `getRecord` returning a record whose optional fields are filled to contract
16
+ * defaults is what lets a caller read a record written before a field existed;
17
+ * `updateRecord` refusing to patch `discussion` is what makes the deliberation
18
+ * trail evidence rather than a mutable blob. A provider that implements either
19
+ * one differently is not a slower or simpler provider — it is a provider with a
20
+ * different contract, which defeats the point of the swap.
21
+ *
22
+ * The second copy is the dangerous one, because it is the one nobody re-reads.
23
+ * `local-provider.js` enforces `UNPATCHABLE` and says why in a comment that a
24
+ * new provider's author would never see.
25
+ *
26
+ * Pure functions only — no I/O, no store, no provider reference — so both
27
+ * providers and the tests can call them directly.
28
+ */
29
+
30
+ import { KIND } from './provider.js';
31
+
32
+ /** Handle prefix per record kind. Handles are quoted in docs and must be short
33
+ * and stable; the prefix set is fixed here so two providers allocate the same
34
+ * shape of handle for the same kind. */
35
+ export const HANDLE_PREFIX = Object.freeze({
36
+ [KIND.IDEA]: 'IDEA',
37
+ [KIND.POSITION]: 'POS',
38
+ [KIND.JOINT]: 'JOINT',
39
+ [KIND.DECISION]: 'DEC',
40
+ [KIND.THREAD]: 'THREAD',
41
+ [KIND.QUESTION]: 'Q',
42
+ [KIND.CLUSTER]: 'CLUS',
43
+ });
44
+
45
+ /**
46
+ * Handle grammar, with the suffix bounded to 9 digits.
47
+ *
48
+ * The bound is load-bearing, not cosmetic. Allocation reads the suffix as a
49
+ * Number; past the exact-integer range `max + 1 === max`, so importing a
50
+ * gigantic handle would make the allocator hand the same one out forever. The
51
+ * contract carries the same bound.
52
+ */
53
+ export const HANDLE_RE = /^([A-Z][A-Z0-9]*)-([1-9][0-9]{0,8})$/;
54
+
55
+ /** Canonical fluid lifecycle states, per the contract's `status` enum. */
56
+ export const FLUID_STATUSES = Object.freeze(new Set(['new', 'discussing', 'promoted', 'killed']));
57
+
58
+ /** Link types the contract defines. Validated so a typo becomes an error at the
59
+ * write rather than a silently unqueryable edge. */
60
+ export const LINK_TYPES = Object.freeze(new Set([
61
+ 'promoted_to', 'maps_to', 'informs', 'blocks',
62
+ 'supports', 'contradicts', 'supersedes', 'duplicate_of',
63
+ ]));
64
+
65
+ export function assertStatus(status) {
66
+ if (!FLUID_STATUSES.has(status)) {
67
+ throw new Error(
68
+ `fluid: invalid status "${status}" (expected one of: ${[...FLUID_STATUSES].join(', ')})`
69
+ );
70
+ }
71
+ return status;
72
+ }
73
+
74
+ /** A caller-supplied handle must be well-formed AND belong to its kind — an
75
+ * `idea` carrying `DEC-4` would be invisible to idea handle allocation and
76
+ * would collide with a real decision later. */
77
+ export function assertHandle(handle, kind) {
78
+ const m = HANDLE_RE.exec(handle ?? '');
79
+ if (!m) {
80
+ throw new Error(`fluid: malformed handle "${handle}" (expected PREFIX-N, N >= 1)`);
81
+ }
82
+ const expected = HANDLE_PREFIX[kind];
83
+ if (m[1] !== expected) {
84
+ throw new Error(
85
+ `fluid: handle "${handle}" does not belong to kind "${kind}" (expected prefix ${expected})`
86
+ );
87
+ }
88
+ return handle;
89
+ }
90
+
91
+ export function assertLink(link) {
92
+ if (!link || !LINK_TYPES.has(link.type)) {
93
+ throw new Error(
94
+ `fluid: invalid link type "${link?.type}" (expected one of: ${[...LINK_TYPES].join(', ')})`
95
+ );
96
+ }
97
+ if (typeof link.target !== 'string' || !link.target) {
98
+ throw new Error('fluid: link requires a target');
99
+ }
100
+ return link;
101
+ }
102
+
103
+ /**
104
+ * Which lifecycle fact an update represents.
105
+ *
106
+ * Status transitions win over field edits because they are the events other
107
+ * rungs read (promotion and kill are the two outcomes a conviction or
108
+ * calibration layer scores against). Checked in that order so a promote that
109
+ * also retitles is still recorded as a promotion.
110
+ *
111
+ * Shared because the event stream is the thing a later semantic layer learns
112
+ * from: two providers that classified the same edit differently would make that
113
+ * history unreadable across a swap.
114
+ */
115
+ export function eventTypeForUpdate(before, after, patch) {
116
+ if (after.status !== before.status) {
117
+ if (after.status === 'killed') return 'killed';
118
+ if (after.status === 'promoted') return 'promoted';
119
+ }
120
+ if (patch.priority !== undefined && after.priority !== before.priority) return 'triaged';
121
+ if (after.discussion.length > before.discussion.length) return 'discussed';
122
+ return 'updated';
123
+ }
124
+
125
+ /**
126
+ * Fields a caller may never patch through `updateRecord`.
127
+ *
128
+ * `handle`/`kind`/`id` are identity: `handle` is quoted in docs and commits, and
129
+ * `kind` additionally decides a record's storage type on providers that key
130
+ * behaviour off it — a provider whose backing store freezes the type at creation
131
+ * would diverge permanently from a record claiming a new one.
132
+ *
133
+ * `provenance` is write-time-stamped and never retrofitted. `discussion` is
134
+ * append-only evidence — a deliberation trail that can be replaced wholesale is
135
+ * not evidence, so it moves only through `appendDiscussion()`. Timestamps are
136
+ * provider-assigned.
137
+ */
138
+ export const UNPATCHABLE = Object.freeze([
139
+ 'handle', 'kind', 'provenance', 'discussion', 'id', 'created_at', 'updated_at',
140
+ ]);
141
+
142
+ /**
143
+ * Refuse a patch that touches an unpatchable field.
144
+ *
145
+ * Refuses rather than silently dropping. Silently ignoring `discussion: []`
146
+ * would look to the caller exactly like a successful erase of the deliberation
147
+ * trail, and looking successful is the dangerous half.
148
+ *
149
+ * Keys are tested with `!== undefined`, so an explicit `null` is a violation
150
+ * too: `{ provenance: null }` is an attempt to clear provenance, not an absent
151
+ * field.
152
+ *
153
+ * @param {object} patch caller-supplied patch
154
+ * @param {string} providerName for the error message
155
+ * @throws {Error} naming every offending field at once, so a caller fixes one
156
+ * call rather than discovering the list one rejection at a time.
157
+ */
158
+ export function assertPatchable(patch, providerName) {
159
+ const forbidden = UNPATCHABLE.filter((f) => patch?.[f] !== undefined);
160
+ if (forbidden.length) {
161
+ throw new Error(
162
+ `fluid: field(s) ${forbidden.join(', ')} cannot be changed through updateRecord ` +
163
+ `(identity and append-only evidence are not patchable; use appendDiscussion for discussion)` +
164
+ (providerName ? ` [${providerName}]` : '')
165
+ );
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Normalize a record on the way out.
171
+ *
172
+ * Every structured field is cloned. Returning stored objects by reference lets
173
+ * a caller mutate what the provider considers canonical with no write, no
174
+ * timestamp, no event and no save — a change that appeared to take effect and
175
+ * then silently vanished. Nothing shares a reference with the store.
176
+ *
177
+ * Absent optional fields are filled to their contract defaults rather than left
178
+ * `undefined`, so a record written before a field existed reads back the same
179
+ * shape as one written after it. This is also what makes a record recovered
180
+ * from a store that erases nulls and empty containers (COMP-FOH C14) read back
181
+ * intact, without a migration.
182
+ *
183
+ * @param {object} record raw stored record
184
+ * @returns {object} a fresh object sharing no references with `record`
185
+ */
186
+ export function normalizeRecord(record) {
187
+ return {
188
+ id: record.id,
189
+ handle: record.handle,
190
+ kind: record.kind,
191
+ title: record.title,
192
+ body: record.body ?? '',
193
+ status: record.status,
194
+ status_label: record.status_label ?? null,
195
+ priority: record.priority ?? null,
196
+ // The 2x2 matrix's two axes. Filled to null here rather than left absent for
197
+ // the reason stated above: every record written before COMP-PLAN-IDEA-UNIFY
198
+ // S3b-2 predates both fields, and a caller that has to distinguish "absent"
199
+ // from "unassigned" would be reading the migration's timeline out of the
200
+ // record shape.
201
+ effort: record.effort ?? null,
202
+ impact: record.impact ?? null,
203
+ cluster: record.cluster ?? null,
204
+ cluster_order: record.cluster_order ?? null,
205
+ tags: Array.isArray(record.tags) ? [...record.tags] : [],
206
+ source: record.source ?? null,
207
+ links: Array.isArray(record.links) ? record.links.map((l) => ({ ...l })) : [],
208
+ killed: record.killed ? { ...record.killed } : null,
209
+ discussion: Array.isArray(record.discussion) ? record.discussion.map((d) => ({ ...d })) : [],
210
+ provenance: { ...record.provenance },
211
+ created_at: record.created_at,
212
+ updated_at: record.updated_at,
213
+ };
214
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * lib/fluid/record-store.js — durable, git-tracked persistence for the floor
3
+ * provider.
4
+ *
5
+ * WHY THIS EXISTS (S3 entry-gate ruling, 2026-08-04)
6
+ * --------------------------------------------------
7
+ * S1 hosted records on vision-store items, in an additive `fluid_ext`
8
+ * namespace. That was the right call for a seam pilot and the wrong one for
9
+ * durability: `.compose/data/vision-state.json` is gitignored (`.gitignore:3`
10
+ * matches `data/`), so wiring the CLI, the API and the UI onto the provider
11
+ * would have moved idea canon from a tracked file to an untracked one on a
12
+ * single machine — and committed a GENERATED `ideabox.md` with no source of
13
+ * truth behind it on any other clone or in CI.
14
+ *
15
+ * The owner ruled: track the records, split them out of vision-state. That is
16
+ * not merely a path change. It inverts which side is canon:
17
+ *
18
+ * BEFORE vision item = canon, nothing tracked
19
+ * AFTER record file = canon, vision item = optional projection
20
+ *
21
+ * Vision-state could not simply be un-ignored: it is ~540KB of runtime state
22
+ * (gates, connections, 465 items) rewritten on every save, so tracking it would
23
+ * mean an unreadable diff and a merge conflict per parallel session. Records
24
+ * need the opposite properties, so they get their own files.
25
+ *
26
+ * LAYOUT — mirrors `docs/judgment/`, deliberately
27
+ * -----------------------------------------------
28
+ * The judgment layer already solved "tool-owned canon plus a generated markdown
29
+ * projection" and is the house convention; a third pattern here would be drift
30
+ * for its own sake.
31
+ *
32
+ * docs/judgment/records/joints/*.json ↔ <root>/records/<HANDLE>.json
33
+ * docs/judgment/records/ledger.jsonl ↔ <root>/events.jsonl
34
+ * docs/judgment/REGISTER.md (generated) ↔ docs/product/ideabox.md
35
+ *
36
+ * ONE FILE PER RECORD, not one array file. A whole-array rewrite makes every
37
+ * `ideabox add` a diff against all records and a merge conflict between two
38
+ * clones that each added an idea. Per-record files make an add a pure file
39
+ * creation. The handle is the filename because it is unique, never reused
40
+ * (retired handles stay retired — see the events log), and already constrained
41
+ * to a filesystem-safe grammar by the contract.
42
+ *
43
+ * NO IN-MEMORY SNAPSHOT. Every read hits the directory. This is what retires
44
+ * the S1 `_sync()` hazard rather than merely narrowing it: there is no cached
45
+ * state for a second writer to invalidate, so the CLI and a running server
46
+ * cannot erase each other's records by saving a stale view. Interleaving is
47
+ * still unserialized — see the known gap at the foot of this file.
48
+ */
49
+
50
+ import {
51
+ appendFileSync,
52
+ existsSync,
53
+ mkdirSync,
54
+ readFileSync,
55
+ readdirSync,
56
+ renameSync,
57
+ rmSync,
58
+ writeFileSync,
59
+ } from 'node:fs';
60
+ import { join, resolve } from 'node:path';
61
+ import { randomUUID } from 'node:crypto';
62
+
63
+ /** Tracked root, relative to the project. Under `docs/` on purpose: this is
64
+ * product canon that belongs in review and in git history, not runtime state.
65
+ * It sits beside its own projection (`docs/product/ideabox.md`). */
66
+ export const DEFAULT_RECORDS_ROOT = join('docs', 'product', 'fluid');
67
+
68
+ const RECORDS_DIR = 'records';
69
+ const EVENTS_FILE = 'events.jsonl';
70
+
71
+ /**
72
+ * Filenames are handles, so the grammar is enforced on the way to disk as well
73
+ * as at the seam. A handle is interpolated into a path; anything carrying a
74
+ * separator or a traversal segment must never reach `join()`. The seam already
75
+ * validates handles, but a store that writes wherever it is pointed is one
76
+ * caller away from writing outside its root.
77
+ */
78
+ const HANDLE_RE = /^[A-Z][A-Z0-9]*-[1-9][0-9]{0,8}$/;
79
+
80
+ function assertHandleSafe(handle) {
81
+ if (typeof handle !== 'string' || !HANDLE_RE.test(handle)) {
82
+ throw new Error(`fluid: unsafe record handle "${handle}" — refusing to build a path from it`);
83
+ }
84
+ return handle;
85
+ }
86
+
87
+ /**
88
+ * Scans RAW log text for handle tokens, independent of JSON parseability.
89
+ *
90
+ * Carried over from S1 unchanged, and for the same reason: the watermark must
91
+ * survive a corrupt or partially-written line. If the only tombstone for a
92
+ * retired handle were lost to a parse failure, that handle would be reissued,
93
+ * and handles are external citations — reuse silently repoints a citation in a
94
+ * doc or a commit at a different idea. Tolerant history reading and durable
95
+ * handle retirement are separate jobs, so they read the log differently.
96
+ */
97
+ const HANDLE_TOKEN_RE = /"handle"\s*:\s*"([A-Z][A-Z0-9]*-[1-9][0-9]{0,8})"/g;
98
+
99
+ /**
100
+ * Durable per-record storage plus the append-only lifecycle log.
101
+ *
102
+ * Deliberately dumb: no validation of record CONTENT (the provider validates
103
+ * against `contracts/fluid-record.schema.json` before handing anything over),
104
+ * no lifecycle rules, no handle allocation. It knows about paths, atomicity and
105
+ * durability, and nothing else.
106
+ */
107
+ export class FluidRecordStore {
108
+ /**
109
+ * @param {string} cwd project root
110
+ * @param {object} [config]
111
+ * @param {string} [config.recordsRoot] absolute or project-relative override
112
+ * (tests point this at a tmp dir)
113
+ */
114
+ constructor(cwd, config = {}) {
115
+ this.cwd = cwd;
116
+ const root = config.recordsRoot ?? DEFAULT_RECORDS_ROOT;
117
+ // resolve(), not join(): an absolute override is documented and a plain
118
+ // join turns `/tmp/fluid` into `<cwd>/tmp/fluid`, silently writing records
119
+ // somewhere the caller never named.
120
+ this.root = resolve(cwd, root);
121
+ this.recordsDir = join(this.root, RECORDS_DIR);
122
+ this.eventsPath = join(this.root, EVENTS_FILE);
123
+ }
124
+
125
+ _pathFor(handle) {
126
+ return join(this.recordsDir, `${assertHandleSafe(handle)}.json`);
127
+ }
128
+
129
+ // -------------------------------------------------------------------------
130
+ // Records
131
+ // -------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Write a record atomically.
135
+ *
136
+ * tmp + rename, matching `VisionStore._save()`. A half-written record file is
137
+ * worse here than in a single state file: it is not a corrupt blob you notice
138
+ * on load, it is one idea that silently fails to parse while its 19 siblings
139
+ * load fine. The rename makes the file appear whole or not at all.
140
+ *
141
+ * The tmp name carries a random suffix rather than a timestamp so two writers
142
+ * in the same millisecond cannot land on the same tmp path and interleave
143
+ * their bytes.
144
+ */
145
+ write(record) {
146
+ const path = this._pathFor(record.handle);
147
+ mkdirSync(this.recordsDir, { recursive: true });
148
+ // Pretty-printed with a trailing newline: these files are reviewed as
149
+ // diffs. Key order is deterministic because the provider builds every
150
+ // record through one object literal and a re-read preserves that order —
151
+ // NOT via a sorted replacer array, which would also filter nested objects
152
+ // and silently drop `provenance` and every link field.
153
+ const data = JSON.stringify(record, null, 2) + '\n';
154
+ const tmp = `${path}.tmp.${randomUUID()}`;
155
+ try {
156
+ writeFileSync(tmp, data, 'utf8');
157
+ renameSync(tmp, path);
158
+ } catch (err) {
159
+ // A leftover tmp file is invisible to read() (it does not end in .json
160
+ // alone — see list()), but leaving litter in tracked canon would show up
161
+ // in `git status` and in review.
162
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
163
+ throw new Error(`fluid: failed to persist record ${record.handle} — ${err.message}`);
164
+ }
165
+ return record;
166
+ }
167
+
168
+ /** @returns {object|null} the record, or null when absent. */
169
+ read(handle) {
170
+ const path = this._pathFor(handle);
171
+ if (!existsSync(path)) return null;
172
+ let record;
173
+ try {
174
+ record = JSON.parse(readFileSync(path, 'utf8'));
175
+ } catch (err) {
176
+ // Loud, not null. Returning null for an unparseable file would make a
177
+ // corrupt record indistinguishable from a deleted one, and the caller's
178
+ // next move on "absent" is to allocate the handle again or report it
179
+ // missing — both of which quietly destroy the damaged record.
180
+ throw new Error(`fluid: record file for ${handle} is unreadable — ${err.message}`);
181
+ }
182
+ return this._assertIdentity(record, handle, path);
183
+ }
184
+
185
+ /**
186
+ * The filename and the record's own `handle` must agree.
187
+ *
188
+ * These are git-tracked files a human can open, and every write path routes
189
+ * through `record.handle` to choose its destination. So a file whose name and
190
+ * contents disagree is not a cosmetic inconsistency: reading `IDEA-1.json`
191
+ * that contains `"handle": "IDEA-2"` and then saving it writes IDEA-2.json,
192
+ * destroying a DIFFERENT record while leaving the malformed one untouched.
193
+ *
194
+ * Cheap to check, and the check has to live here because this is the only
195
+ * layer that knows both the name asked for and the bytes on disk.
196
+ */
197
+ _assertIdentity(record, handle, path) {
198
+ if (record?.handle !== handle) {
199
+ throw new Error(
200
+ `fluid: record file ${path} declares handle "${record?.handle}" but is named ` +
201
+ `"${handle}" — refusing to read it, because writing it back would overwrite ` +
202
+ `a different record`
203
+ );
204
+ }
205
+ return record;
206
+ }
207
+
208
+ /**
209
+ * Every record on disk.
210
+ *
211
+ * Sorted by filename so the enumeration is deterministic regardless of
212
+ * `readdirSync` order, which is filesystem-dependent. The provider re-sorts
213
+ * for presentation; this only guarantees the input to that sort is stable, so
214
+ * a regenerated projection does not churn on an unrelated machine.
215
+ *
216
+ * ONE CORRUPT FILE FAILS THE WHOLE LIST, deliberately. Skipping it would be
217
+ * the friendlier-looking choice and the destructive one: this list is what
218
+ * regenerates `ideabox.md`, so a silently omitted record becomes an idea
219
+ * deleted from tracked markdown by a routine write. Refusing to list at all
220
+ * is recoverable; a projection that quietly drops an idea is not.
221
+ */
222
+ list() {
223
+ if (!existsSync(this.recordsDir)) return [];
224
+ const names = readdirSync(this.recordsDir)
225
+ .filter((n) => n.endsWith('.json') && HANDLE_RE.test(n.slice(0, -'.json'.length)))
226
+ .sort();
227
+ return names.map((n) => {
228
+ const handle = n.slice(0, -'.json'.length);
229
+ // read() enforces filename↔handle identity, so a mismatched file fails
230
+ // the whole list rather than being projected under the wrong identity.
231
+ const record = this.read(handle);
232
+ if (!record) {
233
+ // Raced against a delete between readdir and read. Not an error; the
234
+ // record is genuinely gone.
235
+ return null;
236
+ }
237
+ return record;
238
+ }).filter(Boolean);
239
+ }
240
+
241
+ /** Handles of live records only. Retired handles live in the log. */
242
+ liveHandles() {
243
+ if (!existsSync(this.recordsDir)) return new Set();
244
+ const out = new Set();
245
+ for (const n of readdirSync(this.recordsDir)) {
246
+ if (!n.endsWith('.json')) continue;
247
+ const handle = n.slice(0, -'.json'.length);
248
+ if (HANDLE_RE.test(handle)) out.add(handle);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /** @returns {boolean} whether a file was removed. */
254
+ remove(handle) {
255
+ const path = this._pathFor(handle);
256
+ if (!existsSync(path)) return false;
257
+ rmSync(path);
258
+ return true;
259
+ }
260
+
261
+ // -------------------------------------------------------------------------
262
+ // Events — append-only
263
+ // -------------------------------------------------------------------------
264
+
265
+ _rawLog() {
266
+ return existsSync(this.eventsPath) ? readFileSync(this.eventsPath, 'utf8') : '';
267
+ }
268
+
269
+ /**
270
+ * Append one event.
271
+ *
272
+ * The log is the tombstone ledger: a `created` entry burns its handle
273
+ * permanently, which is what stops a deleted record's handle being handed to
274
+ * a different idea later. It is therefore tracked alongside the records
275
+ * rather than left in the ignored runtime directory — losing it does not lose
276
+ * history, it loses the guarantee.
277
+ */
278
+ appendEvent(event) {
279
+ mkdirSync(this.root, { recursive: true });
280
+ appendFileSync(this.eventsPath, JSON.stringify(event) + '\n', 'utf8');
281
+ return { ok: true };
282
+ }
283
+
284
+ /**
285
+ * Handles named anywhere in the raw log, including on lines that fail to
286
+ * parse. See HANDLE_TOKEN_RE.
287
+ */
288
+ issuedHandlesFromLog() {
289
+ const handles = new Set();
290
+ const text = this._rawLog();
291
+ HANDLE_TOKEN_RE.lastIndex = 0;
292
+ let m;
293
+ while ((m = HANDLE_TOKEN_RE.exec(text)) !== null) handles.add(m[1]);
294
+ return handles;
295
+ }
296
+
297
+ /** Parsed history, skipping unparseable lines. Handle retirement never
298
+ * depends on this path — see issuedHandlesFromLog. */
299
+ readEvents() {
300
+ const out = [];
301
+ for (const line of this._rawLog().split('\n')) {
302
+ const trimmed = line.trim();
303
+ if (!trimmed) continue;
304
+ try { out.push(JSON.parse(trimmed)); } catch { /* a corrupt line must not
305
+ take the readable history with it */ }
306
+ }
307
+ return out;
308
+ }
309
+ }
310
+
311
+ /*
312
+ * KNOWN GAP, accepted for the floor (unchanged from S1, narrowed by this slice)
313
+ * ----------------------------------------------------------------------------
314
+ * There is still no lock. Two processes writing the SAME handle concurrently
315
+ * can interleave read-modify-write, and last writer wins.
316
+ *
317
+ * What this slice did fix is the worse failure it used to sit behind: with a
318
+ * shared state file and an in-memory snapshot, a stale writer saving unrelated
319
+ * work erased records it had never read. Per-record files plus read-per-
320
+ * operation mean a concurrent write can now only lose an update to the ONE
321
+ * record being contended, never to its siblings.
322
+ *
323
+ * Handle allocation is the remaining sharp edge: two creates racing can compute
324
+ * the same next handle. The pre-write tombstone narrows the window but does not
325
+ * close it. Serializing that needs a lock, and `.compose/locks/` already exists
326
+ * for exactly this — it is S3b's job, when the CLI, the API and the UI are all
327
+ * actually writing.
328
+ */