@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
@@ -0,0 +1,393 @@
1
+ /**
2
+ * lib/fluid/ideabox-recover.js — the supported way out of a stranded ideabox.
3
+ *
4
+ * COMP-IDEABOX-MIGRATE-DIALECT, the escape hatch FU-1 left open.
5
+ *
6
+ * THE STATE THIS EXISTS FOR
7
+ * -------------------------
8
+ * A migration is interrupted, and the document is edited before it is resumed.
9
+ * The manifest is open, its hash no longer matches the file, and the gate
10
+ * refuses — correctly, because resuming would import a document nobody checked
11
+ * and project away whatever was added. But EVERY ideabox command runs that
12
+ * gate, so the project is stranded, and the only exit needing no tooling was
13
+ * destructive: delete the manifest, and the partially migrated store becomes
14
+ * canon so the next render replaces the file.
15
+ *
16
+ * That is FU-1's stranding shape with a worse escape hatch. The refusal is
17
+ * right; having no supported way out is the defect.
18
+ *
19
+ * WHAT THE TWO COMMANDS MEAN
20
+ * --------------------------
21
+ * Both say "I have decided which document is right", which is precisely the
22
+ * judgement the gate refuses to make on the user's behalf:
23
+ *
24
+ * - `adoptFile` — the file on disk is right. Finish the migration against
25
+ * it, updating already-imported records to match.
26
+ * - `discardEdits` — the migration is right. Put the file back as it was
27
+ * read and finish that, after saving a copy first.
28
+ *
29
+ * WHY THESE DO NOT RUN THE GATE
30
+ * -----------------------------
31
+ * Every other mutation calls it first (`ideabox-ops.js`, invariant 1). These
32
+ * two cannot: the gate throws `IDEABOX_MANIFEST_STALE`, which is the state they
33
+ * exist to leave. They are the one deliberate exception, and they are narrower
34
+ * than the gate rather than wider — they refuse unless the project is ACTUALLY
35
+ * stranded, so neither is a general "make the file canon" door.
36
+ */
37
+
38
+ import { randomUUID } from 'node:crypto';
39
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
40
+ import { dirname, join } from 'node:path';
41
+
42
+ import { parseIdeabox } from '../ideabox.js';
43
+ import {
44
+ hashMarkdown,
45
+ manifestPath,
46
+ openManifest,
47
+ readManifest,
48
+ } from './ideabox-manifest.js';
49
+ import { ensureIdeaboxMigrated } from './ideabox-migrate.js';
50
+ import { assertIdeaboxReadable } from './ideabox-readable.js';
51
+ import { ideaToRecord } from './import-ideabox.js';
52
+ import { KIND } from './provider.js';
53
+ import { UNPATCHABLE, normalizeRecord } from './record-shape.js';
54
+ import { writeIdeaboxProjection } from './render-ideabox.js';
55
+
56
+ /** Nothing to recover: the project is not in the stranded state. */
57
+ export class IdeaboxNotStranded extends Error {
58
+ constructor(ideaboxPath, why) {
59
+ super(
60
+ `compose: nothing to recover for ${ideaboxPath} — ${why}. These commands exist only to ` +
61
+ `resolve a migration that was interrupted and whose source document then changed. Ordinary ` +
62
+ `hand edits to a migrated ideabox are not recovered here: that file is generated output, and ` +
63
+ `\`compose ideabox render\` is the way back from an edit to it.`
64
+ );
65
+ this.name = 'IdeaboxNotStranded';
66
+ this.code = 'IDEABOX_NOT_STRANDED';
67
+ }
68
+ }
69
+
70
+ /** The manifest predates the stored source text, so there is nothing to restore. */
71
+ export class IdeaboxNoStoredSource extends Error {
72
+ constructor(ideaboxPath) {
73
+ super(
74
+ `compose: cannot discard the edits to ${ideaboxPath} — the interrupted migration was ` +
75
+ `recorded by an older version of compose that did not keep a copy of the document it read, ` +
76
+ `so there is nothing to put back. Discarding would delete the current text and restore ` +
77
+ `nothing. Use \`compose ideabox adopt-file\` to finish the migration against the file as it ` +
78
+ `now stands; the entries it already imported are unaffected either way.`
79
+ );
80
+ this.name = 'IdeaboxNoStoredSource';
81
+ this.code = 'IDEABOX_NO_STORED_SOURCE';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The stranded state, or null.
87
+ *
88
+ * ONE definition, shared by both commands and by nothing else. Stranded means:
89
+ * an open manifest exists, the file exists, and the manifest's hash does not
90
+ * match it. Every other shape is somebody else's job —
91
+ *
92
+ * - no manifest → migration finished (or never ran). `render` is the
93
+ * repair path for a hand edit to generated output.
94
+ * - hash MATCHES → an interrupted migration with an untouched document,
95
+ * which `ensureIdeaboxMigrated` already resumes on its
96
+ * own. Recovering it here would be a second mechanism
97
+ * for a case that has one.
98
+ * - no lock path → no manifest is ever written (SmartMemory), so such a
99
+ * project cannot reach this state at all.
100
+ */
101
+ export function strandedState(provider, ideaboxPath) {
102
+ const manifest = readManifest(provider, ideaboxPath);
103
+ if (!manifest) return null;
104
+ if (!existsSync(ideaboxPath)) return null;
105
+ const markdown = readFileSync(ideaboxPath, 'utf8');
106
+ if (manifest.hash === hashMarkdown(markdown)) return null;
107
+ return { manifest, markdown };
108
+ }
109
+
110
+ function requireStranded(provider, ideaboxPath) {
111
+ const state = strandedState(provider, ideaboxPath);
112
+ if (state) return state;
113
+ const manifest = readManifest(provider, ideaboxPath);
114
+ if (!manifest) throw new IdeaboxNotStranded(ideaboxPath, 'no interrupted migration is recorded');
115
+ if (!existsSync(ideaboxPath)) throw new IdeaboxNotStranded(ideaboxPath, 'the file does not exist');
116
+ throw new IdeaboxNotStranded(
117
+ ideaboxPath,
118
+ 'the interrupted migration matches the file, so re-running any ideabox command finishes it',
119
+ );
120
+ }
121
+
122
+ /**
123
+ * `compose ideabox discard-edits` — the migration is right.
124
+ *
125
+ * `onBackup` is called with the copy's path BEFORE the file is overwritten, not
126
+ * returned at the end. The return value only reaches the caller when everything
127
+ * after the overwrite also succeeded, and the resume that follows can throw —
128
+ * at which point the user has been told their text is gone and NOT where the
129
+ * copy is, which is the one moment the path matters. The error message this
130
+ * command is reached from promises the path is printed; a promise kept only on
131
+ * the happy path is not kept.
132
+ *
133
+ * @returns {Promise<{backup: string|null, result: object}>}
134
+ */
135
+ export async function discardEdits(provider, ideaboxPath, { onBackup = null } = {}) {
136
+ const { manifest, markdown } = requireStranded(provider, ideaboxPath);
137
+ if (typeof manifest.text !== 'string') throw new IdeaboxNoStoredSource(ideaboxPath);
138
+
139
+ // BEFORE ANYTHING IS WRITTEN, including the backup. The stored text was
140
+ // readable when the manifest was opened — the gate proved it — but the
141
+ // manifest is a file on disk like any other, and restoring a document the
142
+ // parser can only half read would patch records toward that subset and then
143
+ // refuse. Checked here, the failure changes nothing at all.
144
+ const restoring = assertIdeaboxReadable(parseIdeabox(manifest.text), ideaboxPath);
145
+
146
+ // THE COPY COMES FIRST, and its path is returned so the caller can print it.
147
+ //
148
+ // "Discard" is a decision someone can make in a hurry, and this is the one
149
+ // command in the ideabox that deliberately destroys text. Refusing to lose it
150
+ // anyway costs one file. Unlike the FU-4 preamble this copy IS transient — it
151
+ // is a safety net for one command, not an input to the projection — so
152
+ // gitignored `.compose/data/` is its right home rather than a tracked sibling.
153
+ const backup = writeBackup(provider, ideaboxPath, markdown);
154
+ if (backup && onBackup) onBackup(backup);
155
+
156
+ // PUT THE RECORDS BACK TOO, not only the file.
157
+ //
158
+ // The two commands can be run in sequence: an `adopt-file` that crashes after
159
+ // its reconcile has already patched records to match the edited document, and
160
+ // the user then changes their mind. Restoring the markdown alone does not undo
161
+ // that — the ordinary import SKIPS records that already exist, so the edited
162
+ // body survived in the store and the very next projection wrote it back into
163
+ // the file that had just been restored. A discard that silently keeps the
164
+ // edits is the content loss this whole feature exists to prevent, inverted.
165
+ //
166
+ // Reconciling BEFORE the overwrite is what keeps a crash here safe: until the
167
+ // file is restored the project is still stranded, so a rerun of this command
168
+ // is still allowed and the reconcile is idempotent. Do it after, and a crash
169
+ // in between leaves a project that is no longer stranded, refuses to discard,
170
+ // and quietly holds the edits.
171
+ const reverted = await reconcileRecords(provider, ideaboxPath, restoring);
172
+
173
+ writeFileAtomic(ideaboxPath, manifest.text);
174
+ // The hash matches again, so the ORDINARY resume path finishes the job. No
175
+ // second import mechanism: recovery hands the existing one a state it can
176
+ // already handle.
177
+ const result = await ensureIdeaboxMigrated(provider, ideaboxPath);
178
+ await writeIdeaboxProjection(provider, ideaboxPath);
179
+ return { backup, result, reverted, leftover: await leftoverFrom(provider, restoring) };
180
+ }
181
+
182
+ /**
183
+ * What an interrupted adoption left in the store that the restored document
184
+ * does not name — reported, never removed.
185
+ *
186
+ * Discarding puts back every field it can, but two things it cannot take back,
187
+ * and BOTH are deliberate rather than missing:
188
+ *
189
+ * - AN UMBRELLA the adoption created. Deleting records is the one thing this
190
+ * whole feature refuses to do, and the renderer emits an empty umbrella on
191
+ * purpose (a project may create one before filing anything into it), so a
192
+ * leftover shows up in the file with nothing under it.
193
+ * - A DISCUSSION ENTRY typed into the document during the outage. The trail is
194
+ * append-only on the seam because it is evidence, so an entry that reached a
195
+ * record stays on it.
196
+ *
197
+ * Neither loses anything the user had; both leave something they may not expect.
198
+ * Silence is what would make that a defect, so the command says so.
199
+ */
200
+ async function leftoverFrom(provider, restoring) {
201
+ const named = new Set((restoring.clusters ?? []).map((c) => c.name));
202
+ const clusters = (await provider.listRecords({ kind: KIND.CLUSTER }))
203
+ .filter((c) => !named.has(c.title))
204
+ .map((c) => c.title);
205
+
206
+ const inDoc = new Map(
207
+ [...(restoring.ideas ?? []), ...(restoring.killed ?? [])]
208
+ .map((i) => [i.id, (i.discussion ?? []).length]),
209
+ );
210
+ const discussed = (await provider.listRecords({ kind: KIND.IDEA }))
211
+ .filter((r) => (r.discussion ?? []).length > (inDoc.get(r.handle) ?? 0))
212
+ .map((r) => r.handle);
213
+
214
+ return { clusters, discussed };
215
+ }
216
+
217
+ /**
218
+ * `compose ideabox adopt-file` — the file on disk is right.
219
+ *
220
+ * The step order is fixed and it is what makes a crash mid-recovery safe:
221
+ * a crash between 2 and 3 leaves the manifest stale, so this command reruns and
222
+ * step 2 is idempotent; a crash after 3 is an ordinary resumable migration with
223
+ * the updates already applied.
224
+ *
225
+ * @returns {Promise<{updated: string[], discussed: string[], imported: string[], kept: string[], reclustered: string[]}>}
226
+ */
227
+ export async function adoptFile(provider, ideaboxPath) {
228
+ const { markdown } = requireStranded(provider, ideaboxPath);
229
+
230
+ // 1. READABLE FIRST, changing nothing. Adopting a document the parser cannot
231
+ // fully read would import the subset it understood and project away the
232
+ // rest — the original bug, performed deliberately.
233
+ const parsed = assertIdeaboxReadable(parseIdeabox(markdown), ideaboxPath);
234
+
235
+ // 2. Reconcile what is already in the store against what the file now says.
236
+ const { updated, discussed, kept, reclustered } = await reconcileRecords(provider, ideaboxPath, parsed);
237
+
238
+ // 3. Re-plan, atomically, against the document being adopted.
239
+ openManifest(provider, ideaboxPath, {
240
+ markdown,
241
+ planned: [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id),
242
+ plannedClusters: (parsed.clusters ?? []).map((c) => c.name),
243
+ });
244
+ // NOT recapturing the preamble here, though the recovery must recapture it:
245
+ // step 4's import already does (`import-ideabox.js` writes it from the
246
+ // document it is importing, which after step 3 is this one). A second call
247
+ // would be a redundant writer of the same file, and the version of this bug
248
+ // that keeps recurring is two writers of one thing drifting apart. Pinned by
249
+ // the adopt-file preamble test, which fails if EITHER writer stops.
250
+
251
+ // 4. The hash matches now, so the ordinary resume path imports the handles
252
+ // that were never reached — including any the user added by hand, which
253
+ // the re-planned manifest now vouches for.
254
+ const { imported } = await ensureIdeaboxMigrated(provider, ideaboxPath);
255
+ await writeIdeaboxProjection(provider, ideaboxPath);
256
+ return { updated, discussed, imported, kept, reclustered };
257
+ }
258
+
259
+ /**
260
+ * Step 2, exported so a test can interrupt the command exactly here.
261
+ *
262
+ * IDEMPOTENT BY CONSTRUCTION: it patches only fields that differ, so a second
263
+ * pass over an unchanged document reports nothing. That is not a nicety — the
264
+ * crash-safety argument for the step order depends on it.
265
+ *
266
+ * NEVER DELETES. A handle in the store and absent from the file is kept and
267
+ * reported. Removing it would be exactly the silent-loss guess the gate exists
268
+ * to refuse, and the user may have deleted the line by accident.
269
+ */
270
+ export async function reconcileRecords(provider, ideaboxPath, parsed) {
271
+ const provenance = { origin: 'import:ideabox' };
272
+ const stored = await provider.listRecords({ kind: KIND.IDEA });
273
+ const byHandle = new Map(stored.map((r) => [r.handle, normalizeRecord(r)]));
274
+
275
+ // Clusters first, exactly as the import does: an idea the user moved into a
276
+ // NEW umbrella has nowhere to point until that umbrella exists, and a null
277
+ // cluster here would silently unfile it. `findOrCreateRecord` is the seam's
278
+ // atomic lookup-or-create, so this is idempotent across reruns.
279
+ const clusterHandleByName = new Map();
280
+ const reclustered = [];
281
+ for (const cluster of parsed.clusters ?? []) {
282
+ const { record } = await provider.findOrCreateRecord(
283
+ { kind: KIND.CLUSTER, title: cluster.name },
284
+ { body: cluster.theme ?? '', cluster_order: cluster.order, provenance },
285
+ );
286
+ clusterHandleByName.set(cluster.name, record.handle);
287
+
288
+ // AN UMBRELLA THAT ALREADY EXISTS IS RECONCILED LIKE ANY OTHER RECORD.
289
+ // `findOrCreateRecord` returns a known cluster untouched, and step 4's
290
+ // import skips known clusters too — so an edit to the THEME of an umbrella
291
+ // that was already imported reached neither writer, and the projection put
292
+ // the old theme back over it. That is the same content loss as an edited
293
+ // idea body, one record kind over, and it is invisible to any test whose
294
+ // umbrella is new.
295
+ const patch = patchFor(normalizeRecord(record), {
296
+ body: cluster.theme ?? '',
297
+ cluster_order: cluster.order ?? null,
298
+ });
299
+ if (Object.keys(patch).length) {
300
+ await provider.updateRecord(record.handle, patch);
301
+ reclustered.push(cluster.name);
302
+ }
303
+ }
304
+
305
+ const all = [
306
+ ...(parsed.ideas ?? []).map((idea) => ({ idea, killed: false })),
307
+ ...(parsed.killed ?? []).map((idea) => ({ idea, killed: true })),
308
+ ];
309
+
310
+ const updated = [];
311
+ const discussed = [];
312
+ for (const { idea, killed } of all) {
313
+ const current = byHandle.get(idea.id);
314
+ // Not in the store yet: step 4 imports it. Creating it here would duplicate
315
+ // the import's own path and skip its `reclaimAborted` handling.
316
+ if (!current) continue;
317
+
318
+ const desired = ideaToRecord(idea, { killed, parsed, clusterHandleByName, provenance });
319
+ const patch = patchFor(current, desired);
320
+ if (Object.keys(patch).length) {
321
+ await provider.updateRecord(idea.id, patch);
322
+ updated.push(idea.id);
323
+ }
324
+
325
+ // Discussion is APPEND-ONLY on the seam — `updateRecord` refuses to patch
326
+ // it, deliberately, because the deliberation trail is evidence rather than
327
+ // a mutable blob (`record-shape.js`). So an entry present in the file and
328
+ // absent from the store is appended, and nothing is ever removed. An entry
329
+ // deleted from the file therefore survives in the record, which is the same
330
+ // direction every other refusal in this bug takes.
331
+ const have = new Set((current.discussion ?? []).map(discussionKey));
332
+ let appended = 0;
333
+ for (const entry of desired.discussion ?? []) {
334
+ if (have.has(discussionKey(entry))) continue;
335
+ await provider.appendDiscussion(idea.id, entry);
336
+ appended += 1;
337
+ }
338
+ if (appended) discussed.push(`${idea.id} (+${appended})`);
339
+ }
340
+
341
+ const inFile = new Set(all.map(({ idea }) => idea.id));
342
+ const kept = stored.map((r) => r.handle).filter((h) => !inFile.has(h));
343
+ return { updated, discussed, kept, reclustered };
344
+ }
345
+
346
+ /** Identity of a discussion entry, for "is this one already recorded". */
347
+ function discussionKey(entry) {
348
+ return JSON.stringify([entry?.at ?? null, entry?.author ?? null, entry?.text ?? '']);
349
+ }
350
+
351
+ /**
352
+ * The patchable difference between a stored record and what the file now says.
353
+ *
354
+ * `UNPATCHABLE` fields are dropped rather than compared: identity, provenance
355
+ * and the append-only discussion trail are not the file's to change, and
356
+ * including any of them would make `updateRecord` refuse the whole patch.
357
+ */
358
+ function patchFor(current, desired) {
359
+ const patch = {};
360
+ for (const [key, value] of Object.entries(desired)) {
361
+ if (UNPATCHABLE.includes(key)) continue;
362
+ if (!deepEqual(current[key] ?? null, value ?? null)) patch[key] = value;
363
+ }
364
+ return patch;
365
+ }
366
+
367
+ function deepEqual(a, b) {
368
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
369
+ }
370
+
371
+ /** The saved copy of the document the user is about to discard. */
372
+ function writeBackup(provider, ideaboxPath, markdown) {
373
+ const home = manifestPath(provider, ideaboxPath);
374
+ if (!home) return null;
375
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
376
+ const path = join(dirname(home), `ideabox-discarded-${stamp}-${randomUUID().slice(0, 8)}.md`);
377
+ mkdirSync(dirname(path), { recursive: true });
378
+ writeFileAtomic(path, markdown);
379
+ return path;
380
+ }
381
+
382
+ /** Temp + rename, so an interrupted write cannot leave a half-file. */
383
+ function writeFileAtomic(path, body) {
384
+ mkdirSync(dirname(path), { recursive: true });
385
+ const tmp = `${path}.tmp.${randomUUID()}`;
386
+ try {
387
+ writeFileSync(tmp, body, 'utf8');
388
+ renameSync(tmp, path);
389
+ } catch (err) {
390
+ rmSync(tmp, { force: true });
391
+ throw err;
392
+ }
393
+ }
@@ -21,6 +21,14 @@ import { readFileSync } from 'node:fs';
21
21
 
22
22
  import { parseIdeabox } from '../ideabox.js';
23
23
  import { toRecordTimestamp } from './ideabox-dates.js';
24
+ import {
25
+ IdeaboxSourceChangedDuringImport,
26
+ closeManifest,
27
+ hashMarkdown,
28
+ openManifest,
29
+ } from './ideabox-manifest.js';
30
+ import { writePreamble } from './ideabox-preamble.js';
31
+ import { assertIdeaboxReadable } from './ideabox-readable.js';
24
32
  import { KIND } from './provider.js';
25
33
 
26
34
  /** Markdown status token → canonical fluid status. */
@@ -59,6 +67,12 @@ function toPriority(raw) {
59
67
  export async function importIdeabox(provider, { markdown, path, dryRun = false } = {}) {
60
68
  const source = markdown ?? readFileSync(path, 'utf8');
61
69
  const parsed = parseIdeabox(source);
70
+ // FU-2. This function is directly callable, and it used to read a failed
71
+ // parse as an empty document — importing only the subset it recognised and
72
+ // leaving the rest to be projected away by the next render. Same shape as the
73
+ // bug that destroyed 18 ideas, one level up: the guard was on the destructive
74
+ // path, not on the parse.
75
+ assertIdeaboxReadable(parsed, path ?? '(in-memory ideabox)');
62
76
 
63
77
  const provenance = { origin: 'import:ideabox' };
64
78
 
@@ -71,6 +85,43 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
71
85
  const skipped = [];
72
86
  const clusterHandles = [];
73
87
 
88
+ // FU-1: THE INTENTION, WRITTEN DOWN BEFORE THE FIRST WRITE.
89
+ //
90
+ // Records are created one at a time. A crash on idea 2 leaves ideas 3..N
91
+ // never issued, so they carry no event, so the gate reads them as hand-added
92
+ // strays and refuses — and every recovery it names runs the same gate. The
93
+ // log can only testify about handles the import REACHED; the unattempted tail
94
+ // leaves no trace, so it has to be declared in advance or it cannot be
95
+ // recovered at all.
96
+ //
97
+ // Written before the first `createRecord` of either population, and removed
98
+ // only on success, so "open" means "an import started here and did not
99
+ // finish". A dry run declares nothing because it writes nothing.
100
+ const plannedHandles = [
101
+ ...(parsed.ideas ?? []).map((i) => i.id),
102
+ ...(parsed.killed ?? []).map((i) => i.id),
103
+ ];
104
+ if (!dryRun && plannedHandles.length) {
105
+ openManifest(provider, path, {
106
+ markdown: source,
107
+ planned: plannedHandles,
108
+ plannedClusters: (parsed.clusters ?? []).map((c) => c.name),
109
+ });
110
+ }
111
+
112
+ // THE DOCUMENT AROUND THE IDEAS (FU-4).
113
+ //
114
+ // A project's own title and introduction are content too, and the projection
115
+ // used to replace them with the standard template on the first render after
116
+ // migration. Captured here, at the one moment the source document is
117
+ // authoritative, from the PARSER'S OWN output rather than a second scan of
118
+ // the same text. It goes to a TRACKED sibling of the ideabox,
119
+ // `<ideabox>.preamble.md` — not beside the migration manifest, which is
120
+ // gitignored, because a tracked projection generated from untracked input
121
+ // loses the heading on every clone that did not run the migration. See
122
+ // `ideabox-preamble.js`.
123
+ if (!dryRun) writePreamble(path, parsed.preamble);
124
+
74
125
  // ---- clusters first: members reference them by handle --------------------
75
126
  const clusterHandleByName = new Map();
76
127
  for (const cluster of parsed.clusters ?? []) {
@@ -112,52 +163,12 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
112
163
  skipped.push(idea.id);
113
164
  continue;
114
165
  }
115
- const clusterHandle = idea.cluster ? clusterHandleByName.get(idea.cluster) ?? null : null;
116
- const clusterOrder = idea.cluster
117
- ? (parsed.clusters ?? []).find((c) => c.name === idea.cluster)?.order ?? null
118
- : null;
119
-
120
- const record = {
121
- kind: KIND.IDEA,
122
- // Verbatim. The whole point of the caller-supplied handle path.
123
- handle: idea.id,
124
- title: idea.title,
125
- body: idea.description ?? '',
126
- status: killed ? 'killed' : toStatus(idea.status),
127
- // Keep the author's token when the canonical enum cannot hold it, so a
128
- // closed enum does not quietly flatten `RE-AIMED (2026-07-21)` to `NEW`.
129
- status_label: STATUS_MAP[String(idea.status ?? '').trim().toUpperCase()]
130
- ? null
131
- : (idea.status || null),
132
- priority: toPriority(idea.priority),
133
- // Carried, not dropped. `parseIdeabox` already validates both against
134
- // their enums and yields null otherwise (`lib/ideabox.js:304-311`), so
135
- // there is nothing to re-check here — but omitting them is not a harmless
136
- // gap. This function is the first-use migration gate every upgrading
137
- // install runs, and the render that follows it rewrites the markdown from
138
- // the records. A dropped field is therefore deleted from the user's file
139
- // on upgrade, silently, with no way back. No idea in THIS repo carries
140
- // either, which is exactly why it went unnoticed.
141
- effort: idea.effort ?? null,
142
- impact: idea.impact ?? null,
143
- cluster: clusterHandle,
144
- cluster_order: clusterOrder,
145
- tags: idea.tags ?? [],
146
- source: idea.source || null,
147
- links: idea.mapsTo ? [{ type: 'maps_to', target: idea.mapsTo }] : [],
148
- killed: (killed || idea.killedReason)
149
- ? {
150
- at: toRecordTimestamp(idea.killedDate),
151
- reason: idea.killedReason || 'reason not recorded in markdown',
152
- }
153
- : null,
154
- discussion: (idea.discussion ?? []).map((d) => ({
155
- at: toRecordTimestamp(d.date),
156
- text: d.text ?? '',
157
- author: d.author ?? null,
158
- })),
166
+ const record = ideaToRecord(idea, {
167
+ killed,
168
+ parsed,
169
+ clusterHandleByName,
159
170
  provenance,
160
- };
171
+ });
161
172
 
162
173
  if (dryRun) {
163
174
  imported.push(idea.id);
@@ -175,6 +186,36 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
175
186
  imported.push(idea.id);
176
187
  }
177
188
 
189
+ // THE DOCUMENT IS STILL THE USER'S UNTIL THIS FINISHES.
190
+ //
191
+ // Every check up to here compares IDs, and an edit to an idea that ALREADY
192
+ // has a record changes no ID at all: the assessment sees a file whose handles
193
+ // are all known, calls it consistent, and the projection replaces the edited
194
+ // body with the one imported from the version read at the start. The edit is
195
+ // destroyed and it existed nowhere else.
196
+ //
197
+ // The scope is what makes this different from a hand edit to generated
198
+ // output. DURING the migration this file is the source document — nothing in
199
+ // it has reached the store yet — so an edit to it is unrecoverable content.
200
+ // AFTER the migration it is output, and discarding hand edits is exactly what
201
+ // `render` is for (`lib/ideabox-cli.js`); that contract is untouched. The
202
+ // open manifest is precisely the marker that separates the two states.
203
+ //
204
+ // Failing here leaves the manifest OPEN on purpose. Nothing has been
205
+ // destroyed, and the next run meets the refusal that already exists for this
206
+ // shape: the hash no longer matches, so the gate stops with
207
+ // `IDEABOX_MANIFEST_STALE` naming the mid-migration edit.
208
+ if (!dryRun && path && plannedHandles.length) {
209
+ if (hashMarkdown(readFileSync(path, 'utf8')) !== hashMarkdown(source)) {
210
+ throw new IdeaboxSourceChangedDuringImport(path);
211
+ }
212
+ }
213
+
214
+ // The import completed. Closing the manifest is what makes an OPEN one mean
215
+ // "interrupted" — and it is what keeps the protection intact, since a
216
+ // hand-added idea after a completed migration must still be refused.
217
+ if (!dryRun) closeManifest(provider, path);
218
+
178
219
  return {
179
220
  imported,
180
221
  skipped,
@@ -184,3 +225,105 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
184
225
  alreadyImported: imported.length === 0 && skipped.length > 0,
185
226
  };
186
227
  }
228
+
229
+ /**
230
+ * One markdown idea → one fluid record. THE ONLY MAPPING.
231
+ *
232
+ * Extracted from the import loop so the recovery path (`ideabox-recover.js`)
233
+ * can reconcile an already-imported record against the current file using the
234
+ * SAME rules the import used. A second mapping of this shape is the two-readers
235
+ * pattern that produced this entire bug class: the two would agree on the day
236
+ * they were written and drift apart on the first field either one gained.
237
+ *
238
+ * Pure. No provider, no I/O — the caller resolves cluster handles and decides
239
+ * whether to create or patch.
240
+ *
241
+ * @param {object} idea a `parseIdeabox` idea
242
+ * @param {object} ctx
243
+ * @param {boolean} ctx.killed whether it came from the killed section
244
+ * @param {object} ctx.parsed the whole parse, for cluster order
245
+ * @param {Map<string,string>} ctx.clusterHandleByName resolved cluster handles
246
+ * @param {object} ctx.provenance stamped on every record this writes
247
+ */
248
+ export function ideaToRecord(idea, { killed = false, parsed, clusterHandleByName, provenance }) {
249
+ const clusterHandle = idea.cluster ? clusterHandleByName.get(idea.cluster) ?? null : null;
250
+ const clusterOrder = idea.cluster
251
+ ? (parsed.clusters ?? []).find((c) => c.name === idea.cluster)?.order ?? null
252
+ : null;
253
+
254
+ return {
255
+ kind: KIND.IDEA,
256
+ // Verbatim. The whole point of the caller-supplied handle path.
257
+ handle: idea.id,
258
+ title: idea.title,
259
+ body: idea.description ?? '',
260
+ status: killed ? 'killed' : toStatus(idea.status),
261
+ // Keep the author's token when the canonical enum cannot hold it, so a
262
+ // closed enum does not quietly flatten `RE-AIMED (2026-07-21)` to `NEW`.
263
+ status_label: STATUS_MAP[String(idea.status ?? '').trim().toUpperCase()]
264
+ ? null
265
+ : (idea.status || null),
266
+ priority: toPriority(idea.priority),
267
+ // Carried, not dropped. `parseIdeabox` already validates both against
268
+ // their enums and yields null otherwise (`lib/ideabox.js:304-311`), so
269
+ // there is nothing to re-check here — but omitting them is not a harmless
270
+ // gap. This function is the first-use migration gate every upgrading
271
+ // install runs, and the render that follows it rewrites the markdown from
272
+ // the records. A dropped field is therefore deleted from the user's file
273
+ // on upgrade, silently, with no way back. No idea in THIS repo carries
274
+ // either, which is exactly why it went unnoticed.
275
+ effort: idea.effort ?? null,
276
+ impact: idea.impact ?? null,
277
+ cluster: clusterHandle,
278
+ cluster_order: clusterOrder,
279
+ tags: idea.tags ?? [],
280
+ source: idea.source || null,
281
+ // Whatever the parser could not name, kept rather than discarded — MINUS
282
+ // the fields that have a typed home. `**Promoted to:**` is one the legacy
283
+ // parser does not know, so it lands in `_extraLines`; carrying it as an
284
+ // opaque extra puts it beyond the reach of `promoteIdea`, which filters
285
+ // stale `promoted_to` links before adding the new one
286
+ // (`ideabox-ops.js`). The record would then render BOTH the old target and
287
+ // the current one. A field with a typed representation must be imported
288
+ // into it, not carried around it.
289
+ extra_fields: extrasWithoutTypedFields(idea._extraLines ?? []),
290
+ links: [
291
+ ...(promotedTargetOf(idea._extraLines ?? [])
292
+ ? [{ type: 'promoted_to', target: promotedTargetOf(idea._extraLines ?? []) }]
293
+ : []),
294
+ ...(idea.mapsTo ? [{ type: 'maps_to', target: idea.mapsTo }] : []),
295
+ ],
296
+ killed: (killed || idea.killedReason)
297
+ ? {
298
+ at: toRecordTimestamp(idea.killedDate),
299
+ reason: idea.killedReason || 'reason not recorded in markdown',
300
+ }
301
+ : null,
302
+ discussion: (idea.discussion ?? []).map((d) => ({
303
+ at: toRecordTimestamp(d.date),
304
+ text: d.text ?? '',
305
+ author: d.author ?? null,
306
+ })),
307
+ provenance,
308
+ };
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Typed fields hiding in the unrecognised pile
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** `**Promoted to:** X` written by hand, which the parser does not recognise. */
316
+ const PROMOTED_TO_RE = /^\*\*Promoted to:\*\*\s*(.+)$/;
317
+
318
+ function promotedTargetOf(extraLines) {
319
+ for (const line of extraLines) {
320
+ const m = String(line).match(PROMOTED_TO_RE);
321
+ if (m) return m[1].trim();
322
+ }
323
+ return null;
324
+ }
325
+
326
+ /** Drop the lines lifted into typed fields so they are not rendered twice. */
327
+ function extrasWithoutTypedFields(extraLines) {
328
+ return extraLines.filter((l) => !PROMOTED_TO_RE.test(String(l)));
329
+ }
@@ -397,6 +397,12 @@ export class LocalFluidProvider extends FluidProvider {
397
397
  cluster_order: input.cluster_order ?? null,
398
398
  tags: input.tags ?? [],
399
399
  source: input.source ?? null,
400
+ // Unrecognised hand-authored markdown fields, carried verbatim. This
401
+ // literal is one of FOUR places the record's fields are enumerated (the
402
+ // contract, `record-shape.js`, and both providers); a field missing from
403
+ // any one of them is silently dropped on write
404
+ // (COMP-IDEABOX-MIGRATE-DIALECT).
405
+ extra_fields: input.extra_fields ?? [],
400
406
  links: input.links ?? [],
401
407
  killed: input.killed ?? null,
402
408
  discussion: input.discussion ?? [],