@smartmemory/compose 0.4.1 → 0.5.1

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 (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -34,9 +34,16 @@
34
34
  import { existsSync, readFileSync } from 'node:fs';
35
35
 
36
36
  import { parseIdeabox } from '../ideabox.js';
37
+ import { hashMarkdown, readManifest } from './ideabox-manifest.js';
38
+ // FU-2: the readability check lives in its own leaf module so the PARSER's own
39
+ // module can import it without closing a cycle through this one. Re-exported
40
+ // here because this was its address for its whole life so far.
41
+ import { IdeaboxUnreadable, assertIdeaboxReadable } from './ideabox-readable.js';
37
42
  import { importIdeabox } from './import-ideabox.js';
38
43
  import { KIND } from './provider.js';
39
44
 
45
+ export { IdeaboxUnreadable, assertIdeaboxReadable };
46
+
40
47
  export class IdeaboxMigrationConflict extends Error {
41
48
  constructor(missing, ideaboxPath) {
42
49
  super(
@@ -54,43 +61,129 @@ export class IdeaboxMigrationConflict extends Error {
54
61
  }
55
62
 
56
63
  /**
57
- * Ensure the record store reflects the markdown before any mutation touches it.
64
+ * An interrupted migration whose source document has changed underneath it.
58
65
  *
59
- * Runs before every mutating ideabox command. Three states, one of which stops
60
- * the command:
66
+ * Distinct from a conflict: the entries are ones this store PLANNED to write,
67
+ * so they are not strays — but the plan was made against a different version of
68
+ * the file, and importing the current one would import a document nobody
69
+ * checked. FU-1.
70
+ */
71
+ export class IdeaboxManifestStale extends Error {
72
+ constructor(ideaboxPath) {
73
+ super(
74
+ `compose: the ideabox at ${ideaboxPath} has an unfinished migration recorded against a ` +
75
+ `DIFFERENT version of this file — the document was most likely edited while the migration ` +
76
+ `was running, or after it was interrupted. Resuming would import a version nobody checked, ` +
77
+ `and the entries added since would be projected away. Nothing has been changed. Decide which ` +
78
+ `document is right and say so, with one of:\n` +
79
+ ` compose ideabox adopt-file the file on disk is right — finish the migration against ` +
80
+ `it, updating what was already imported to match and importing the rest\n` +
81
+ ` compose ideabox discard-edits the migration is right — put the file back as it was read ` +
82
+ `and finish it (a copy of the current file is saved first, and its path printed)\n` +
83
+ `Deleting the record of the unfinished migration is NOT a shortcut: it makes the partially ` +
84
+ `migrated store canon, and the next render replaces this file with a projection of it.`
85
+ );
86
+ this.name = 'IdeaboxManifestStale';
87
+ this.code = 'IDEABOX_MANIFEST_STALE';
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The source document changed between the gate's check and the projection's
93
+ * write. FU-3.
61
94
  *
62
- * - **no records, markdown has entries** import it (the upgrade path)
63
- * - **records exist, markdown adds nothing** already migrated, proceed
64
- * - **records exist, markdown has entries with no record** refuse
95
+ * The remedy is genuinely different from a conflict's nothing is wrong with
96
+ * the file or the store, the two writers simply interleaved so it does not
97
+ * reuse `IdeaboxMigrationConflict`, whose text tells the user to re-add ideas
98
+ * by hand.
99
+ */
100
+ export class IdeaboxChangedUnderLock extends Error {
101
+ constructor(ideaboxPath, reason) {
102
+ super(
103
+ `compose: the ideabox at ${ideaboxPath} changed between the check and the write ` +
104
+ `(${reason}), so the projection was abandoned rather than replacing content that was ` +
105
+ `never checked. Nothing has been changed. Run the command again.`
106
+ );
107
+ this.name = 'IdeaboxChangedUnderLock';
108
+ this.code = 'IDEABOX_CHANGED_UNDER_LOCK';
109
+ this.reason = reason;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * PURE. What the gate WOULD do, computed without doing any of it.
65
115
  *
66
- * A fresh project with no markdown and no records is the trivial first case and
67
- * simply proceeds.
116
+ * Split out of `ensureIdeaboxMigrated` for FU-3: the projection has to re-run
117
+ * this decision INSIDE its write lock, and it can only do that if making the
118
+ * decision writes nothing and takes no lock. Reads exactly three things —
119
+ * `listRecords`, `readEvents`, the markdown — none of which lock, so running it
120
+ * under the projection lock cannot deadlock the way moving the whole gate under
121
+ * it does (measured: a full 30s lock timeout, see `render-ideabox.js`).
68
122
  *
69
- * @param {import('./provider.js').FluidProvider} provider
70
- * @param {string} ideaboxPath absolute path to the markdown ideabox
71
- * @returns {Promise<{migrated: boolean, imported: string[]}>}
123
+ * Refusals are RETURNED, not thrown, because a decision procedure that throws
124
+ * cannot be used to ask a question.
125
+ *
126
+ * @returns {Promise<{action: 'none'|'import', markdown?: string} |
127
+ * {action: 'refuse', reason: string, error: Error}>}
72
128
  */
73
- export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
129
+ export async function assessIdeabox(provider, ideaboxPath) {
74
130
  const records = await provider.listRecords({ kind: KIND.IDEA });
75
131
  const markdown = existsSync(ideaboxPath) ? readFileSync(ideaboxPath, 'utf8') : null;
76
132
 
77
- if (markdown === null) return { migrated: false, imported: [] };
133
+ if (markdown === null) return { action: 'none' };
78
134
 
79
135
  const parsed = parseIdeabox(markdown);
80
136
  const inMarkdown = [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id);
81
137
 
138
+ try {
139
+ assertIdeaboxReadable(parsed, ideaboxPath);
140
+ } catch (error) {
141
+ return { action: 'refuse', reason: 'unreadable', error };
142
+ }
143
+
82
144
  const known = new Set(records.map((r) => r.handle));
83
145
  const missing = inMarkdown.filter((id) => !known.has(id));
84
146
 
85
147
  if (records.length === 0) {
86
- if (inMarkdown.length === 0) return { migrated: false, imported: [] };
148
+ if (inMarkdown.length === 0) return { action: 'none' };
87
149
  // The upgrade path.
88
- const result = await importIdeabox(provider, { markdown, path: ideaboxPath });
89
- return { migrated: true, imported: result.imported };
150
+ return { action: 'import', markdown };
151
+ }
152
+
153
+ // AN OPEN MANIFEST MEANS THE MIGRATION NEVER FINISHED, and that is a fact
154
+ // about the DOCUMENT, not only about the handles that happen to be absent.
155
+ //
156
+ // Scoping this to the missing-handles branch was wrong in a way the ID
157
+ // comparison hides: an edit to an idea that ALREADY has a record changes no
158
+ // ID, so nothing is missing, and the gate waved a file through that the
159
+ // projection then rewrote from the version read at the start of the import.
160
+ // Until the migration completes this file is still the SOURCE document, so
161
+ // the question here is whether it is the same document, not whether its ids
162
+ // line up. (After the migration there is no manifest, so `render` still
163
+ // discards hand edits to what is by then generated output — that contract is
164
+ // untouched.)
165
+ const manifest = readManifest(provider, ideaboxPath);
166
+ const planned = new Set();
167
+ if (manifest) {
168
+ if (manifest.hash !== hashMarkdown(markdown)) {
169
+ // Planned against a different document. Every handle it names is suspect,
170
+ // and so is every handle in the file now.
171
+ return {
172
+ action: 'refuse',
173
+ reason: 'manifest-stale',
174
+ error: new IdeaboxManifestStale(ideaboxPath),
175
+ };
176
+ }
177
+ for (const handle of manifest.planned) planned.add(handle);
178
+ // Same document, unfinished import. Finishing it is a no-op for handles
179
+ // that already landed, and it is what closes the manifest — so a crash
180
+ // between the last write and the close heals on the next command instead of
181
+ // leaving the store in a state every later check has to reason about.
182
+ if (missing.length === 0) return { action: 'import', markdown };
90
183
  }
91
184
 
92
185
  if (missing.length) {
93
- // RESUME versus REFUSE, decided PER HANDLE from the events log.
186
+ // RESUME versus REFUSE, decided PER HANDLE.
94
187
  //
95
188
  // A crash partway through the first-use import leaves some records written
96
189
  // and the rest missing, which lands here rather than in the empty-store
@@ -99,29 +192,84 @@ export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
99
192
  // fails and there is no way out — and the reclaim path built for exactly
100
193
  // this case is never reached.
101
194
  //
102
- // The log distinguishes the two populations. A handle the import already
103
- // burned carries an event; `importIdeabox` skips live records and reclaims
104
- // its own aborted allocations, so resuming is safe and lossless.
105
- //
106
195
  // The evidence has to be per-handle, not "did an import ever run". Once the
107
196
  // first import succeeds the log carries `imported` events forever, so a
108
197
  // global check would quietly import anything later hand-added to what is
109
198
  // now generated output — losing the very protection this gate exists for.
110
- // A handle with no event was never issued here: it was typed into the file
111
- // by hand, and importing it would treat the markdown as authoritative when
112
- // it no longer is.
113
- // Three populations, and only one of them is resumable:
199
+ // A handle with no event and no manifest entry was never issued here: it
200
+ // was typed into the file by hand, and importing it would treat the
201
+ // markdown as authoritative when it no longer is.
202
+ //
203
+ // Two sources of evidence, because neither alone covers the whole failure:
204
+ //
205
+ // - THE EVENT LOG covers handles the import REACHED. `importIdeabox`
206
+ // skips live records and reclaims its own aborted allocations, so a
207
+ // handle that was issued and not deleted is a create that crashed, and
208
+ // resuming it is safe and lossless.
209
+ // - THE MANIFEST covers the handles it never reached. An import that dies
210
+ // on idea 2 leaves ideas 3..N with no event anywhere, and no amount of
211
+ // reading the log afterwards can distinguish them from strays — the
212
+ // intention has to have been written down first (FU-1).
213
+ //
214
+ // Three populations, and only two of them are resumable:
114
215
  // - issued, no `deleted` event → a create that crashed. RESUME.
216
+ // - planned by an OPEN manifest whose hash still matches this file
217
+ // → never attempted. RESUME.
115
218
  // - issued, `deleted` event → deliberately retired; this file is just
116
219
  // stale output. REFUSE (a render fixes it,
117
220
  // and importing would resurrect it).
118
- // - never issued → hand-typed into generated output. REFUSE.
221
+ // - neither → hand-typed into generated output. REFUSE.
119
222
  const { issued, deleted } = await handleHistory(provider);
120
- const resumable = missing.filter((id) => issued.has(id) && !deleted.has(id));
223
+
224
+ const resumable = missing.filter(
225
+ (id) => (issued.has(id) && !deleted.has(id)) || (planned.has(id) && !deleted.has(id)),
226
+ );
121
227
  const strays = missing.filter((id) => !resumable.includes(id));
122
- if (strays.length) throw new IdeaboxMigrationConflict(strays, ideaboxPath);
228
+ if (strays.length) {
229
+ return {
230
+ action: 'refuse',
231
+ reason: 'stray-entries',
232
+ error: new IdeaboxMigrationConflict(strays, ideaboxPath),
233
+ };
234
+ }
235
+
236
+ return { action: 'import', markdown };
237
+ }
238
+
239
+ return { action: 'none' };
240
+ }
241
+
242
+ /**
243
+ * Ensure the record store reflects the markdown before any mutation touches it.
244
+ *
245
+ * Runs before every mutating ideabox command. Three states, one of which stops
246
+ * the command:
247
+ *
248
+ * - **no records, markdown has entries** → import it (the upgrade path)
249
+ * - **records exist, markdown adds nothing** → already migrated, proceed
250
+ * - **records exist, markdown has entries with no record** → refuse
251
+ *
252
+ * A fresh project with no markdown and no records is the trivial first case and
253
+ * simply proceeds.
254
+ *
255
+ * Assess, then act. The decision is `assessIdeabox` and this is the only thing
256
+ * that executes it; the split exists so the projection can ask the same
257
+ * question under its lock (FU-3) without re-implementing the answer.
258
+ *
259
+ * @param {import('./provider.js').FluidProvider} provider
260
+ * @param {string} ideaboxPath absolute path to the markdown ideabox
261
+ * @returns {Promise<{migrated: boolean, imported: string[]}>}
262
+ */
263
+ export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
264
+ const assessment = await assessIdeabox(provider, ideaboxPath);
265
+
266
+ if (assessment.action === 'refuse') throw assessment.error;
123
267
 
124
- const result = await importIdeabox(provider, { markdown, path: ideaboxPath });
268
+ if (assessment.action === 'import') {
269
+ const result = await importIdeabox(provider, {
270
+ markdown: assessment.markdown,
271
+ path: ideaboxPath,
272
+ });
125
273
  return { migrated: true, imported: result.imported };
126
274
  }
127
275
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * lib/fluid/ideabox-preamble.js — a project's own heading and introduction,
3
+ * kept beside the projection rather than inside it.
4
+ *
5
+ * COMP-IDEABOX-MIGRATE-DIALECT FU-4.
6
+ *
7
+ * THE DEFECT
8
+ * ----------
9
+ * `renderIdeabox` emits a hardcoded template preamble, so the first projection
10
+ * after a migration replaced a project's own title and introductory prose with
11
+ * the standard one. Ideas, clusters, custom fields and bodies all survived; the
12
+ * document AROUND them did not. Measured on forge-top: `# Forge Ideabox` and
13
+ * its two-line introduction were destroyed.
14
+ *
15
+ * WHY A SIDECAR AND NOT A RECORD
16
+ * ------------------------------
17
+ * The obvious fix — carry the preamble forward from the file being replaced —
18
+ * was implemented and rejected, because it makes the DESTINATION authoritative
19
+ * and `render` is documented as the way back from any hand edit
20
+ * (`lib/ideabox-cli.js`). A projection that reads its own destination cannot
21
+ * repair it.
22
+ *
23
+ * The alternative was to make the preamble a record. The owner rejected that:
24
+ * it means a new ontology type in every SmartMemory tenant and a decision about
25
+ * what a remote store does with per-document text. The preamble is not a
26
+ * property of the idea corpus at all — it is a property of THIS FILE, and the
27
+ * file is local no matter which provider holds the records.
28
+ *
29
+ * The projection therefore stays independent of its destination: it is rendered
30
+ * from the records plus this sidecar, and a hand edit to the file's heading is
31
+ * still discarded by the next render, exactly as a hand edit to an idea is.
32
+ *
33
+ * WHY IT IS TRACKED, AND NOT BESIDE THE MANIFEST
34
+ * ----------------------------------------------
35
+ * The first version of this put the sidecar in gitignored `.compose/data/`,
36
+ * next to the migration manifest, keyed off `provider.lockPath`. That was
37
+ * wrong twice.
38
+ *
39
+ * It made a TRACKED projection depend on UNTRACKED input. The heading then
40
+ * survived only on the machine that ran the migration: any other clone — a
41
+ * teammate, CI — found no sidecar, rendered the template over the custom
42
+ * heading and committed that, and the migrating machine restored it on its next
43
+ * render. FU-4's own defect, recurring on every clone, plus git churn on a
44
+ * tracked file. This is the exact failure the owner already ruled on: records
45
+ * were moved OUT of gitignored `vision-state.json` in the S3 entry-gate ruling
46
+ * of 2026-08-04 (see the header of `local-provider.js`) because canon that a
47
+ * tracked file is generated from cannot itself be ignored. The manifest is a
48
+ * fair neighbour for none of this: it is TRANSIENT, alive only between the
49
+ * start and the end of one import, while the preamble is durable content with
50
+ * the same lifecycle as the records.
51
+ *
52
+ * And keying off `provider.lockPath` excluded SmartMemory, which has no lock —
53
+ * the very provider whose existence was the argument for a local sidecar rather
54
+ * than a record. Keyed off the ideabox path instead, every provider gets one.
55
+ *
56
+ * Beside the document it describes, it is also discoverable: the hand-written
57
+ * recovery below is a file someone can find, not a hashed name under a hidden
58
+ * directory. A deliberately named, deliberately committed file is not the stray
59
+ * temp file that tracked directories have to be protected from.
60
+ */
61
+
62
+ import { randomUUID } from 'node:crypto';
63
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
64
+ import { dirname } from 'node:path';
65
+
66
+ /**
67
+ * The preamble file for this ideabox: a tracked sibling of it.
68
+ *
69
+ * `docs/product/ideabox.md` → `docs/product/ideabox.preamble.md`. Derived from
70
+ * the ideabox path alone, so it does not depend on which provider holds the
71
+ * records and every provider gets one. Plain markdown rather than JSON so a
72
+ * project that has to write one by hand can just write it.
73
+ */
74
+ export function preamblePath(ideaboxPath) {
75
+ if (!ideaboxPath) return null;
76
+ return String(ideaboxPath).replace(/(\.md)?$/i, '.preamble.md');
77
+ }
78
+
79
+ /**
80
+ * The generated banner is not part of anyone's introduction.
81
+ *
82
+ * A document being imported is normally a legacy one and carries no banner, but
83
+ * a store whose records were removed re-imports its own PROJECTION — and the
84
+ * parser hands back everything before `## Ideas`, banner included. Capturing
85
+ * that would make the next render emit the banner twice, and the one after that
86
+ * three times.
87
+ */
88
+ function withoutBanner(text) {
89
+ const lines = String(text ?? '').split('\n');
90
+ if (!/^\s*<!--/.test(lines[0] ?? '') || !/GENERATED FILE/.test(lines[0] ?? '')) return text;
91
+ const end = lines.findIndex((l) => /-->/.test(l));
92
+ if (end === -1) return text;
93
+ return lines.slice(end + 1).join('\n').replace(/^\n+/, '');
94
+ }
95
+
96
+ /**
97
+ * Capture the source document's own preamble.
98
+ *
99
+ * @param {string} ideaboxPath
100
+ * @param {string} text `parseIdeabox(...).preamble` — the parser's own output,
101
+ * never a second scan of the same document. Two readers of one document is
102
+ * the exact shape that produced this whole bug class.
103
+ * @returns {string|null} the path written, or null when there is nothing to
104
+ * write or nowhere to write it
105
+ */
106
+ export function writePreamble(ideaboxPath, text) {
107
+ const path = preamblePath(ideaboxPath);
108
+ if (!path) return null;
109
+ const body = withoutBanner(text);
110
+ if (!body || !body.trim()) return null;
111
+ // Nothing to do when it already says this. Same reasoning as the manifest: a
112
+ // rewrite that changes nothing is a window in which a crash can lose
113
+ // something, for no gain.
114
+ if (readPreamble(ideaboxPath) === body) return path;
115
+
116
+ mkdirSync(dirname(path), { recursive: true });
117
+ const tmp = `${path}.tmp.${randomUUID()}`;
118
+ try {
119
+ writeFileSync(tmp, body, 'utf8');
120
+ renameSync(tmp, path);
121
+ } catch (err) {
122
+ rmSync(tmp, { force: true });
123
+ throw err;
124
+ }
125
+ return path;
126
+ }
127
+
128
+ /**
129
+ * The captured preamble, or null.
130
+ *
131
+ * Null is the ordinary answer for a project that migrated before this existed,
132
+ * and it means the renderer falls back to the standard template — which is what
133
+ * that project already has in its file, so nothing changes for it.
134
+ */
135
+ export function readPreamble(ideaboxPath) {
136
+ const path = preamblePath(ideaboxPath);
137
+ if (!path || !existsSync(path)) return null;
138
+ try {
139
+ const text = readFileSync(path, 'utf8');
140
+ if (!text.trim()) return null;
141
+ // Trailing blank lines are structural, not content — the renderer puts its
142
+ // own separator before `## Ideas`, and the parser pops them on the way back
143
+ // (`lib/ideabox.js`), so leaving one here costs the fixed point that the
144
+ // whole cutover rests on. `writePreamble` never stores one because it
145
+ // stores what the parser already popped; a sidecar written BY HAND does,
146
+ // because every editor ends a file with a newline — and writing one by hand
147
+ // is the documented recovery for a project that migrated before this
148
+ // existed.
149
+ const lines = text.split('\n');
150
+ while (lines.length && lines.at(-1).trim() === '') lines.pop();
151
+ return lines.length ? lines.join('\n') : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * lib/fluid/ideabox-readable.js — ONE readability check, for every caller.
3
+ *
4
+ * COMP-IDEABOX-MIGRATE-DIALECT FU-2.
5
+ *
6
+ * The original bug was a parse that failed being read as "this file has no
7
+ * ideas", after which the next projection write replaced the file with a
8
+ * projection that did not contain them — 18 ideas destroyed by one command.
9
+ * The fix for that bug put the readability check in the MIGRATION GATE. That
10
+ * guarded the destructive path but not the parse: every other caller of
11
+ * `parseIdeabox` still read failure as absence.
12
+ *
13
+ * This module is the check itself, extracted so there is one implementation
14
+ * instead of one per caller. It is a LEAF on purpose: it takes an already
15
+ * parsed document and knows nothing about providers, stores or files.
16
+ * `lib/ideabox.js` (the parser) has to be able to import it, and
17
+ * `ideabox-migrate.js` already imports `parseIdeabox` FROM `lib/ideabox.js` —
18
+ * so keeping the assertion in the gate and wiring it into `readIdeabox` would
19
+ * close an import cycle. Hence its own module, re-exported from
20
+ * `ideabox-migrate.js` so existing importers are unaffected.
21
+ */
22
+
23
+ export class IdeaboxUnreadable extends Error {
24
+ constructor(unread, ideaboxPath) {
25
+ super(
26
+ `compose: the ideabox at ${ideaboxPath} declares ${unread.length} idea(s) this version cannot ` +
27
+ `read: ${unread.join(', ')}. Refusing rather than proceeding, because continuing would treat ` +
28
+ `them as absent and the next write would overwrite this file with a projection that does not ` +
29
+ `contain them. This usually means the file is in a dialect newer or older than this install, ` +
30
+ `or is partly converted. Nothing has been changed. Back the file up, then either upgrade ` +
31
+ `compose or convert the entries by hand.`
32
+ );
33
+ this.name = 'IdeaboxUnreadable';
34
+ this.code = 'IDEABOX_UNREADABLE';
35
+ this.unread = unread;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Throw unless the parse actually understood the document.
41
+ *
42
+ * @param {object} parsed a `parseIdeabox` result
43
+ * @param {string} ideaboxPath named in the error, for the human who has to fix it
44
+ * @returns {object} the same `parsed`, so callers can `return assertIdeaboxReadable(...)`
45
+ */
46
+ export function assertIdeaboxReadable(parsed, ideaboxPath) {
47
+ const inMarkdown = [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id);
48
+
49
+ // READABILITY BEFORE SEMANTICS.
50
+ //
51
+ // Every caller reads `inMarkdown` as a statement about what the user has.
52
+ // That is only true if the parse actually understood the file. When it did
53
+ // not, an id vanishes from `inMarkdown` and every branch silently reads its
54
+ // absence as consent — "no ideas here" — and the next write projects over it.
55
+ // That is not a hypothetical: it destroyed 18 ideas in one command
56
+ // (COMP-IDEABOX-MIGRATE-DIALECT).
57
+ //
58
+ // So compare what the file DECLARES against what the parser PRODUCED, and
59
+ // stop on any gap. This deliberately catches more than the empty parse that
60
+ // motivated it: a half-converted file yields some ideas and hides the rest,
61
+ // which every count-based check (`inMarkdown.length === 0`) waves straight
62
+ // through while it is just as destructive.
63
+ // What the parse could see but not read. Taken from the parser itself, not
64
+ // from a second scan of the same text: two readers of one document is the
65
+ // exact shape that produced this bug and three of its follow-ons.
66
+ const unread = [...new Set(parsed.unconsumed ?? [])].filter((id) => !inMarkdown.includes(id));
67
+ if (unread.length) throw new IdeaboxUnreadable(unread, ideaboxPath);
68
+
69
+ // The same id declared twice is the half-converted document: the parser reads
70
+ // one copy, so the check above is satisfied while the other — routinely the
71
+ // older, richer one — is invisible and would be deleted by the next render.
72
+ const seen = new Set();
73
+ const duplicated = [...new Set([
74
+ ...[...inMarkdown, ...(parsed.unconsumed ?? [])].filter((id) => seen.size === seen.add(id).size),
75
+ // An umbrella named after an idea that also exists here as a real idea:
76
+ // the half-converted document, where the richer original survives only as
77
+ // the heading the parser turned into a cluster.
78
+ ...(parsed.collisions ?? []),
79
+ ])];
80
+ if (duplicated.length) throw new IdeaboxUnreadable(duplicated, ideaboxPath);
81
+
82
+ return parsed;
83
+ }