@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
package/lib/gsd.js CHANGED
@@ -26,7 +26,7 @@ import { validateBoundaryMap } from './boundary-map.js';
26
26
  import { enrichTaskGraph } from './gsd-decompose-enrich.js';
27
27
  import { buildTaskDescription } from './gsd-prompt.js';
28
28
  import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
29
- import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts } from './build.js';
29
+ import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts, loadPipelineProfiles, preflightPipelineProfiles, admitConsumerWave, publishConsumerCheckpoint, evaluateConfiguredGate, reportWaveEvidence, waveProfilesEnabled } from './build.js';
30
30
  import {
31
31
  ConsumerFanoutArtifacts,
32
32
  ConsumerMergeDecisionError,
@@ -144,7 +144,8 @@ export async function runGsd(featureCode, opts = {}) {
144
144
  const preMergeGate = resolvePreMergeGate(cwd, opts.preMergeGate);
145
145
 
146
146
  // 4. Load pipeline spec
147
- const specPath = join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
147
+ const localPath = join(cwd, 'pipelines', 'gsd.stratum.yaml');
148
+ const specPath = existsSync(localPath) ? localPath : join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
148
149
  // 4a. COMP-GSD-4: inject the stratum flow budget block from `gsd.budget.*`.
149
150
  // injectBudget is IDENTITY when nothing is configured, so an un-budgeted gsd
150
151
  // run (and plain `compose build`) is byte-identical.
@@ -152,6 +153,9 @@ export async function runGsd(featureCode, opts = {}) {
152
153
  const specYaml = injectBudget(readFileSync(specPath, 'utf-8'), budgetCfg);
153
154
  const localSpec = YAML.parse(specYaml);
154
155
  const localSpecDigest = sha256(JSON.stringify(localSpec));
156
+ const profileCheck = preflightPipelineProfiles(loadPipelineProfiles(specPath), localSpec, specPath);
157
+ const pipelineProfiles = profileCheck.normalized;
158
+ const profileDigest = waveProfilesEnabled(pipelineProfiles) ? profileCheck.profilesDigest : undefined;
155
159
 
156
160
  // 4a. COMP-GSD-4: cumulative cross-session ceiling pre-check (tokens/cost).
157
161
  // Refuse to start/resume a run that has already spent its lifetime budget —
@@ -235,7 +239,7 @@ export async function runGsd(featureCode, opts = {}) {
235
239
  stepCtx = {
236
240
  stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
237
241
  receiptsMode,
238
- localSpec, localSpecDigest,
242
+ localSpec, localSpecDigest, pipelineProfiles, profileDigest,
239
243
  filesChanged: [],
240
244
  stuckDetector,
241
245
  // D2(a): per-ITEM wall-clock ceiling from gsd.budget.per_task_ms, enforced
@@ -294,6 +298,11 @@ export async function runGsd(featureCode, opts = {}) {
294
298
  }, { workspaceRoot: cwd });
295
299
  const flowId = response.runId;
296
300
  stepCtx.flowId = flowId;
301
+ if (waveProfilesEnabled(pipelineProfiles)) {
302
+ stepCtx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
303
+ revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: profileDigest });
304
+ stepCtx.artifacts = stepCtx.consumerArtifacts;
305
+ }
297
306
  flushState(stepCtx, { flowId, phase: 'decompose' });
298
307
  emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
299
308
 
@@ -303,11 +312,17 @@ export async function runGsd(featureCode, opts = {}) {
303
312
  response.status !== 'completed' &&
304
313
  response.status !== 'failed' &&
305
314
  response.status !== 'stuck' &&
315
+ response.status !== 'waiting_gate' &&
306
316
  response.status !== 'budget_exhausted'
307
317
  ) {
308
318
  response = await runOneStep(response, stepCtx);
309
319
  }
310
320
 
321
+ if (response.status === 'waiting_gate') {
322
+ flushState(stepCtx, { status: 'waiting_gate', gateToken: response.gateToken, reason: response.reason });
323
+ return response;
324
+ }
325
+
311
326
  if (response.status === 'stuck') {
312
327
  // Artifacts (stuck.md/json + pause.json) were written by runOneStep.
313
328
  // COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
@@ -494,25 +509,35 @@ async function runOneStep(response, ctx) {
494
509
  targetCwd: cwd,
495
510
  revisionDigest: descriptor.revisionDigest,
496
511
  specDigest: localSpecDigest,
512
+ profilesDigest: ctx.profileDigest,
497
513
  });
498
514
  }
499
515
  ctx.consumerArtifacts.bindRunRevision({
500
516
  revisionDigest: descriptor.revisionDigest,
501
517
  specDigest: localSpecDigest,
518
+ profilesDigest: ctx.profileDigest,
502
519
  });
520
+ ctx.artifacts = ctx.consumerArtifacts;
521
+ // This audit already serves issuance recovery on legacy runs; admission
522
+ // uses it only when this step opts into wave policies.
523
+ const audit = await stratum.audit(flowId);
524
+ const configured = ctx.pipelineProfiles?.[descriptor.step]?.tier_from || ctx.pipelineProfiles?._consumer?.[descriptor.step];
525
+ const admission = configured ? await admitConsumerWave({ descriptor, descriptors: ready.filter(isConsumerDescriptor), audit,
526
+ localSpec, profiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, stratum, flowId }) : null;
503
527
  // D7(a): render the exact TaskResult path from the item's task id (the
504
528
  // fanout `over` is decompose_gsd.output.tasks, indexed by itemIndex).
505
- const consumerItem = ctx.lastTaskGraph?.tasks?.[descriptor.itemIndex];
529
+ const consumerItem = descriptor.item;
506
530
  const taskResultPath = consumerItem?.id
507
531
  ? gsdTaskResultPath(featureCode, consumerItem.id)
508
532
  : undefined;
509
533
  try {
510
534
  return await runConsumerIssuance({
511
535
  descriptor,
536
+ admission,
512
537
  flowId,
513
538
  stratum,
514
539
  artifacts: ctx.consumerArtifacts,
515
- audit: await stratum.audit(flowId),
540
+ audit,
516
541
  localSpec,
517
542
  // D2(b): onUsage debits each item's agent usage into the cumulative
518
543
  // ledger. D2(a): per-item wall-clock ceiling from gsd.budget.per_task_ms.
@@ -523,12 +548,15 @@ async function runOneStep(response, ctx) {
523
548
  // by definition a GSD task, so timing.json + diffs/<id>.diff are written
524
549
  // for the milestone report. The build consumer path omits it (no marker),
525
550
  // so build-mode fanout stays byte-identical.
526
- context: { cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
551
+ context: { pipelineProfiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
527
552
  // runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
528
553
  // has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
529
554
  // required or the item throws "progress.debug is not a function".
530
555
  progress: NOOP_PROGRESS,
531
- streamWriter: { write() {} },
556
+ streamWriter: { write(event) {
557
+ if (event.type === 'step_model') appendGsdEvent(cwd, featureCode, 'step_model', event);
558
+ } },
559
+ profile: ctx.pipelineProfiles?.[descriptor.step]?.default ?? ctx.pipelineProfiles?.[descriptor.step],
532
560
  perItemTimeoutMs: ctx.perItemTimeoutMs,
533
561
  stuckDetector: ctx.stuckDetector,
534
562
  });
@@ -555,7 +583,7 @@ async function runOneStep(response, ctx) {
555
583
  featureCode,
556
584
  cwd,
557
585
  cwd,
558
- { cwd, featureCode, mode: 'feature', filesChanged: ctx.filesChanged ?? [] },
586
+ { ...ctx, cwd, featureCode, mode: 'feature', artifacts: ctx.consumerArtifacts, filesChanged: ctx.filesChanged ?? [] },
559
587
  '',
560
588
  null,
561
589
  );
@@ -663,46 +691,65 @@ async function runOneStep(response, ctx) {
663
691
  const gateStep = steps.find((step) => step.id === gateStepId);
664
692
  const predecessorIds = new Set(gateStep?.after ?? []);
665
693
  const fanoutStep = steps.find((step) => predecessorIds.has(step.id) && step.fanout?.dispatch === 'consumer');
666
- if (!fanoutStep) {
667
- throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
668
- }
669
- if (!ctx.consumerArtifacts) {
670
- ctx.consumerArtifacts = new ConsumerFanoutArtifacts({
671
- runId: flowId,
672
- targetCwd: cwd,
673
- revisionDigest: response.revisionDigest,
674
- specDigest: localSpecDigest,
675
- });
676
- }
677
- const artifacts = ctx.consumerArtifacts;
678
- artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
679
- let transaction;
680
- let outcome = 'approve';
681
- let rationale = 'consumer fanout artifacts merged';
682
- try {
683
- transaction = artifacts.prepareMerge({
684
- gateStepId,
685
- gateToken: gateState.gateToken,
686
- fanoutStepId: fanoutStep.id,
687
- audit,
688
- });
689
- await artifacts.applyMerge(transaction);
690
- } catch (error) {
691
- if (!(error instanceof ConsumerMergeDecisionError)) throw error;
692
- outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
693
- rationale = `${error.code}: ${error.message}`;
694
- transaction ??= artifacts.journal.mergeTransactions.find(
695
- (entry) => entry.gateToken === gateState.gateToken,
696
- );
697
- if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
698
- }
699
- const next = await stratum.gateResolve(
700
- flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken,
701
- );
702
- artifacts.markGateResolved(transaction, outcome);
703
- if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`);
704
- if (outcome === 'approve') ctx.filesChanged = collectChangedFiles(cwd);
705
- return next;
694
+ const outputDecision = ctx.artifacts ? await evaluateConfiguredGate(ctx, { localSpec, gateStepId,
695
+ gateToken: gateState.gateToken, audit }) : null;
696
+ if (outputDecision && !outputDecision.outcome) return { status: 'waiting_gate', flowId,
697
+ gateToken: gateState.gateToken, reason: outputDecision.reason };
698
+ const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale) => {
699
+ if (!fanoutStep && !outputDecision) throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
700
+ if (fanoutStep && !ctx.consumerArtifacts) {
701
+ ctx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
702
+ revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: ctx.profileDigest });
703
+ }
704
+ const artifacts = fanoutStep ? ctx.consumerArtifacts : null;
705
+ if (artifacts) ctx.artifacts = artifacts;
706
+ if (ctx.pipelineProfiles?._consumer?.[fanoutStep?.id]?.checkpoint_gate === gateStepId) {
707
+ artifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: ctx.profileDigest });
708
+ }
709
+ let transaction;
710
+ let outcome = requestedOutcome;
711
+ let rationale = requestedRationale;
712
+ if (artifacts) {
713
+ artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
714
+ try {
715
+ transaction = artifacts.prepareMerge({ gateStepId, gateToken: gateState.gateToken, fanoutStepId: fanoutStep.id, audit });
716
+ if (outcome === 'approve') await artifacts.applyMerge(transaction);
717
+ else artifacts.restoreMergeBaseline(transaction, audit);
718
+ } catch (error) {
719
+ if (!(error instanceof ConsumerMergeDecisionError)) throw error;
720
+ outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
721
+ rationale = `${error.code}: ${error.message}`;
722
+ transaction ??= artifacts.journal.mergeTransactions.find(e => e.gateToken === gateState.gateToken);
723
+ if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
724
+ }
725
+ }
726
+ let next;
727
+ try {
728
+ next = await stratum.gateResolve(flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken);
729
+ } catch (error) {
730
+ const current = await stratum.audit(flowId);
731
+ const ordinal = (audit.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length;
732
+ const confirmed = (current.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId)[ordinal]?.detail?.decision;
733
+ if (!confirmed) throw error;
734
+ outcome = confirmed;
735
+ if (artifacts && outcome === 'approve') {
736
+ artifacts.markGateResolved(transaction, outcome);
737
+ await publishConsumerCheckpoint(ctx, transaction);
738
+ }
739
+ next = await stratum.resume(flowId);
740
+ }
741
+ if (artifacts) {
742
+ artifacts.markGateResolved(transaction, outcome);
743
+ if (outcome === 'approve') await publishConsumerCheckpoint(ctx, transaction);
744
+ if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`,
745
+ artifacts.journal.wave ? { dispatchTokens: transaction.acceptedDispatchTokens } : {});
746
+ if (outcome === 'approve' && !artifacts.journal.wave) ctx.filesChanged = collectChangedFiles(cwd);
747
+ }
748
+ if (outputDecision) await reportWaveEvidence(ctx, 'gate_decision', gateState.gateToken,
749
+ { ...outputDecision, outcome, rationale }, 'accepted');
750
+ return next;
751
+ };
752
+ return resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', outputDecision?.rationale ?? 'consumer fanout artifacts merged');
706
753
  }
707
754
 
708
755
  return response;
@@ -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');
package/lib/ideabox.js CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
18
18
  import { join, dirname } from 'node:path'
19
+ import { assertIdeaboxReadable } from './fluid/ideabox-readable.js'
19
20
  import { resolvePathValue } from './paths-core.js'
20
21
 
21
22
  // ---------------------------------------------------------------------------
@@ -47,6 +48,36 @@ export const IDEABOX_TEMPLATE = `# Ideabox
47
48
  // Matches: #### IDEA-42 — Some Title (or "- " variant)
48
49
  const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
49
50
 
51
+ // The LEGACY (pre-umbrella) dialect: ideas are H3 under arbitrary `## Topic`
52
+ // headings, with no `## Ideas` wrapper and no cluster level. Published installs
53
+ // upgrading from before COMP-PLAN-IDEA-UNIFY have this shape, so it is the
54
+ // dialect the migration path actually meets — see COMP-IDEABOX-MIGRATE-DIALECT,
55
+ // where failing to read it destroyed 18 ideas in one command.
56
+ const LEGACY_IDEA_HEADING_RE = /^###\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
57
+
58
+ // An idea heading at EITHER level. A third vintage in the wild is a hybrid: it
59
+ // has the modern `## Ideas` wrapper (so it is not the flat dialect) but writes
60
+ // both its ideas AND its umbrellas at H3. Those two are still unambiguous —
61
+ // an H3 that starts with an `IDEA-N` id is an idea, and any other H3 is an
62
+ // umbrella — so the level alone was never what distinguished them.
63
+ // Measured 2026-09-06: three projects on this machine are in this dialect
64
+ // (books 9 ideas, ScaleMate 2, trustflow 1). Before the guard landed they were
65
+ // silently destroyed; with the guard but without this they are refused, which
66
+ // is safe but leaves them unable to use the ideabox at all.
67
+ const IDEA_HEADING_ANY_RE = /^#{3,4}\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
68
+
69
+ /**
70
+ * A line SHAPED like an idea declaration, at any heading level.
71
+ *
72
+ * Used only to notice a heading the dialect's own idea pattern could not
73
+ * consume — the "I can see something here and cannot read it" case.
74
+ */
75
+ const IDEA_DECL_SHAPE_RE = /^\s*#{1,6}\s+(IDEA-\d+)\b/
76
+
77
+ /** The bullet form, for a document that uses no idea headings at all. */
78
+ const IDEA_BULLET_SHAPE_RE = /^\s*[-*]\s+(IDEA-\d+)\s*[—–:-]/
79
+
80
+
50
81
  // Matches a field line: **FieldName:** value (colon inside bold markers)
51
82
  const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
52
83
 
@@ -80,6 +111,41 @@ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
80
111
  export function parseIdeabox(markdown) {
81
112
  const lines = markdown.split('\n')
82
113
 
114
+ // Legacy dialect detection, fenced as narrowly as possible: it activates ONLY
115
+ // when the document has no `## Ideas` section AND carries H3 idea headings.
116
+ // A new-dialect file always opens its ideas with `## Ideas`, so legacy mode
117
+ // can never engage on one. A file with BOTH shapes is deliberately left to
118
+ // the modern path — a half-converted document is not something to guess at,
119
+ // and the migration gate refuses it by declaration count instead.
120
+ // A COMPLETE heading, not a prefix: `/^##\s+Ideas/` also matched a legitimate
121
+ // legacy topic called `## Ideas for Later`, which switched legacy mode off and
122
+ // made that document's ideas unreadable. Fenced blocks are skipped for the
123
+ // same reason — an example containing `## Ideas` must not decide the dialect
124
+ // of the file quoting it.
125
+ const structural = []
126
+ let fenced = false
127
+ for (const l of lines) {
128
+ if (/^\s*```/.test(l)) { fenced = !fenced; continue }
129
+ if (!fenced) structural.push(l)
130
+ }
131
+ const hasIdeasSection = structural.some((l) => /^##\s+Ideas\s*$/.test(l))
132
+ const legacy = !hasIdeasSection && structural.some((l) => LEGACY_IDEA_HEADING_RE.test(l))
133
+
134
+ // The hybrid dialect only exists in a document the tools have NEVER written:
135
+ // every write emits ideas at H4, so a document containing any `#### IDEA-` is
136
+ // modern, and an H3 there is an umbrella no matter what it is called.
137
+ //
138
+ // That distinction is load-bearing rather than cosmetic. `compose ideabox`
139
+ // will happily create a cluster named `IDEA-9 — Cache research`, and reading
140
+ // that back as an idea made the next command fail: the guard saw a declared
141
+ // id with no record behind it and refused. Recognising H3 ideas everywhere
142
+ // broke round-tripping the tool's own output.
143
+ const hasModernIdeaHeadings = structural.some((l) => IDEA_HEADING_RE.test(l))
144
+ const hybrid = hasIdeasSection && !hasModernIdeaHeadings
145
+ const ideaHeadingRe = legacy
146
+ ? LEGACY_IDEA_HEADING_RE
147
+ : (hybrid ? IDEA_HEADING_ANY_RE : IDEA_HEADING_RE)
148
+
83
149
  const ideas = []
84
150
  const killed = []
85
151
  // Cluster-scoped data. An umbrella heading carries a hand-authored multi-
@@ -89,11 +155,25 @@ export function parseIdeabox(markdown) {
89
155
  // parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
90
156
  const clusters = []
91
157
  const clusterIndex = new Map()
158
+ // Idea-shaped headings that NO branch consumed. The migration gate compares
159
+ // these against the ideas actually produced, so that "the parser could not
160
+ // read this" can never be mistaken for "there was nothing here". Collected
161
+ // HERE, by the parser itself, rather than by a second scanner: the whole class
162
+ // of bug this module keeps producing is two readers disagreeing about what the
163
+ // document says, and a separate scanner is a second reader by construction.
164
+ const shapes = []
165
+ // Idea-shaped headings taken as UMBRELLA names. Legitimate on its own — the
166
+ // tools will create a cluster called `IDEA-9 — Cache research` — but an
167
+ // umbrella named after an id that is ALSO a real idea in the same document is
168
+ // the half-converted file, not a naming choice.
169
+ const clusterNamedIds = []
92
170
  // Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
93
171
  // instead of preserving it drops hand-authored convention bullets.
94
172
  const preambleLines = []
95
173
 
96
- let inIdeasSection = false
174
+ // In the legacy dialect there is no `## Ideas` wrapper — the ideas simply
175
+ // live under topic headings — so the whole document is the ideas section.
176
+ let inIdeasSection = legacy
97
177
  let inKilledSection = false
98
178
  let currentCluster = null
99
179
  let currentIdea = null
@@ -111,11 +191,43 @@ export function parseIdeabox(markdown) {
111
191
  currentIdea = null
112
192
  }
113
193
 
194
+ // Fenced blocks are documentation, not content. Detection and the declaration
195
+ // scan already skipped them; the parse loop did not, so an example in a
196
+ // ```markdown``` block was imported as a real idea — and, worse, satisfied the
197
+ // readability guard on behalf of a genuine entry with the same id that the
198
+ // parser could NOT read, letting the destructive write through. All three
199
+ // readers have to agree on what is content.
200
+ let inFence = false
201
+
114
202
  for (let i = 0; i < lines.length; i++) {
115
203
  const line = lines[i]
116
204
 
205
+ if (/^\s*```/.test(line)) {
206
+ inFence = !inFence
207
+ if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
208
+ // The DELIMITERS are content too. Keeping the enclosed lines while
209
+ // dropping the fence turned a fenced `#### IDEA-2 — example` inside an
210
+ // idea's body into a real heading on the next projection, and the render
211
+ // after that refused the file it had just written.
212
+ else if (currentIdea) currentIdea._extraLines.push(line)
213
+ continue
214
+ }
215
+ if (inFence) {
216
+ if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
217
+ else if (currentIdea) currentIdea._extraLines.push(line)
218
+ continue
219
+ }
220
+
221
+ // Every idea-shaped heading in the document, recorded BEFORE any branch can
222
+ // swallow it. What is consumed as a real idea or as an umbrella is
223
+ // subtracted at the end; whatever is left is content the parser could see
224
+ // and could not read, which the migration gate must refuse rather than
225
+ // treat as absent.
226
+ const shapeHere = line.match(IDEA_DECL_SHAPE_RE)
227
+ if (shapeHere) shapes.push(shapeHere[1])
228
+
117
229
  // Detect section boundaries
118
- if (/^##\s+Ideas/.test(line)) {
230
+ if (/^##\s+Ideas\s*$/.test(line)) {
119
231
  flushCurrentIdea()
120
232
  inIdeasSection = true
121
233
  inKilledSection = false
@@ -123,7 +235,7 @@ export function parseIdeabox(markdown) {
123
235
  currentCluster = null
124
236
  continue
125
237
  }
126
- if (/^##\s+Killed\s+Ideas/.test(line)) {
238
+ if (/^##\s+Killed\s+Ideas\s*$/.test(line)) {
127
239
  flushCurrentIdea()
128
240
  inIdeasSection = false
129
241
  inKilledSection = true
@@ -132,8 +244,34 @@ export function parseIdeabox(markdown) {
132
244
  continue
133
245
  }
134
246
  // Other H2 sections end both
135
- if (/^##\s/.test(line) && !(/^##\s+Ideas/.test(line)) && !(/^##\s+Killed\s+Ideas/.test(line))) {
247
+ if (/^##\s/.test(line) && !(/^##\s+Ideas\s*$/.test(line)) && !(/^##\s+Killed\s+Ideas\s*$/.test(line))) {
136
248
  flushCurrentIdea()
249
+ if (legacy) {
250
+ // A `## Topic` heading in the legacy dialect is not the end of the
251
+ // ideas — it is how that dialect GROUPED them, which is precisely what
252
+ // an umbrella is. Mapping it to a cluster instead of discarding it
253
+ // means the upgrade preserves the author's grouping rather than
254
+ // flattening ten topics into one undifferentiated list. Treating it as
255
+ // preamble (the previous behaviour) collected every topic heading at
256
+ // the top of the file, detached from its ideas.
257
+ const name = line.replace(/^##\s+/, '').trim()
258
+ seenAnySection = true
259
+ // Leaving the killed section matters: without this, every idea under a
260
+ // topic heading that happened to follow `## Killed Ideas` was imported
261
+ // as KILLED. A topic heading opens a group, it does not inherit the
262
+ // previous section's disposition — and it re-enters the ideas section,
263
+ // which `## Killed Ideas` had closed, or the ideas below it are read as
264
+ // neither live nor killed and vanish entirely.
265
+ inKilledSection = false
266
+ inIdeasSection = true
267
+ currentCluster = name
268
+ if (!clusterIndex.has(name)) {
269
+ const entry = { name, theme: '', order: clusters.length }
270
+ clusters.push(entry)
271
+ clusterIndex.set(name, entry)
272
+ }
273
+ continue
274
+ }
137
275
  inIdeasSection = false
138
276
  inKilledSection = false
139
277
  // Still part of the preamble when it precedes the first real section —
@@ -142,6 +280,15 @@ export function parseIdeabox(markdown) {
142
280
  continue
143
281
  }
144
282
 
283
+ // Legacy preamble: everything before the first topic heading or idea. The
284
+ // modern collector below is unreachable here because legacy mode is inside
285
+ // the ideas section from line one, so without this the document's title and
286
+ // introduction were dropped on the first render after migration.
287
+ if (legacy && !seenAnySection && !currentIdea) {
288
+ preambleLines.push(line)
289
+ continue
290
+ }
291
+
145
292
  if (!inIdeasSection && !inKilledSection) {
146
293
  // Preamble = everything before the first section heading.
147
294
  if (!seenAnySection) preambleLines.push(line)
@@ -156,9 +303,14 @@ export function parseIdeabox(markdown) {
156
303
  continue
157
304
  }
158
305
 
159
- // H3 = cluster heading
160
- if (/^###\s/.test(line)) {
306
+ // H3 = cluster heading (modern dialect only — in the legacy dialect H3 IS
307
+ // the idea heading, handled below, and there is no cluster level at all).
308
+ // An H3 carrying an idea id is an IDEA at this level too, not an umbrella
309
+ // named after one; the hybrid dialect writes both at H3.
310
+ if (!legacy && /^###\s/.test(line) && !(hybrid && IDEA_HEADING_ANY_RE.test(line))) {
161
311
  flushCurrentIdea()
312
+ const eaten = line.match(IDEA_DECL_SHAPE_RE)
313
+ if (eaten) clusterNamedIds.push(eaten[1])
162
314
  currentCluster = line.replace(/^###\s+/, '').trim()
163
315
  if (!clusterIndex.has(currentCluster)) {
164
316
  const entry = { name: currentCluster, theme: '', order: clusters.length }
@@ -179,7 +331,7 @@ export function parseIdeabox(markdown) {
179
331
  }
180
332
 
181
333
  // H4 = idea heading
182
- const headingMatch = line.match(IDEA_HEADING_RE)
334
+ const headingMatch = line.match(ideaHeadingRe)
183
335
  if (headingMatch) {
184
336
  flushCurrentIdea()
185
337
  currentIdea = {
@@ -273,7 +425,47 @@ export function parseIdeabox(markdown) {
273
425
  // serializer re-adds the separator itself.
274
426
  while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
275
427
 
276
- return { ideas, killed, nextId, clusters, preamble: preambleLines.join('\n') }
428
+ // The bullet form is a fallback, not a rule: it counts ONLY for a document
429
+ // with no idea headings whatsoever. Counting bullets unconditionally made
430
+ // `- IDEA-1 needs research`, written inside IDEA-1's own body, a second
431
+ // declaration of IDEA-1 — and the gate then refused a perfectly readable file.
432
+ // A reference is not a declaration, and the only document where a bullet
433
+ // plausibly IS one is a document that declares nothing any other way.
434
+ const parsedIds = [...ideas, ...killed].map((i) => i.id)
435
+
436
+ // By COUNT, not by membership. A half-converted document declares the same id
437
+ // twice — once in each dialect — and the parser consumes only one of them; a
438
+ // membership test sees the id in `parsedIds` and calls it accounted for, while
439
+ // the other copy (routinely the older, richer one) is invisible and would be
440
+ // deleted by the next render. Counting says two were written and one was read.
441
+ const count = (arr, id) => arr.filter((x) => x === id).length
442
+ const unconsumed = [...new Set(shapes)].filter(
443
+ (id) => count(shapes, id) > count(parsedIds, id) + count(clusterNamedIds, id),
444
+ )
445
+ // An umbrella named after an id that is also a real idea here.
446
+ const collisions = clusterNamedIds.filter((id) => parsedIds.includes(id))
447
+
448
+ if (!ideas.length && !killed.length && !unconsumed.length) {
449
+ let bulletFence = false
450
+ for (const line of lines) {
451
+ if (/^\s*```/.test(line)) { bulletFence = !bulletFence; continue }
452
+ if (bulletFence) continue
453
+ const m = line.match(IDEA_BULLET_SHAPE_RE)
454
+ if (m && !unconsumed.includes(m[1])) unconsumed.push(m[1])
455
+ }
456
+ }
457
+
458
+ return {
459
+ ideas,
460
+ killed,
461
+ nextId,
462
+ clusters,
463
+ preamble: preambleLines.join('\n'),
464
+ // Idea-shaped content this parse could NOT turn into an idea.
465
+ unconsumed,
466
+ // Umbrellas named after ids that are also real ideas in this document.
467
+ collisions,
468
+ }
277
469
  }
278
470
 
279
471
  function extractStatus(raw) {
@@ -647,7 +839,15 @@ export function readIdeabox(cwd, ideaboxPath) {
647
839
  return { ideas: [], killed: [], nextId: 1 }
648
840
  }
649
841
  const markdown = readFileSync(fullPath, 'utf-8')
650
- return parseIdeabox(markdown)
842
+ // FU-2. Returning a partial parse from here made "I could not read this file"
843
+ // indistinguishable from "this file has nothing in it" for every caller —
844
+ // most visibly `compose new --from-idea`, which reported "idea not found" for
845
+ // an idea plainly present in the file and then built a feature without the
846
+ // content it had been asked for. The assertion lives in its own leaf module
847
+ // (`fluid/ideabox-readable.js`) precisely so the parser's module can call it:
848
+ // the migration gate imports `parseIdeabox` from here, so keeping it there
849
+ // would close an import cycle.
850
+ return assertIdeaboxReadable(parseIdeabox(markdown), fullPath)
651
851
  }
652
852
 
653
853
  /**