@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
@@ -16,11 +16,12 @@
16
16
  */
17
17
 
18
18
  import { randomUUID } from 'node:crypto';
19
- import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
21
 
22
22
  import { withDirLock } from '../dir-lock.js';
23
23
  import { toMarkdownDate } from './ideabox-dates.js';
24
+ import { readPreamble } from './ideabox-preamble.js';
24
25
  import { KIND } from './provider.js';
25
26
 
26
27
  // Says only what is true. The cockpit's write path is NOT on the store yet
@@ -31,7 +32,20 @@ const BANNER = [
31
32
  ' Projection of the fluid-store idea records (COMP-PLAN-IDEA-UNIFY).',
32
33
  ' Edits here are overwritten on the next render. Change ideas with',
33
34
  ' `compose ideabox add|pri|kill|discuss|promote`, then `compose ideabox',
34
- ' render` if this file ever looks stale. -->',
35
+ ' render` if this file ever looks stale.',
36
+ // The heading and introduction come from a DIFFERENT file, and nothing in the
37
+ // document says so. Someone correcting the title here loses it on the next
38
+ // render with no clue where it went — the one edit this file invites that has
39
+ // no visible home (COMP-IDEABOX-MIGRATE-DIALECT FU-4 follow-up).
40
+ ' The title and introduction above are not generated: they live in',
41
+ ' `<this file>.preamble.md`. Edit them THERE, not here.',
42
+ // The inverse rule, and nothing said it. Everything else in this file is
43
+ // generated output whose hand edits are discarded on the next render; the
44
+ // sidecar is the one input, so ITS hand edits are canon and are kept
45
+ // untouched forever. Two files side by side following opposite rules, with
46
+ // only one of them stated, is a trap for the next person to edit either.
47
+ ' That file is the opposite of this one: it is yours, nothing',
48
+ ' regenerates it, and what you write there is kept. -->',
35
49
  ];
36
50
 
