@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,261 @@
1
+ /**
2
+ * lib/fluid/render-ideabox.js — `ideabox.md` as a projection of the records.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S2, Decision 3's projection pattern (one canonical
5
+ * store; surfaces are projections; never a second source), the same shape as
6
+ * `ROADMAP.md ← feature.json`.
7
+ *
8
+ * The records are the ONLY input. This module never reads the existing
9
+ * markdown to decide what to write — doing so is what turns a projection back
10
+ * into a second source, and it is how a generator ends up preserving stale
11
+ * content nobody can trace to an owner.
12
+ *
13
+ * Consequence, stated plainly: after the cutover the file is output. Hand edits
14
+ * to it are lost on the next render, which is why the banner says so and why
15
+ * the CLI (S3) is the supported way to change anything.
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ import { withDirLock } from '../dir-lock.js';
23
+ import { toMarkdownDate } from './ideabox-dates.js';
24
+ import { KIND } from './provider.js';
25
+
26
+ // Says only what is true. The cockpit's write path is NOT on the store yet
27
+ // (S3b-2), and its endpoints fail closed until it is — so promising that "both
28
+ // write to the store" would invite exactly the edit that gets discarded.
29
+ const BANNER = [
30
+ '<!-- GENERATED FILE — DO NOT EDIT.',
31
+ ' Projection of the fluid-store idea records (COMP-PLAN-IDEA-UNIFY).',
32
+ ' Edits here are overwritten on the next render. Change ideas with',
33
+ ' `compose ideabox add|pri|kill|discuss|promote`, then `compose ideabox',
34
+ ' render` if this file ever looks stale. -->',
35
+ ];
36
+
37
+ const PREAMBLE = [
38
+ '# Ideabox',
39
+ '',
40
+ '**Purpose:** Capture raw ideas before they\'re ready for the roadmap.',
41
+ '',
42
+ '## Conventions',
43
+ '- **ID:** `IDEA-N` (sequential, never reuse)',
44
+ '- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`',
45
+ '- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)',
46
+ '- **Source:** Where the idea came from',
47
+ '- **Tags:** bare words, space-separated',
48
+ // Carried forward from the hand-authored preamble: a real project convention
49
+ // that the old template never knew about and silently deleted on every write.
50
+ '- **Umbrella:** Ideas are grouped under thematic umbrellas. The umbrella name is a working label; ideas may move between umbrellas as they\'re discussed. IDs are stable.',
51
+ ];
52
+
53
+ const STATUS_TOKEN = Object.freeze({
54
+ new: 'NEW',
55
+ discussing: 'DISCUSSING',
56
+ promoted: 'PROMOTED',
57
+ killed: 'KILLED',
58
+ });
59
+
60
+ function handleNumber(handle) {
61
+ const m = /-([0-9]+)$/.exec(handle ?? '');
62
+ return m ? Number(m[1]) : 0;
63
+ }
64
+
65
+ function renderIdea(record) {
66
+ const out = [];
67
+ out.push(`#### ${record.handle} — ${record.title}`);
68
+
69
+ // `status_label` preserves what the author actually wrote when the canonical
70
+ // enum could not hold it (`RE-AIMED (2026-07-21)`).
71
+ const status = record.status_label || STATUS_TOKEN[record.status];
72
+ const tags = record.tags?.length ? ` | **Tags:** ${record.tags.join(' ')}` : '';
73
+ if (record.status === 'killed') {
74
+ // A killed idea carries no priority segment. Not cosmetic: the legacy
75
+ // serializer omits it (`lib/ideabox.js` — `**Status:** KILLED${tagStr}`),
76
+ // and if the projection emits one, `serialize(parse(projection))` stops
77
+ // being the identity. That fixed point is the assertion the whole cutover
78
+ // rests on, and it would have broken the first time anyone killed an idea.
79
+ // Invisible until now only because the Killed Ideas section was empty.
80
+ out.push(`**Status:** ${status}${tags}`);
81
+ } else {
82
+ out.push(`**Status:** ${status} | **Priority:** ${record.priority || '—'}${tags}`);
83
+ }
84
+
85
+ if (record.source) out.push(`**Source:** ${record.source}`);
86
+ if (record.body) out.push(`**Idea:** ${record.body}`);
87
+
88
+ // `Promoted to:` is emitted BEFORE `Maps to:`, which reads backwards and is
89
+ // deliberate. The legacy parser knows `Maps to` and does not know `Promoted
90
+ // to`, so the latter lands in `_extraLines` — and the legacy serializer emits
91
+ // extras BEFORE the trailing known fields (`lib/ideabox.js:436-440`, itself a
92
+ // fix for reordering). Emitting them in the readable order therefore flips
93
+ // them on the first `serialize(parse(projection))`, breaking the fixed point
94
+ // for any idea carrying both edges. Invisible so far only because no idea has
95
+ // ever had both.
96
+ for (const link of record.links ?? []) {
97
+ if (link.type === 'promoted_to') out.push(`**Promoted to:** ${link.target}`);
98
+ }
99
+ for (const link of record.links ?? []) {
100
+ if (link.type === 'maps_to') out.push(`**Maps to:** ${link.target}`);
101
+ }
102
+
103
+ // The 2x2 matrix axes, in the legacy serializer's slot: after `Maps to:` and
104
+ // before the discussion block (`lib/ideabox.js:441-442`). Position is not
105
+ // cosmetic — the projection has to be a fixed point of that serializer.
106
+ if (record.effort) out.push(`**Effort:** ${record.effort}`);
107
+ if (record.impact) out.push(`**Impact:** ${record.impact}`);
108
+
109
+ // `Killed:` precedes the discussion block for the same reason: that is where
110
+ // `serializeKilledIdea` puts it (`lib/ideabox.js:465-471`). Emitting it after
111
+ // the discussion cost the fixed point for every killed idea that had been
112
+ // discussed — which is most of the ones anyone would want to kill.
113
+ if (record.status === 'killed' && record.killed) {
114
+ out.push(`**Killed:** ${toMarkdownDate(record.killed.at)} — ${record.killed.reason}`);
115
+ }
116
+
117
+ if (record.discussion?.length) {
118
+ out.push('**Discussion:**');
119
+ for (const d of record.discussion) {
120
+ out.push(`- [${toMarkdownDate(d.at)}] ${d.author ?? 'unknown'}: ${d.text}`);
121
+ }
122
+ }
123
+
124
+ out.push('');
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Render the markdown for a set of records.
130
+ * @param {object} data
131
+ * @param {Array<object>} data.ideas
132
+ * @param {Array<object>} data.clusters
133
+ * @returns {string}
134
+ */
135
+ export function renderIdeabox({ ideas, clusters }) {
136
+ const lines = [...BANNER, '', ...PREAMBLE, '', '## Ideas', ''];
137
+ // The legacy serializer emits a placeholder comment when the file contains no
138
+ // `###` grouping heading at all, and the projection has to match it byte for
139
+ // byte or `serialize(parse(projection))` stops being the identity. That state
140
+ // is not exotic: it is every brand-new ideabox, and any ideabox whose ideas
141
+ // have all been killed or promoted. Emitted below once both bucket counts are
142
+ // known.
143
+ const groupingPlaceholder = lines.length;
144
+
145
+ // Deterministic ordering everywhere: rendering twice must produce identical
146
+ // bytes, or the file churns in git on every unrelated write.
147
+ const ordered = [...clusters].sort(
148
+ (a, b) => (a.cluster_order ?? Number.MAX_SAFE_INTEGER) - (b.cluster_order ?? Number.MAX_SAFE_INTEGER)
149
+ || handleNumber(a.handle) - handleNumber(b.handle)
150
+ );
151
+
152
+ const live = ideas.filter((i) => i.status !== 'killed');
153
+ const killed = ideas.filter((i) => i.status === 'killed');
154
+ const byHandle = (a, b) => handleNumber(a.handle) - handleNumber(b.handle);
155
+
156
+ for (const cluster of ordered) {
157
+ const members = live.filter((i) => i.cluster === cluster.handle).sort(byHandle);
158
+ lines.push('---', '');
159
+ lines.push(`### ${cluster.title}`, '');
160
+ if (cluster.body) lines.push(`**Theme:** ${cluster.body}`, '');
161
+ for (const idea of members) lines.push(...renderIdea(idea));
162
+ }
163
+
164
+ const unclustered = live.filter((i) => !i.cluster).sort(byHandle);
165
+ if (unclustered.length) {
166
+ lines.push('---', '');
167
+ lines.push('### Unclustered', '');
168
+ for (const idea of unclustered) lines.push(...renderIdea(idea));
169
+ }
170
+
171
+ // A live idea belongs to exactly one bucket: a cluster whose handle it names,
172
+ // or Unclustered. An idea whose `cluster` holds anything else — most easily a
173
+ // cluster NAME where a handle belongs, which the CLI accepts as free text —
174
+ // matches neither filter and is silently absent from the file while its record
175
+ // sits on disk. The projection is the only surface most readers ever see, so
176
+ // that reads as deletion.
177
+ //
178
+ // Refusing is correct rather than merely safe: the alternative is to invent a
179
+ // home for the idea, and a projection that guesses is a projection nobody can
180
+ // trust. The write is abandoned before it starts, so the previous good file
181
+ // survives to be re-rendered once the reference is fixed.
182
+ if (ordered.length === 0 && unclustered.length === 0) {
183
+ lines.splice(groupingPlaceholder, 0, '<!-- Ideas grouped by potential feature cluster -->', '');
184
+ }
185
+
186
+ const orphans = live.filter((i) => i.cluster && !clusters.some((c) => c.handle === i.cluster));
187
+ if (orphans.length) {
188
+ throw new Error(
189
+ `fluid: cannot render the ideabox — ${orphans.length} idea(s) name a cluster that ` +
190
+ `does not exist, and would silently vanish from the projection: ` +
191
+ orphans.map((i) => `${i.handle} → "${i.cluster}"`).join(', ') +
192
+ `. Point each at a real cluster handle (or clear it) and render again.`
193
+ );
194
+ }
195
+
196
+ lines.push('## Killed Ideas', '');
197
+ for (const idea of killed.sort(byHandle)) lines.push(...renderIdea(idea));
198
+
199
+ return lines.join('\n');
200
+ }
201
+
202
+ /**
203
+ * Read the records from a provider and render.
204
+ * @param {import('./provider.js').FluidProvider} provider
205
+ */
206
+ export async function renderIdeaboxFrom(provider) {
207
+ const [ideas, clusters] = await Promise.all([
208
+ provider.listRecords({ kind: KIND.IDEA }),
209
+ provider.listRecords({ kind: KIND.CLUSTER }),
210
+ ]);
211
+ return renderIdeabox({ ideas, clusters });
212
+ }
213
+
214
+ /**
215
+ * Render and write atomically (temp + rename), following the roadmap-gen
216
+ * pattern — a half-written projection of canonical data is worse than none.
217
+ *
218
+ * SERIALIZED AGAINST OTHER RENDERS, not just internally atomic.
219
+ *
220
+ * Atomicity alone leaves a real race once there are two writers (the CLI and the
221
+ * REST API, from S3b-2). Each mutation is individually locked inside the
222
+ * provider, but the render is a separate read-then-publish: writer A can read a
223
+ * snapshot, writer B can then mutate AND publish a newer projection, and A's
224
+ * rename lands last with the older content. Canon is untouched — the records are
225
+ * still right — but the generated file stays wrong until the next write, and it
226
+ * is the surface humans read.
227
+ *
228
+ * Taking the provider's mutation lock around read-and-publish closes it without
229
+ * needing a reentrant lock: the mutation has already released by the time this
230
+ * runs, and whichever render acquires last re-reads the current records, so the
231
+ * file that lands last is the correct one.
232
+ *
233
+ * A provider with no lock (SmartMemory — see `factory.js`) renders unserialized,
234
+ * which is the documented state of that provider rather than a new gap.
235
+ */
236
+ export async function writeIdeaboxProjection(provider, outPath) {
237
+ return provider.lockPath
238
+ ? withDirLock(provider.lockPath, () => publishProjection(provider, outPath))
239
+ : publishProjection(provider, outPath);
240
+ }
241
+
242
+ async function publishProjection(provider, outPath) {
243
+ // Rendered BEFORE the destination is touched, so a render that refuses (an
244
+ // idea naming a cluster that does not exist) leaves the previous good file
245
+ // exactly where it was rather than replacing it with a partial view.
246
+ const markdown = await renderIdeaboxFrom(provider);
247
+ mkdirSync(dirname(outPath), { recursive: true });
248
+ // randomUUID, not pid: two renders from one process must not collide on the
249
+ // temp name, and a recycled pid must not adopt a stranded file. Cleaned up on
250
+ // failure because this directory is tracked — a leftover `.ideabox.md.tmp.*`
251
+ // is named to be committed by accident.
252
+ const tmp = join(dirname(outPath), `.ideabox.md.tmp.${randomUUID()}`);
253
+ try {
254
+ writeFileSync(tmp, markdown, 'utf8');
255
+ renameSync(tmp, outPath);
256
+ } catch (err) {
257
+ rmSync(tmp, { force: true });
258
+ throw err;
259
+ }
260
+ return markdown;
261
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * lib/fluid/schema.js — fluid-record contract loader.
3
+ *
4
+ * Mirrors lib/judgment/schema.js: the contract is memoized and callers validate
5
+ * against a NAMED DEFINITION rather than the root.
6
+ *
7
+ * This exists because "the seam validates its records" has to be executable to
8
+ * be true. Hand-rolled field checks drift from the published contract the moment
9
+ * either changes, and the drift is invisible — the code keeps accepting what the
10
+ * contract forbids while the contract keeps claiming otherwise.
11
+ */
12
+ import { dirname, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { SchemaValidator } from '../../server/schema-validator.js';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ export const FLUID_SCHEMA_PATH = resolve(__dirname, '../../contracts/fluid-record.schema.json');
19
+
20
+ let _validator = null;
21
+
22
+ export function getFluidValidator() {
23
+ if (!_validator) _validator = new SchemaValidator(FLUID_SCHEMA_PATH);
24
+ return _validator;
25
+ }
26
+
27
+ function describe(errors) {
28
+ return errors
29
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
30
+ .join('; ');
31
+ }
32
+
33
+ /** Validate against a definition, or throw with the schema's own complaint. */
34
+ export function assertValid(defName, obj, what = defName) {
35
+ const { valid, errors } = getFluidValidator().validate(defName, obj);
36
+ if (!valid) {
37
+ throw new Error(`fluid: invalid ${what} — ${describe(errors)}`);
38
+ }
39
+ return obj;
40
+ }