@smartmemory/compose 0.4.1 → 0.5.0

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 (114) 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 +1 -1
  5. package/bin/compose.js +33 -14
  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/build-cancel.js +205 -0
  61. package/lib/build.js +552 -87
  62. package/lib/canon-guard.js +3 -24
  63. package/lib/canon-registry.js +2 -71
  64. package/lib/codex-preflight.js +8 -0
  65. package/lib/colleague/context.js +123 -0
  66. package/lib/consumer-fanout.js +24 -1
  67. package/lib/decision-blocks.js +38 -0
  68. package/lib/dispatch-ledger.js +7 -0
  69. package/lib/fluid/factory.js +112 -1
  70. package/lib/fluid/ideabox-manifest.js +203 -0
  71. package/lib/fluid/ideabox-migrate.js +177 -29
  72. package/lib/fluid/ideabox-preamble.js +155 -0
  73. package/lib/fluid/ideabox-readable.js +83 -0
  74. package/lib/fluid/ideabox-recover.js +393 -0
  75. package/lib/fluid/import-ideabox.js +188 -45
  76. package/lib/fluid/local-provider.js +6 -0
  77. package/lib/fluid/portfolio.js +255 -0
  78. package/lib/fluid/record-shape.js +7 -0
  79. package/lib/fluid/render-ideabox.js +153 -7
  80. package/lib/fluid/smartmemory-provider.js +6 -0
  81. package/lib/gate-prompt.js +14 -7
  82. package/lib/ideabox-cli.js +68 -0
  83. package/lib/ideabox.js +209 -9
  84. package/lib/maya-identity.js +16 -2
  85. package/lib/process-termination.js +121 -3
  86. package/lib/receipts-gate.js +268 -0
  87. package/lib/result-normalizer.js +28 -1
  88. package/lib/smartmemory-client.js +68 -1
  89. package/lib/stratum-mcp-client.js +104 -5
  90. package/lib/tool-inventory.js +0 -1
  91. package/lib/version-check.js +9 -3
  92. package/package.json +7 -5
  93. package/server/build-stream-bridge.js +43 -1
  94. package/server/cc-session-watcher.js +54 -5
  95. package/server/compose-mcp-tools.js +48 -50
  96. package/server/compose-mcp.js +0 -2
  97. package/server/design-routes.js +1 -1
  98. package/server/file-watcher.js +14 -0
  99. package/server/ideabox-routes.js +10 -0
  100. package/server/index.js +5 -1
  101. package/server/lifecycle-guard.js +13 -0
  102. package/server/maya-routes.js +111 -7
  103. package/server/mcp-tool-defs.js +0 -25
  104. package/server/mcp-tool-policy.js +6 -13
  105. package/server/stratum-client.js +61 -15
  106. package/server/supervisor.js +18 -4
  107. package/server/vision-routes.js +9 -3
  108. package/dist/assets/channel-SnZzzh7k.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  111. package/dist/assets/clone-DgklGjHm.js +0 -1
  112. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  113. package/lib/append-integrity.js +0 -81
  114. package/lib/canon-override.js +0 -196