37
51
  const PREAMBLE = [
@@ -85,6 +99,19 @@ function renderIdea(record) {
85
99
  if (record.source) out.push(`**Source:** ${record.source}`);
86
100
  if (record.body) out.push(`**Idea:** ${record.body}`);
87
101
 
102
+ // Unrecognised hand-authored fields. The two serializers put these in
103
+ // DIFFERENT slots and the projection has to match whichever one will read it
104
+ // back: `serializeIdea` emits them after `**Idea:**` and before the trailing
105
+ // known fields (`lib/ideabox.js`), while `serializeKilledIdea` emits them
106
+ // after `**Killed:**`. Using the live slot for both meant a killed idea
107
+ // carrying a custom field reordered the file on every write, so the
108
+ // projection stopped being a fixed point of the serializer — the property the
109
+ // whole cutover rests on. Killed ideas are emitted further down, next to
110
+ // `**Killed:**`.
111
+ if (!record.killed) {
112
+ for (const extra of record.extra_fields ?? []) out.push(extra);
113
+ }
114
+
88
115
  // `Promoted to:` is emitted BEFORE `Maps to:`, which reads backwards and is
89
116
  // deliberate. The legacy parser knows `Maps to` and does not know `Promoted
90
117
  // to`, so the latter lands in `_extraLines` — and the legacy serializer emits
@@ -112,6 +139,9 @@ function renderIdea(record) {
112
139
  // discussed — which is most of the ones anyone would want to kill.
113
140
  if (record.status === 'killed' && record.killed) {
114
141
  out.push(`**Killed:** ${toMarkdownDate(record.killed.at)} — ${record.killed.reason}`);
142
+ // The killed serializer's slot for unrecognised fields: immediately after
143
+ // `**Killed:**`, before the discussion block.
144
+ for (const extra of record.extra_fields ?? []) out.push(extra);
115
145
  }
116
146
 
117
147
  if (record.discussion?.length) {
@@ -132,8 +162,14 @@ function renderIdea(record) {
132
162
  * @param {Array<object>} data.clusters
133
163
  * @returns {string}
134
164
  */
135
- export function renderIdeabox({ ideas, clusters }) {
136
- const lines = [...BANNER, '', ...PREAMBLE, '', '## Ideas', ''];
165
+ export function renderIdeabox({ ideas, clusters, preamble = null }) {
166
+ // A project's own title and introduction, when it has one. The default
167
+ // PREAMBLE is a template, and using it unconditionally deleted the heading and
168
+ // prose an upgrading project had written for itself — the migration kept every
169
+ // idea and silently rewrote the document around them
170
+ // (COMP-IDEABOX-MIGRATE-DIALECT, Codex review finding 4).
171
+ const preambleLines = preamble && preamble.length ? preamble : PREAMBLE;
172
+ const lines = [...BANNER, '', ...preambleLines, '', '## Ideas', ''];
137
173
  // The legacy serializer emits a placeholder comment when the file contains no
138
174
  // `###` grouping heading at all, and the projection has to match it byte for
139
175
  // byte or `serialize(parse(projection))` stops being the identity. That state
@@ -203,14 +239,44 @@ export function renderIdeabox({ ideas, clusters }) {
203
239
  * Read the records from a provider and render.
204
240
  * @param {import('./provider.js').FluidProvider} provider
205
241
  */
206
- export async function renderIdeaboxFrom(provider) {
242
+ export async function renderIdeaboxFrom(provider, { preamble = null, outPath = null } = {}) {
207
243
  const [ideas, clusters] = await Promise.all([
208
244
  provider.listRecords({ kind: KIND.IDEA }),
209
245
  provider.listRecords({ kind: KIND.CLUSTER }),
210
246
  ]);
211
- return renderIdeabox({ ideas, clusters });
247
+ // The project's own heading and introduction, captured at migration into a
248
+ // tracked sibling of the ideabox (FU-4). NOT read from the destination —
249
+ // `<ideabox>.preamble.md` is a different file; see the note below. `outPath`
250
+ // names which ideabox's sidecar to read, not a file to read from. An explicit
251
+ // `preamble` still wins, and a project with no sidecar gets the standard
252
+ // template exactly as before.
253
+ const captured = preamble ?? (outPath ? readPreamble(outPath) : null);
254
+ return renderIdeabox({
255
+ ideas,
256
+ clusters,
257
+ preamble: captured ? captured.split('\n') : null,
258
+ });
212
259
  }
213
260
 
261
+ /**
262
+ * STILL NOT READ FROM THE DESTINATION (FU-4, resolved 2026-09-07).
263
+ *
264
+ * Carrying the existing file's preamble forward would have preserved a
265
+ * project's own title and introduction through migration, but it makes the
266
+ * DESTINATION authoritative, and `render` is documented as the way back from
267
+ * any hand edit (`lib/ideabox-cli.js`). Reading the file we are about to repair
268
+ * means a hand-corrupted heading survives the repair. Both behaviours are
269
+ * legitimate and they were mutually exclusive while the preamble lived only in
270
+ * the markdown.
271
+ *
272
+ * So it no longer lives only there. `importIdeabox` captures it at migration
273
+ * into a tracked SIBLING of the ideabox, `<ideabox>.preamble.md`
274
+ * (`ideabox-preamble.js`), and the renderer reads THAT. A sibling is a
275
+ * different file, so the projection stays a function of the records plus the
276
+ * sidecar and never of its own output: a hand edit to the heading is discarded
277
+ * by the next render exactly as a hand edit to an idea is.
278
+ */
279
+
214
280
  /**
215
281
  * Render and write atomically (temp + rename), following the roadmap-gen
216
282
  * pattern — a half-written projection of canonical data is worse than none.
@@ -234,16 +300,78 @@ export async function renderIdeaboxFrom(provider) {
234
300
  * which is the documented state of that provider rather than a new gap.
235
301
  */
236
302
  export async function writeIdeaboxProjection(provider, outPath) {
303
+ // THE SINGLE DOOR.
304
+ //
305
+ // Every projection write replaces the user's ideabox wholesale, so every
306
+ // projection write is a potential erasure — and the migration gate that
307
+ // prevents it used to be applied caller-by-caller. It was on the CLI render
308
+ // and absent from the HTTP render, which meant a button in the cockpit could
309
+ // do what the equivalent command refused to
310
+ // (COMP-IDEABOX-MIGRATE-DIALECT, Codex review finding 1). Guarding each
311
+ // caller is a list that has to stay complete forever; guarding the boundary
312
+ // they all pass through is a fact. It goes here.
313
+ //
314
+ // Idempotent for the ordinary case: after a normal mutation the store is
315
+ // ahead of the file, which the gate reads as already-migrated and returns
316
+ // without work.
317
+ //
318
+ // DELIBERATELY OUTSIDE `withDirLock`, and it must stay there. The gate can
319
+ // import, importing calls `provider.createRecord`, and that takes this same
320
+ // lock — which is a non-reentrant `mkdirSync` lock (`lib/dir-lock.js`). Moving
321
+ // this call under the lock to close the read-then-write window deadlocks every
322
+ // migration: measured, it fails after the full 30s lock timeout rather than
323
+ // completing.
324
+ //
325
+ // The window that leaves — the file changing between this check and the
326
+ // replacement below — is closed by `publishProjection` re-running the gate's
327
+ // PURE assessment inside the lock (FU-3). That is the shape the deadlock
328
+ // forces: the decision moves under the lock, the execution stays outside it.
329
+ const { ensureIdeaboxMigrated } = await import('./ideabox-migrate.js');
330
+ await ensureIdeaboxMigrated(provider, outPath);
331
+
237
332
  return provider.lockPath
238
333
  ? withDirLock(provider.lockPath, () => publishProjection(provider, outPath))
239
334
  : publishProjection(provider, outPath);
240
335
  }
241
336
 
242
337
  async function publishProjection(provider, outPath) {
338
+ // THE SECOND LOOK, INSIDE THE LOCK.
339
+ //
340
+ // The gate above ran before this lock was acquired, so the file it approved
341
+ // is not necessarily the file about to be replaced: an editor saving a new
342
+ // idea while a migration runs had it destroyed, because the projection was
343
+ // built from records made out of the OLDER document
344
+ // (COMP-IDEABOX-MIGRATE-DIALECT FU-3).
345
+ //
346
+ // Re-ASSESSING rather than comparing a hash captured at gate time. A hash
347
+ // comparison cannot tell an edit from an ordinary concurrent render: two
348
+ // legitimate writers (the CLI and the REST API) each re-read the records, and
349
+ // whichever lands second would refuse for no reason. The assessment asks the
350
+ // question that actually matters — is this file still consistent with the
351
+ // store? — so a concurrent render assesses as `none` and proceeds, while a
352
+ // hand-added idea assesses as a stray and stops the write.
353
+ //
354
+ // Safe under the lock because the assessment is pure and takes no lock: it
355
+ // reads `listRecords`, `readEvents` and the markdown, and `renderIdeaboxFrom`
356
+ // below already performs the first of those under this same lock.
357
+ const { assessIdeabox, IdeaboxChangedUnderLock } = await import('./ideabox-migrate.js');
358
+ // The exact bytes the assessment below is about to approve. Re-read
359
+ // immediately before the rename — see the note there.
360
+ const approved = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
361
+ const assessment = await assessIdeabox(provider, outPath);
362
+ if (assessment.action === 'refuse') throw assessment.error;
363
+ if (assessment.action !== 'none') {
364
+ // `import` here means the store and the file diverged after the gate
365
+ // approved them — records deleted concurrently, or an interrupted migration
366
+ // resumable from its manifest. Either way the write is abandoned rather
367
+ // than replacing a document this pass never checked.
368
+ throw new IdeaboxChangedUnderLock(outPath, `the store and the file now need ${assessment.action}`);
369
+ }
370
+
243
371
  // Rendered BEFORE the destination is touched, so a render that refuses (an
244
372
  // idea naming a cluster that does not exist) leaves the previous good file
245
373
  // exactly where it was rather than replacing it with a partial view.
246
- const markdown = await renderIdeaboxFrom(provider);
374
+ const markdown = await renderIdeaboxFrom(provider, { outPath });
247
375
  mkdirSync(dirname(outPath), { recursive: true });
248
376
  // randomUUID, not pid: two renders from one process must not collide on the
249
377
  // temp name, and a recycled pid must not adopt a stranded file. Cleaned up on
@@ -252,6 +380,24 @@ async function publishProjection(provider, outPath) {
252
380
  const tmp = join(dirname(outPath), `.ideabox.md.tmp.${randomUUID()}`);
253
381
  try {
254
382
  writeFileSync(tmp, markdown, 'utf8');
383
+ // THE LOCK DOES NOT COORDINATE THE PERSON.
384
+ //
385
+ // The assessment above closed the window between the gate and the lock, but
386
+ // not the window inside the lock: reading the records and rendering them
387
+ // takes time, and a human saving a new idea in that moment never acquires
388
+ // this lock — nothing invites them to. Their whole idea was still lost, one
389
+ // step later than before.
390
+ //
391
+ // So the destination is compared against ITSELF, over milliseconds, with
392
+ // the lock held. That is not the gate-time fingerprint this deliberately
393
+ // does not use: the lock serializes every render Compose performs, so no
394
+ // legitimate concurrent render can change this file between the two reads,
395
+ // and none can be refused by this check. Only a writer outside the lock —
396
+ // which means a person with the file open — trips it.
397
+ const current = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
398
+ if (current !== approved) {
399
+ throw new IdeaboxChangedUnderLock(outPath, 'it was saved from outside compose mid-write');
400
+ }
255
401
  renameSync(tmp, outPath);
256
402
  } catch (err) {
257
403
  rmSync(tmp, { force: true });
@@ -959,6 +959,12 @@ export class SmartMemoryFluidProvider extends FluidProvider {
959
959
  cluster_order: input.cluster_order ?? null,
960
960
  tags: input.tags ?? [],
961
961
  source: input.source ?? null,
962
+ // Unrecognised hand-authored markdown fields, carried verbatim. This
963
+ // literal is one of FOUR places the record's fields are enumerated (the
964
+ // contract, `record-shape.js`, and both providers); a field missing from
965
+ // any one of them is silently dropped on write
966
+ // (COMP-IDEABOX-MIGRATE-DIALECT).
967
+ extra_fields: input.extra_fields ?? [],
962
968
  links: input.links ?? [],
963
969
  killed: input.killed ?? null,
964
970
  discussion: input.discussion ?? [],
@@ -136,28 +136,32 @@ export class GateInputUnavailableError extends Error {
136
136
  *
137
137
  * @param {import('node:readline').Interface} rl
138
138
  * @param {string} question
139
- * @param {{ armGuard?: boolean, deadlineMs?: number }} [opts]
139
+ * @param {{ armGuard?: boolean, deadlineMs?: number, signal?: AbortSignal }} [opts]
140
140
  * @returns {Promise<string>}
141
141
  */
142
- export function ask(rl, question, { armGuard = false, deadlineMs = GATE_NONINTERACTIVE_TIMEOUT_MS } = {}) {
143
- if (!armGuard) {
142
+ export function ask(rl, question, { armGuard = false, deadlineMs = GATE_NONINTERACTIVE_TIMEOUT_MS, signal } = {}) {
143
+ if (!armGuard && !signal) {
144
144
  return new Promise(resolve => rl.question(question, resolve));
145
145
  }
146
146
  return new Promise((resolve, reject) => {
147
147
  let settled = false;
148
148
  const onEnd = () => finish(() => reject(new GateInputUnavailableError('stdin was closed before a decision')));
149
- const timer = setTimeout(
149
+ const onAbort = () => finish(() => reject(signal.reason));
150
+ const timer = armGuard ? setTimeout(
150
151
  () => finish(() => reject(new GateInputUnavailableError(`no input arrived within ${Math.round(deadlineMs / 1000)}s`))),
151
152
  deadlineMs,
152
- );
153
+ ) : null;
153
154
  function finish(act) {
154
155
  if (settled) return;
155
156
  settled = true;
156
157
  clearTimeout(timer);
157
158
  rl.input.removeListener('end', onEnd);
159
+ signal?.removeEventListener('abort', onAbort);
158
160
  act();
159
161
  }
160
- rl.input.once('end', onEnd);
162
+ if (armGuard) rl.input.once('end', onEnd);
163
+ if (signal?.aborted) { onAbort(); return; }
164
+ signal?.addEventListener('abort', onAbort, { once: true });
161
165
  rl.question(question, answer => finish(() => resolve(answer)));
162
166
  });
163
167
  }
@@ -230,7 +234,8 @@ function drawGatePanel(out, gateDispatch, { artifact, gateExtras } = {}) {
230
234
  * @param {object} [options.gateExtras] - { fromPhase, toPhase } for panel display
231
235
  * @returns {Promise<{ outcome: string, rationale: string }>}
232
236
  */
233
- export async function promptGate(gateDispatch, { input, output, artifact, askAgent, gateExtras, nonInteractive, timeoutMs } = {}) {
237
+ export async function promptGate(gateDispatch, { input, output, artifact, askAgent, gateExtras, nonInteractive, timeoutMs, signal } = {}) {
238
+ signal?.throwIfAborted();
234
239
  if (nonInteractive) {
235
240
  return { outcome: 'approve', rationale: 'auto-approved (--all mode)' };
236
241
  }
@@ -247,6 +252,7 @@ export async function promptGate(gateDispatch, { input, output, artifact, askAge
247
252
  const askGuard = {
248
253
  armGuard: input === undefined && !process.stdin.isTTY,
249
254
  deadlineMs: timeoutMs ?? GATE_NONINTERACTIVE_TIMEOUT_MS,
255
+ signal,
250
256
  };
251
257
 
252
258
  try {
@@ -312,6 +318,7 @@ export async function promptGate(gateDispatch, { input, output, artifact, askAge
312
318
  const answer = await askAgent(trimmed, artifact);
313
319
  rl.output.write(`\n ${answer}\n`);
314
320
  } catch (err) {
321
+ if (signal?.aborted) throw err;
315
322
  rl.output.write(` (agent error: ${err.message})\n`);
316
323
  }
317
324
  notes.push(trimmed);
@@ -48,6 +48,7 @@ import {
48
48
  import { toMarkdownDate } from './fluid/ideabox-dates.js';
49
49
  import { KIND } from './fluid/provider.js';
50
50
  import { ensureIdeaboxMigrated } from './fluid/ideabox-migrate.js';
51
+ import { adoptFile, discardEdits } from './fluid/ideabox-recover.js';
51
52
  import { writeIdeaboxProjection } from './fluid/render-ideabox.js';
52
53
 
53
54
  const USAGE = [
@@ -63,6 +64,10 @@ const USAGE = [
63
64
  ' discuss <ID> "<comment>" Add a discussion comment',
64
65
  ' triage [--lens <name>] Walk untriaged ideas and assign priorities',
65
66
  ' render Rewrite the ideabox file from the records',
67
+ '',
68
+ 'Recovering an interrupted migration whose file then changed:',
69
+ ' adopt-file The file on disk is right — finish the migration against it',
70
+ ' discard-edits The migration is right — restore the file (a copy is saved first)',
66
71
  ];
67
72
 
68
73
  const PRIORITIES = ['P0', 'P1', 'P2'];
@@ -91,6 +96,20 @@ function reportOpFailure(err) {
91
96
  console.error(err.message);
92
97
  return 1;
93
98
  }
99
+ // Every refusal in the migration family — unreadable, conflict, stale
100
+ // manifest, changed under the lock, nothing to recover — is a message written
101
+ // FOR the user, naming what was found and what to do about it. Rethrowing
102
+ // meant the CLI died with a Node stack trace and the guidance buried in the
103
+ // middle of it. That is not a hypothetical cost: the stale-manifest message
104
+ // is the one place the two recovery commands are named, and a supported exit
105
+ // nobody can read is not an exit.
106
+ //
107
+ // Matched by code prefix rather than by class so a refusal added later is
108
+ // reported the same way without anyone having to remember this list.
109
+ if (typeof err?.code === 'string' && err.code.startsWith('IDEABOX_')) {
110
+ console.error(err.message);
111
+ return 1;
112
+ }
94
113
  throw err;
95
114
  }
96
115
 
@@ -304,6 +323,55 @@ export async function runIdeaboxCommand(cwd, args, opts = {}) {
304
323
  return 0;
305
324
  }
306
325
 
326
+ // The two recovery commands. Deliberately NOT preceded by
327
+ // `ensureIdeaboxMigrated`: that gate throws on exactly the state these
328
+ // exist to leave. They run their own, narrower check instead — both
329
+ // refuse unless an interrupted migration is recorded AND its document has
330
+ // changed, so neither is a general "make the file canon" door.
331
+ case 'adopt-file': {
332
+ const { updated, discussed, imported, kept, reclustered } = await adoptFile(provider, ideaboxPath);
333
+ console.log(`Adopted ${ideaboxPath} as the source of the interrupted migration.`);
334
+ // A recovery that does not say what it did is not a recovery.
335
+ console.log(` Updated to match the file: ${updated.length ? updated.join(', ') : 'none'}`);
336
+ if (discussed.length) console.log(` Discussion entries added: ${discussed.join(', ')}`);
337
+ if (reclustered.length) console.log(` Umbrellas updated: ${reclustered.join(', ')}`);
338
+ console.log(` Imported: ${imported.length ? imported.join(', ') : 'none'}`);
339
+ if (kept.length) {
340
+ console.log(` Kept, though absent from the file: ${kept.join(', ')}`);
341
+ console.log(' Nothing is ever deleted by this command. They are back in the file now;');
342
+ console.log(' remove them deliberately with `compose ideabox kill <ID>` if they are stale.');
343
+ }
344
+ return 0;
345
+ }
346
+
347
+ case 'discard-edits': {
348
+ // Printed from the callback rather than the return value: if the resume
349
+ // throws after the overwrite, the user has just lost the text and this
350
+ // line is the only thing telling them where the copy is.
351
+ const { result, reverted, leftover } = await discardEdits(provider, ideaboxPath, {
352
+ onBackup: (path) => console.log(`Saved a copy of the discarded file: ${path}`),
353
+ });
354
+ console.log(`Restored ${ideaboxPath} to the document the migration read, and finished it.`);
355
+ // Records are restored as well as the file — an adoption that got as far
356
+ // as patching them is what makes this more than a file copy.
357
+ if (reverted.updated.length) {
358
+ console.log(` Put back to the migrated version: ${reverted.updated.join(', ')}`);
359
+ }
360
+ console.log(` Imported: ${result.imported.length ? result.imported.join(', ') : 'none'}`);
361
+ // Nothing is deleted here either, so anything an interrupted adoption
362
+ // added and this cannot take back is named rather than left as a
363
+ // surprise in the file.
364
+ if (leftover.clusters.length) {
365
+ console.log(` Umbrellas left in place, unnamed by the restored file: ${leftover.clusters.join(', ')}`);
366
+ console.log(' They render with no ideas under them. Nothing was deleted to make room.');
367
+ }
368
+ if (leftover.discussed.length) {
369
+ console.log(` Comments that stay on their idea: ${leftover.discussed.join(', ')}`);
370
+ console.log(' The discussion trail is append-only, so a comment typed during the outage is kept.');
371
+ }
372
+ return 0;
373
+ }
374
+
307
375
  default:
308
376
  console.error(`Unknown ideabox subcommand: ${sub}`);
309
377
  console.error('Run: compose ideabox --help');