@smartmemory/compose 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,606 @@
1
+ /**
2
+ * lib/fluid/local-provider.js — the zero-install floor provider.
3
+ *
4
+ * Implements the fluid-store seam over git-tracked record files
5
+ * (`lib/fluid/record-store.js`). There is exactly one record store.
6
+ *
7
+ * Storage layout:
8
+ * - a record → one tracked JSON file, `docs/product/fluid/records/<HANDLE>.json`
9
+ * - an event → one line in `docs/product/fluid/events.jsonl` (append-only)
10
+ *
11
+ * WHY NOT VISION ITEMS (S3 entry-gate ruling, 2026-08-04 — supersedes S1)
12
+ * -----------------------------------------------------------------------
13
+ * S1 hosted records on vision-store items in an additive `fluid_ext` namespace,
14
+ * reading `PROVIDER-SEAM` (§8k) as "the vision store's existing typed items are
15
+ * the expected implementation substrate of the local floor provider."
16
+ *
17
+ * That could not survive the durability question S3 opens with.
18
+ * `.compose/data/vision-state.json` is gitignored, so records hosted there are
19
+ * untracked, single-machine, and absent from CI — while `ideabox.md`, which
20
+ * this slice turns into a GENERATED projection of them, is tracked. Canon would
21
+ * have moved from a tracked file to an ignored one at the moment of cutover.
22
+ * Un-ignoring vision-state was not an option either: it is ~540KB of churning
23
+ * runtime state, unreadable as a diff and conflict-prone per parallel session.
24
+ *
25
+ * The owner ruled: track the records, split them out of vision-state. So the
26
+ * substrate clause of §8k no longer holds, and the direction of canon inverts —
27
+ * the record file is canon, and a vision item (if S4 wants one for the
28
+ * promotion edge) becomes a derived projection of it. The seam itself, its
29
+ * capability model and every handle invariant are untouched.
30
+ *
31
+ * What this bought beyond durability: the two-write compensation dance is gone
32
+ * (one file, one write), and so is the `_sync()` stale-snapshot hazard — there
33
+ * is no cached state for a second writer to invalidate.
34
+ *
35
+ * This provider declares STORAGE capabilities ONLY. It has no recall, no
36
+ * challenge, no conviction, no calibration, no contradiction — and per the
37
+ * ruling it must not pretend otherwise. Those calls inherit the base class's
38
+ * refusal and throw `FluidCapabilityUnavailable`. That is the intended,
39
+ * correct behavior of the floor, not a gap to be filled in later: the floor is
40
+ * the filing cabinet, and the intelligence is what a richer provider adds.
41
+ */
42
+
43
+ import { createHash, randomUUID } from 'node:crypto';
44
+ import { join } from 'node:path';
45
+
46
+ import { withDirLock } from '../dir-lock.js';
47
+
48
+ import {
49
+ CAP,
50
+ FluidAmbiguousMatch,
51
+ FluidProvider,
52
+ FluidRecordNotFound,
53
+ KIND,
54
+ MUTATION_SCOPE,
55
+ STORAGE_CAP,
56
+ } from './provider.js';
57
+ import { FluidRecordStore } from './record-store.js';
58
+ // Seam-wide record rules, shared with every other provider so the two cannot
59
+ // drift apart (COMP-FOH C12/C16). Handle grammar, link and status vocabularies
60
+ // and the event-type derivation live there for the same reason: they define
61
+ // what a fluid record IS, which is a property of the seam, not of a store.
62
+ import {
63
+ HANDLE_PREFIX,
64
+ HANDLE_RE,
65
+ UNPATCHABLE,
66
+ assertHandle,
67
+ assertLink,
68
+ assertPatchable,
69
+ assertStatus,
70
+ eventTypeForUpdate,
71
+ normalizeRecord,
72
+ } from './record-shape.js';
73
+ import { assertValid } from './schema.js';
74
+
75
+ function nowIso() { return new Date().toISOString(); }
76
+
77
+ /** Validate at the seam so a bad status surfaces as a fluid-level error naming
78
+ * the legal values, rather than as a schema rejection quoting an enum the
79
+ * caller never sees. */
80
+ export class LocalFluidProvider extends FluidProvider {
81
+ name() { return 'local'; }
82
+
83
+ /** Storage only. Never a semantic capability — see the file header. */
84
+ capabilities() {
85
+ return new Set([STORAGE_CAP.RECORDS, STORAGE_CAP.EVENTS, STORAGE_CAP.LINKS]);
86
+ }
87
+
88
+ /**
89
+ * The kinds this provider owns.
90
+ *
91
+ * `position` and `joint` are still refused, but the S1 reason for it is now
92
+ * obsolete and the real one is stronger. S1 said they had no vision item type
93
+ * to live in; records are their own files now, so that constraint is gone.
94
+ * They stay out because THE JUDGMENT LAYER ALREADY OWNS THEM —
95
+ * `docs/judgment/records/positions/` and `.../joints/`, written by
96
+ * `judgment_position_create` and `judgment_joint_add`. Accepting them here
97
+ * would give one kind two stores and two canons, which is the exact
98
+ * fragmentation this epic exists to end.
99
+ */
100
+ supportedKinds() {
101
+ return new Set([KIND.IDEA, KIND.DECISION, KIND.THREAD, KIND.QUESTION, KIND.CLUSTER]);
102
+ }
103
+
104
+ /**
105
+ * MACHINE, and that is the complete answer for this provider rather than a
106
+ * shortfall. `lib/dir-lock.js` is a filesystem mutex: it serializes every
107
+ * process on this machine, which is every process that can reach a local
108
+ * directory. CLUSTER is unreachable here and also unnecessary.
109
+ */
110
+ mutationScope() { return MUTATION_SCOPE.MACHINE; }
111
+
112
+ /** A directory on this machine. Two clones are two stores, not one shared one. */
113
+ isShared() { return false; }
114
+
115
+ /**
116
+ * @param {string} cwd project root
117
+ * @param {object} [config]
118
+ * @param {string} [config.recordsRoot] override (tests point this at a tmp dir)
119
+ */
120
+ async init(cwd, config = {}) {
121
+ this.cwd = cwd;
122
+ this.store = new FluidRecordStore(cwd, config);
123
+ this.recordsDir = this.store.recordsDir;
124
+ this.eventsPath = this.store.eventsPath;
125
+
126
+ // Every mutating method serializes on this. It lives under `.compose/data/`
127
+ // because that path is gitignored: the lock writes an owner token INSIDE
128
+ // the lock dir, so siting it next to the records — which are tracked canon —
129
+ // would put untracked noise in tracked territory on every idea write.
130
+ // (`record-store.js` used to point at `.compose/locks/`, which is NOT
131
+ // ignored; that comment is corrected.)
132
+ //
133
+ // Keyed by the records dir rather than by cwd so two stores under one
134
+ // project — which in practice means two test fixtures — do not serialize
135
+ // against each other, while two writers to the SAME store always do.
136
+ const key = createHash('sha256').update(this.recordsDir).digest('hex').slice(0, 12);
137
+ this.lockPath = join(cwd, '.compose', 'data', `fluid-records-${key}.lock`);
138
+ return this;
139
+ }
140
+
141
+ // -------------------------------------------------------------------------
142
+ // Mapping
143
+ // -------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Normalize a record on the way out. Delegates to the shared seam rule
147
+ * (`record-shape.js`) so every provider fills contract defaults and clones
148
+ * identically — see that module for why this is not provider-private.
149
+ */
150
+ _normalize(record) {
151
+ return normalizeRecord(record);
152
+ }
153
+
154
+ // -------------------------------------------------------------------------
155
+ // Handle allocation
156
+ // -------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Next handle for a kind: one past the highest number EVER issued for that
160
+ * prefix.
161
+ *
162
+ * The watermark is the max over live records AND the append-only event log —
163
+ * not over live records alone. Deriving it from live records only would reuse
164
+ * the handle of a deleted record, and handles are quoted in docs, commits and
165
+ * conversation, so reuse silently repoints an external citation at a different
166
+ * idea. The event log is never pruned, so a `created` event is a permanent
167
+ * tombstone for its handle.
168
+ */
169
+ /** Every handle ever issued — live records UNION the append-only log. */
170
+ _issuedHandles() {
171
+ const issued = new Set(this.store.liveHandles());
172
+ for (const handle of this.store.issuedHandlesFromLog()) issued.add(handle);
173
+ return issued;
174
+ }
175
+
176
+ _nextHandle(kind) {
177
+ const prefix = HANDLE_PREFIX[kind];
178
+ const issued = this._issuedHandles();
179
+ let max = 0;
180
+ for (const handle of issued) {
181
+ const m = HANDLE_RE.exec(handle);
182
+ if (m && m[1] === prefix) max = Math.max(max, Number(m[2]));
183
+ }
184
+ const candidate = `${prefix}-${max + 1}`;
185
+ // Belt and braces: the arithmetic above is only as trustworthy as the digit
186
+ // bound, so the automatic path makes the same membership check the
187
+ // caller-supplied path makes rather than trusting max + 1 to be fresh.
188
+ if (issued.has(candidate)) {
189
+ throw new Error(`fluid: handle allocation failed — ${candidate} is already issued`);
190
+ }
191
+ return candidate;
192
+ }
193
+
194
+ /**
195
+ * True when this exact handle has EVER been issued — live or retired.
196
+ *
197
+ * Membership, deliberately not `n <= highest`. The import supplies its own
198
+ * handles to preserve existing IDEA-N citations, and it may encounter them in
199
+ * any order; a watermark comparison would reject IDEA-3 merely because IDEA-20
200
+ * had already been imported. Only a handle genuinely seen before is refused.
201
+ */
202
+ /**
203
+ * Was this handle burned by a create that never finished?
204
+ *
205
+ * `createRecord` appends the tombstone BEFORE writing the record, so a crash
206
+ * between the two leaves a handle that is issued but has no record and never
207
+ * had one. The one-time import is exactly where that matters: it skips only
208
+ * LIVE records (`import-ideabox.js`), so a rerun retries the handle and is
209
+ * then refused by the issued-handle guard — leaving the migration permanently
210
+ * unrestartable, on the single operation that moves a project's whole idea
211
+ * corpus.
212
+ *
213
+ * WHAT THIS CAN AND CANNOT PROVE — stated precisely, because an earlier
214
+ * version of this comment claimed more than the code delivers.
215
+ *
216
+ * It establishes only: no record file, and no `deleted` event. It does NOT
217
+ * establish that the handle was never live. A record created normally whose
218
+ * file later disappeared out-of-band — a bad merge, a stray `rm`, a partial
219
+ * checkout — has exactly this shape, and no evidence in the log distinguishes
220
+ * it from a create that crashed. Do not read the name as a proof of absence.
221
+ *
222
+ * What makes reclaiming acceptable is the CALLER, not the check.
223
+ * `reclaimAborted` is opt-in and the one-time import is the only thing that
224
+ * passes it. The import supplies handles read out of the markdown, so the most
225
+ * a reclaim can do is restore a handle to the content the markdown already
226
+ * says belongs to it: a lost `IDEA-12.json` comes back as the IDEA-12 the file
227
+ * describes, never handed to an unrelated idea. Automatic allocation never
228
+ * reaches this path, and nothing else should pass the flag.
229
+ *
230
+ * Conservative where it can be. Structured events are read rather than
231
+ * `issuedHandlesFromLog`, whose regex matches a handle anywhere in the log
232
+ * including as another record's link target; and ANY `deleted` event
233
+ * disqualifies the handle, so a deliberately retired one stays retired.
234
+ */
235
+ _isAbortedAllocation(handle) {
236
+ if (this.store.read(handle)) return false;
237
+ for (const event of this.store.readEvents()) {
238
+ if (event?.handle === handle && event.type === 'deleted') return false;
239
+ }
240
+ return true;
241
+ }
242
+
243
+ _handleWasIssued(handle) {
244
+ return this._issuedHandles().has(handle);
245
+ }
246
+
247
+ // -------------------------------------------------------------------------
248
+ // Records
249
+ // -------------------------------------------------------------------------
250
+
251
+ async getRecord(handle) {
252
+ // A malformed handle is a miss, not a crash: getRecord is the lookup a
253
+ // caller makes with untrusted input (a CLI argument, a URL segment), and
254
+ // the honest answer to "is there a record called ../../etc/passwd" is no.
255
+ // Paths that ALLOCATE a handle validate it strictly instead.
256
+ if (!HANDLE_RE.test(handle ?? '')) return null;
257
+ const record = this.store.read(handle);
258
+ return record ? this._normalize(record) : null;
259
+ }
260
+
261
+ async listRecords(filter = {}) {
262
+ let records = this.store.list().map((r) => this._normalize(r));
263
+ if (filter.kind) records = records.filter((r) => r.kind === filter.kind);
264
+ if (filter.status) records = records.filter((r) => r.status === filter.status);
265
+ if (filter.cluster !== undefined) records = records.filter((r) => r.cluster === filter.cluster);
266
+ // Stable order: cluster order, then handle number. Presentation layers rely
267
+ // on this being deterministic so a regenerated projection does not churn.
268
+ return records.sort((a, b) => {
269
+ const ca = a.cluster_order ?? Number.MAX_SAFE_INTEGER;
270
+ const cb = b.cluster_order ?? Number.MAX_SAFE_INTEGER;
271
+ if (ca !== cb) return ca - cb;
272
+ return (Number(HANDLE_RE.exec(a.handle)?.[2] ?? 0)) - (Number(HANDLE_RE.exec(b.handle)?.[2] ?? 0));
273
+ });
274
+ }
275
+
276
+ // -------------------------------------------------------------------------
277
+ // Mutation — every path below serializes on one lock
278
+ // -------------------------------------------------------------------------
279
+ //
280
+ // The lock covers TWO distinct races, and scoping it to only the first was
281
+ // the original mistake:
282
+ //
283
+ // 1. HANDLE ALLOCATION. `_nextHandle` reads the records directory AND the
284
+ // raw events log (`_issuedHandles`), so two creates can agree on the same
285
+ // next handle. The pre-write tombstone narrows that window; it does not
286
+ // close it. Note the lock must therefore cover the LOG, not just the
287
+ // record file — guarding the directory alone still permits a stale log
288
+ // read.
289
+ //
290
+ // 2. LOST UPDATES on every other mutation. `updateRecord`, `appendDiscussion`,
291
+ // `addLink` and `removeLink` are all read-modify-write against one record
292
+ // file: both writers read, both merge onto their own snapshot, and the
293
+ // later write erases the earlier one. S3a accepted this while nothing was
294
+ // wired ("it can only lose an update to the one contended record"). The
295
+ // CLI cutover is what makes it reachable, so it is fixed here rather than
296
+ // inherited.
297
+ //
298
+ // One lock rather than per-record locks: idea writes are human-scale, the
299
+ // critical sections are a few small-file operations, and a single lock also
300
+ // orders allocation against mutation. Two granularities would buy nothing and
301
+ // cost a deadlock ordering rule.
302
+ //
303
+ // Each public method is a thin wrapper over a `*Locked` body because
304
+ // `withDirLock` is NOT reentrant and these paths call one another's helpers.
305
+ // `appendEvent` deliberately stays unlocked: it is an O_APPEND write of a
306
+ // single line to an append-only log, it is called from inside locked bodies,
307
+ // and locking it would deadlock every one of them.
308
+
309
+ /**
310
+ * @param {object} input record fields; `handle` may be supplied by the
311
+ * one-time import to preserve existing IDEA-N citations, otherwise it is
312
+ * allocated.
313
+ */
314
+ /**
315
+ * Genuinely atomic here (F6-1): the lookup and the create happen inside ONE
316
+ * hold of the mutation lock, so a second writer racing the same title waits,
317
+ * then finds the record the first one made instead of creating a twin.
318
+ *
319
+ * `_createRecordLocked` is the non-locking inner create this composes on —
320
+ * calling the public `createRecord` would deadlock, since `withDirLock` is
321
+ * deliberately not reentrant ("callers compose by locking once at the
322
+ * outermost mutating boundary").
323
+ */
324
+ async findOrCreateRecord({ kind, title }, input = {}) {
325
+ return withDirLock(this.lockPath, async () => {
326
+ const matches = this.store.list()
327
+ .map((r) => this._normalize(r))
328
+ .filter((r) => r.kind === kind && r.title.toLowerCase() === String(title).toLowerCase());
329
+ if (matches.length > 1) {
330
+ throw new FluidAmbiguousMatch(kind, title, matches.map((m) => m.handle));
331
+ }
332
+ if (matches.length === 1) return { record: matches[0], created: false };
333
+ return { record: await this._createRecordLocked({ ...input, kind, title }), created: true };
334
+ });
335
+ }
336
+
337
+ async createRecord(input) {
338
+ return withDirLock(this.lockPath, () => this._createRecordLocked(input));
339
+ }
340
+
341
+ async updateRecord(handle, patch) {
342
+ return withDirLock(this.lockPath, () => this._updateRecordLocked(handle, patch));
343
+ }
344
+
345
+ async appendDiscussion(handle, entry) {
346
+ return withDirLock(this.lockPath, () => this._appendDiscussionLocked(handle, entry));
347
+ }
348
+
349
+ async deleteRecord(handle) {
350
+ return withDirLock(this.lockPath, () => this._deleteRecordLocked(handle));
351
+ }
352
+
353
+ async addLink(handle, link) {
354
+ return withDirLock(this.lockPath, () => this._addLinkLocked(handle, link));
355
+ }
356
+
357
+ async removeLink(handle, link) {
358
+ return withDirLock(this.lockPath, () => this._removeLinkLocked(handle, link));
359
+ }
360
+
361
+ async _createRecordLocked(input) {
362
+ const kind = input.kind ?? KIND.IDEA;
363
+ this.requireKind(kind);
364
+ if (!input.title) throw new Error('fluid: createRecord requires a title');
365
+
366
+ let handle;
367
+ if (input.handle === undefined) {
368
+ handle = this._nextHandle(kind);
369
+ } else {
370
+ handle = assertHandle(input.handle, kind);
371
+ // Checked against every handle EVER issued, not just the live ones. A
372
+ // retired handle is still spoken for: reissuing it would repoint existing
373
+ // citations at a different record, which is precisely the outcome the
374
+ // tombstones exist to prevent, and the caller-supplied path is the one
375
+ // that can request it explicitly.
376
+ if (this._handleWasIssued(handle) && !(input.reclaimAborted && this._isAbortedAllocation(handle))) {
377
+ throw new Error(
378
+ `fluid: handle ${handle} has already been issued and cannot be reused ` +
379
+ `(handles are external citations; retired ones stay retired)`
380
+ );
381
+ }
382
+ }
383
+
384
+ for (const link of input.links ?? []) assertLink(link);
385
+ const status = assertStatus(input.status ?? 'new');
386
+ const record = {
387
+ handle,
388
+ kind,
389
+ title: input.title,
390
+ body: input.body ?? '',
391
+ status,
392
+ status_label: input.status_label ?? null,
393
+ priority: input.priority ?? null,
394
+ effort: input.effort ?? null,
395
+ impact: input.impact ?? null,
396
+ cluster: input.cluster ?? null,
397
+ cluster_order: input.cluster_order ?? null,
398
+ tags: input.tags ?? [],
399
+ source: input.source ?? null,
400
+ links: input.links ?? [],
401
+ killed: input.killed ?? null,
402
+ discussion: input.discussion ?? [],
403
+ provenance: {
404
+ origin: input.provenance?.origin ?? 'cli:ideabox',
405
+ recorded_at: input.provenance?.recorded_at ?? nowIso(),
406
+ author: input.provenance?.author ?? null,
407
+ },
408
+ };
409
+
410
+ // Validate against the published contract, not a hand-rolled subset. A
411
+ // bespoke field check drifts from the schema silently, accepting what the
412
+ // contract forbids while the contract keeps claiming otherwise.
413
+ //
414
+ // Validated in its FINAL persisted form — id and timestamps included —
415
+ // rather than with an `id: 'pending'` stand-in, so what the contract
416
+ // approved is byte-for-byte what reaches disk.
417
+ const now = nowIso();
418
+ const persisted = this._normalize({
419
+ ...record,
420
+ // Provider-assigned and provider-scoped, per the contract: `id` changes
421
+ // when a record is imported into a different provider, while `handle`
422
+ // survives the swap because it is quoted in docs and commits.
423
+ id: randomUUID(),
424
+ created_at: now,
425
+ updated_at: now,
426
+ });
427
+ assertValid('record', persisted, 'record');
428
+
429
+ // The tombstone is written BEFORE the record exists.
430
+ //
431
+ // Ordering matters and this is the only safe direction. Persisting the
432
+ // record first and appending the tombstone after means a failed append
433
+ // leaves a discoverable record whose handle was never burned — delete it
434
+ // and the handle is reissued, defeating the invariant. Burning first can
435
+ // at worst waste a handle if creation then fails, and a wasted handle is
436
+ // free while a reissued one is unrecoverable.
437
+ await this.appendEvent({
438
+ handle,
439
+ type: input.provenance?.origin === 'import:ideabox' ? 'imported' : 'created',
440
+ at: now,
441
+ detail: { kind },
442
+ });
443
+
444
+ // One file, one write. The S1 version wrote a vision item and then its
445
+ // namespace, and needed a compensating delete when the second failed or the
446
+ // record would exist as an inert half. A record is a single file now, and
447
+ // the atomic rename inside the store makes it appear whole or not at all.
448
+ this.store.write(persisted);
449
+ return this._normalize(persisted);
450
+ }
451
+
452
+ async _updateRecordLocked(handle, patch) {
453
+ const existing = this.store.read(handle);
454
+ if (!existing) throw new FluidRecordNotFound(handle, this.name());
455
+
456
+ // Refuse an unpatchable field rather than silently dropping it — shared with
457
+ // every provider, because a provider that allows one is not a simpler
458
+ // provider, it is one with a different contract.
459
+ assertPatchable(patch, this.name());
460
+
461
+ const current = this._normalize(existing);
462
+ const merged = {
463
+ ...current,
464
+ ...patch,
465
+ ...Object.fromEntries(UNPATCHABLE.map((f) => [f, current[f]])),
466
+ updated_at: nowIso(),
467
+ };
468
+
469
+ // Validate the RESULT against the contract before anything reaches disk —
470
+ // the whole shape, not the two fields that were easy to check by hand.
471
+ //
472
+ // ORDER IS LOAD-BEARING: validate the RAW merge, then normalize. Normalizing
473
+ // first would hand the schema an already-sanitized object, and the schema
474
+ // would approve what the sanitizer had quietly repaired. Two silent
475
+ // failures live in that gap, both of which report success:
476
+ // - `{ links: null }` becomes `[]`, erasing every link
477
+ // - `{ titel: 'x' }` is dropped, so a misspelled field writes nothing
478
+ // The contract already rejects both (`links` is typed, and the record
479
+ // definition is `additionalProperties: false`) — but only if it sees them.
480
+ assertStatus(merged.status);
481
+ assertValid('record', merged, 'record');
482
+ for (const link of merged.links) assertLink(link);
483
+
484
+ const next = this._normalize(merged);
485
+ const eventType = eventTypeForUpdate(current, next, patch);
486
+
487
+ this.store.write(next);
488
+
489
+ await this.appendEvent({
490
+ handle,
491
+ type: eventType,
492
+ at: nowIso(),
493
+ detail: { fields: Object.keys(patch) },
494
+ });
495
+
496
+ return next;
497
+ }
498
+
499
+ /** The only way discussion grows. Append-only in the API, not merely by
500
+ * convention in the contract — a deliberation trail that can be rewritten is
501
+ * not evidence. */
502
+ async _appendDiscussionLocked(handle, entry) {
503
+ const existing = this.store.read(handle);
504
+ if (!existing) throw new FluidRecordNotFound(handle, this.name());
505
+ if (!entry?.text) throw new Error('fluid: a discussion entry requires text');
506
+
507
+ const record = this._normalize(existing);
508
+ record.discussion.push({
509
+ at: entry.at ?? nowIso(),
510
+ text: entry.text,
511
+ author: entry.author ?? null,
512
+ });
513
+ record.updated_at = nowIso();
514
+ assertValid('record', record, 'record');
515
+ this.store.write(record);
516
+ await this.appendEvent({ handle, type: 'discussed', at: nowIso(), detail: {} });
517
+ return record;
518
+ }
519
+
520
+ /**
521
+ * Hard delete. NOT the lifecycle path — killing an idea is
522
+ * `updateRecord(handle, {status: 'killed'})`, which keeps the record and its
523
+ * reasoning. This removes the record entirely and exists for administrative
524
+ * correction.
525
+ *
526
+ * Because it destroys a record that may carry deliberation evidence, and the
527
+ * contract calls that evidence append-only, the discussion is carried into the
528
+ * append-only log before the record goes. Otherwise "append-only" would be
529
+ * true of every path except the one that actually erases it, and the surviving
530
+ * `discussed` events carry no text.
531
+ */
532
+ async _deleteRecordLocked(handle) {
533
+ const existing = this.store.read(handle);
534
+ if (!existing) throw new FluidRecordNotFound(handle, this.name());
535
+ const record = this._normalize(existing);
536
+
537
+ await this.appendEvent({
538
+ handle,
539
+ type: 'deleted',
540
+ at: nowIso(),
541
+ detail: { kind: record.kind, title: record.title, discussion: record.discussion },
542
+ });
543
+
544
+ // rmSync throws on a failed unlink, so a delete that did not reach disk
545
+ // surfaces instead of returning ok while the record is still there to
546
+ // reappear on the next read.
547
+ this.store.remove(handle);
548
+ return { ok: true };
549
+ }
550
+
551
+ // -------------------------------------------------------------------------
552
+ // Links
553
+ // -------------------------------------------------------------------------
554
+
555
+ async _addLinkLocked(handle, link) {
556
+ assertLink(link);
557
+ const existing = this.store.read(handle);
558
+ if (!existing) throw new FluidRecordNotFound(handle, this.name());
559
+ const record = this._normalize(existing);
560
+ const exists = record.links.some((l) => l.type === link.type && l.target === link.target);
561
+ // Idempotent: re-adding an existing link is a no-op, and deliberately emits
562
+ // no event. A `linked` event per repeat would inflate the lifecycle history
563
+ // that a conviction or calibration layer reads as signal.
564
+ if (!exists) {
565
+ record.links.push({ ...link });
566
+ record.updated_at = nowIso();
567
+ this.store.write(record);
568
+ await this.appendEvent({ handle, type: 'linked', at: nowIso(), detail: { ...link } });
569
+ }
570
+ return record;
571
+ }
572
+
573
+ async _removeLinkLocked(handle, link) {
574
+ const existing = this.store.read(handle);
575
+ if (!existing) throw new FluidRecordNotFound(handle, this.name());
576
+ const record = this._normalize(existing);
577
+ const before = record.links.length;
578
+ record.links = record.links.filter((l) => !(l.type === link.type && l.target === link.target));
579
+ // Matching addLink: only a real change touches the record or the timestamp.
580
+ if (record.links.length !== before) {
581
+ record.updated_at = nowIso();
582
+ this.store.write(record);
583
+ }
584
+ return record;
585
+ }
586
+
587
+ // -------------------------------------------------------------------------
588
+ // Lifecycle events
589
+ // -------------------------------------------------------------------------
590
+
591
+ async appendEvent(event) {
592
+ const full = { at: nowIso(), ...event };
593
+ // The log is append-only, so a malformed entry is permanent. Validate before
594
+ // it lands rather than discovering it on a read that no longer has a caller
595
+ // to blame.
596
+ assertValid('lifecycle_event', full, 'lifecycle event');
597
+ return this.store.appendEvent(full);
598
+ }
599
+
600
+ async readEvents(handle) {
601
+ const all = this.store.readEvents();
602
+ return handle ? all.filter((e) => e.handle === handle) : all;
603
+ }
604
+ }
605
+
606
+ export { CAP };