@@ -0,0 +1,203 @@
1
+ /**
2
+ * lib/fluid/ideabox-manifest.js — a durable record of a migration in flight.
3
+ *
4
+ * COMP-IDEABOX-MIGRATE-DIALECT FU-1.
5
+ *
6
+ * THE FAILURE THIS EXISTS TO PREVENT
7
+ * ----------------------------------
8
+ * `importIdeabox` writes records sequentially. Crash on idea 2 and ideas 3..N
9
+ * were never issued, so they carry no event — and the gate's resume policy is
10
+ * derived from the event log, so it classifies every one of them as a
11
+ * hand-added stray and refuses. The recovery the refusal names (`compose
12
+ * ideabox add`, then `render`) runs the same gate, so the installation is
13
+ * stranded with no way forward.
14
+ *
15
+ * The event log can only testify about handles the import reached. The
16
+ * unattempted TAIL of a migration leaves no trace anywhere, which is why this
17
+ * has to be an intention written down BEFORE the first write rather than an
18
+ * inference from what happened after it.
19
+ *
20
+ * WHY IT IS SAFE TO TRUST
21
+ * -----------------------
22
+ * The manifest may only ever WIDEN the resumable set, and only under two
23
+ * conditions checked together: it is still open (a completed import removes
24
+ * it), and its hash matches the document being read right now. A hand-added
25
+ * idea changes the document, so the hash stops matching and the gate refuses —
26
+ * the protection the gate exists for is not weakened by anything here.
27
+ *
28
+ * WHERE IT LIVES
29
+ * --------------
30
+ * `.compose/data/`, beside the provider's lock. `data/` is blanket-gitignored
31
+ * (`.gitignore:3`); the ideabox's own directory is TRACKED, and a stray file
32
+ * there gets committed by accident (see the temp-file note in
33
+ * `render-ideabox.js`). A manifest that is missing — a fresh clone, a provider
34
+ * with no local lock path — degrades to REFUSE, which is the safe direction.
35
+ */
36
+
37
+ import { createHash, randomUUID } from 'node:crypto';
38
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
39
+ import { dirname, join } from 'node:path';
40
+
41
+ /**
42
+ * The source document changed WHILE its own migration was running.
43
+ *
44
+ * Raised by `importIdeabox` after its last write, when the file it parsed is no
45
+ * longer the file on disk. During a migration the ideabox is still the user's
46
+ * OWN SOURCE DOCUMENT, not generated output, so an edit to it is real content
47
+ * that never reached the store — and the projection that follows would erase
48
+ * it. Failing here leaves the manifest OPEN, so the next run refuses as
49
+ * `IDEABOX_MANIFEST_STALE` and names the mid-migration edit.
50
+ *
51
+ * Lives in this leaf module rather than beside the other migration errors
52
+ * because `ideabox-migrate.js` imports `importIdeabox`, so importing back from
53
+ * it would close a cycle.
54
+ */
55
+ export class IdeaboxSourceChangedDuringImport extends Error {
56
+ constructor(ideaboxPath) {
57
+ super(
58
+ `compose: the ideabox at ${ideaboxPath} was edited while its migration was running, so the ` +
59
+ `import stopped before finishing. Until the migration completes this file is still your ` +
60
+ `source document rather than generated output, and continuing would have replaced it with a ` +
61
+ `projection built from the version read at the start — destroying whatever was just added. ` +
62
+ `Nothing has been changed and the unfinished migration is still recorded. Records written ` +
63
+ `before the edit are already in the store, so re-running will NOT pick the edit up. Decide ` +
64
+ `which document is right and say so: \`compose ideabox adopt-file\` finishes the migration ` +
65
+ `against the file as it now stands, keeping the edit; \`compose ideabox discard-edits\` puts ` +
66
+ `the file back as the migration read it and finishes that, after saving a copy of the current ` +
67
+ `file first.`
68
+ );
69
+ this.name = 'IdeaboxSourceChangedDuringImport';
70
+ this.code = 'IDEABOX_SOURCE_CHANGED_DURING_IMPORT';
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The current manifest format.
76
+ *
77
+ * v2 adds `text`: the source markdown itself, not only its hash. That is what
78
+ * makes `compose ideabox discard-edits` LOSSLESS — without the original
79
+ * document there is nothing to put back, and the only recovery from a
80
+ * mid-migration edit is to adopt whatever the file now says. An ideabox is a
81
+ * small file and this is written once per migration.
82
+ *
83
+ * A v1 manifest is still valid and still resumes; it simply has no `text`, so
84
+ * `discard-edits` refuses on one and names `adopt-file` as its recovery.
85
+ */
86
+ export const MANIFEST_VERSION = 2;
87
+
88
+ /** Content identity of the document a migration was planned against. */
89
+ export function hashMarkdown(markdown) {
90
+ return createHash('sha256').update(String(markdown ?? ''), 'utf8').digest('hex');
91
+ }
92
+
93
+ /**
94
+ * Where this provider keeps the manifest for this ideabox, or null when there
95
+ * is nowhere durable to put one.
96
+ *
97
+ * Derived from `provider.lockPath`, which is the one machine-local, gitignored
98
+ * location both the gate and the importer already have in hand. Keyed by the
99
+ * ideabox path so two ideaboxes under one project do not share a manifest.
100
+ * A provider with no lock path (SmartMemory — see `factory.js`) gets null and
101
+ * therefore no resume widening, which matches its already-documented lack of
102
+ * machine-local coordination.
103
+ */
104
+ export function manifestPath(provider, ideaboxPath) {
105
+ if (!provider?.lockPath || !ideaboxPath) return null;
106
+ const key = createHash('sha256').update(String(ideaboxPath)).digest('hex').slice(0, 12);
107
+ return join(dirname(provider.lockPath), `ideabox-migration-${key}.json`);
108
+ }
109
+
110
+ /**
111
+ * Declare a migration BEFORE the first record is written.
112
+ *
113
+ * @param {object} provider
114
+ * @param {string} ideaboxPath
115
+ * @param {object} plan
116
+ * @param {string} plan.markdown the exact text that was parsed
117
+ * @param {string[]} plan.planned every idea handle the import intends to write
118
+ * @param {string[]} [plan.plannedClusters] cluster NAMES, for diagnostics only —
119
+ * cluster handles are allocated by the store and are not known in advance,
120
+ * and the gate's resume decision is about idea handles from the markdown.
121
+ * @returns {string|null} the path written, or null when there is no home
122
+ */
123
+ export function openManifest(provider, ideaboxPath, { markdown, planned, plannedClusters = [] }) {
124
+ const path = manifestPath(provider, ideaboxPath);
125
+ if (!path) return null;
126
+ const hash = hashMarkdown(markdown);
127
+
128
+ // A RETRY MUST NOT TOUCH THE PLAN IT IS RETRYING.
129
+ //
130
+ // Every resumed import came through here again, and rewriting a manifest that
131
+ // already says the same thing is pure risk: the file whose entire job is to
132
+ // survive a crash was being destroyed and recreated by each attempt to
133
+ // recover from one. An open manifest carrying this hash and this plan is
134
+ // already correct, so it is left exactly where it is.
135
+ const open = readManifest(provider, ideaboxPath);
136
+ // An identical plan is left exactly where it is — EXCEPT when it predates the
137
+ // stored text. We are holding the very document a v1 manifest failed to keep,
138
+ // so upgrading it here costs one write and gives an in-flight migration the
139
+ // lossless recovery it was started without.
140
+ if (open && open.hash === hash && sameSet(open.planned, planned)
141
+ && typeof open.text === 'string') return path;
142
+
143
+ mkdirSync(dirname(path), { recursive: true });
144
+ const body = JSON.stringify({
145
+ version: MANIFEST_VERSION,
146
+ source: ideaboxPath,
147
+ hash,
148
+ // The document itself, so `discard-edits` has something to put back.
149
+ text: String(markdown ?? ''),
150
+ planned,
151
+ plannedClusters,
152
+ startedAt: new Date().toISOString(),
153
+ }, null, 2);
154
+
155
+ // Temp + rename, the pattern `publishProjection` uses on the projection.
156
+ // A plain `writeFileSync` truncates first, so a crash or a full disk between
157
+ // the truncate and the write leaves a manifest that is present but empty —
158
+ // and a manifest that cannot be read is a manifest that does not vouch for
159
+ // anything, which turns the whole remaining corpus into strays. `rename` is
160
+ // atomic: the manifest is either the old plan or the new one, never neither.
161
+ const tmp = `${path}.tmp.${randomUUID()}`;
162
+ try {
163
+ writeFileSync(tmp, body, 'utf8');
164
+ renameSync(tmp, path);
165
+ } catch (err) {
166
+ rmSync(tmp, { force: true });
167
+ throw err;
168
+ }
169
+ return path;
170
+ }
171
+
172
+ /** Order-insensitive equality for two handle lists. */
173
+ function sameSet(a, b) {
174
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
175
+ const set = new Set(a);
176
+ return b.every((x) => set.has(x));
177
+ }
178
+
179
+ /** The import finished. Removing the file is what makes "open" mean something. */
180
+ export function closeManifest(provider, ideaboxPath) {
181
+ const path = manifestPath(provider, ideaboxPath);
182
+ if (!path) return;
183
+ rmSync(path, { force: true });
184
+ }
185
+
186
+ /**
187
+ * The open manifest for this ideabox, or null.
188
+ *
189
+ * An unreadable or malformed manifest reads as absent: every ambiguous state
190
+ * here has to fall back to refusing, because the one thing this must not do is
191
+ * widen the resumable set on a guess.
192
+ */
193
+ export function readManifest(provider, ideaboxPath) {
194
+ const path = manifestPath(provider, ideaboxPath);
195
+ if (!path || !existsSync(path)) return null;
196
+ try {
197
+ const data = JSON.parse(readFileSync(path, 'utf8'));
198
+ if (!data || typeof data.hash !== 'string' || !Array.isArray(data.planned)) return null;
199
+ return data;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
@@ -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
+ }