@smartmemory/compose 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Parse markdown text containing ```decision fenced blocks.
3
+ * Returns { parts: Array<{ type: 'text'|'decision', content: string|object }> }.
4
+ *
5
+ * This module is shared by the shipped server and the browser source. Keep it
6
+ * under lib/ so npm installs do not need the otherwise development-only src/.
7
+ */
8
+ export function parseDecisionBlocks(text) {
9
+ const parts = [];
10
+ const regex = /```decision\n([\s\S]*?)```/g;
11
+ let lastIndex = 0;
12
+ let match;
13
+
14
+ while ((match = regex.exec(text)) !== null) {
15
+ if (match.index > lastIndex) {
16
+ parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
17
+ }
18
+
19
+ const raw = match[1].trim();
20
+ try {
21
+ parts.push({ type: 'decision', content: JSON.parse(raw) });
22
+ } catch {
23
+ parts.push({ type: 'text', content: raw });
24
+ }
25
+
26
+ lastIndex = match.index + match[0].length;
27
+ }
28
+
29
+ if (lastIndex < text.length) {
30
+ parts.push({ type: 'text', content: text.slice(lastIndex) });
31
+ }
32
+
33
+ if (parts.length === 0) {
34
+ parts.push({ type: 'text', content: text });
35
+ }
36
+
37
+ return { parts };
38
+ }
@@ -58,6 +58,12 @@ const EVENT_FIELDS = {
58
58
  'build_id', 'feature_code', 'step_id', 'attempt', 'model',
59
59
  'effort_intended', 'effort_executed', 'tokens_in', 'tokens_out',
60
60
  'tokens_total', 'usd', 'duration_ms', 'note',
61
+ // COMP-BUILD-CANCEL S03-6 (C44): compose's DERIVED transport for the run —
62
+ // provider codex plus a cancellationId sent implies exec. Never `transport`:
63
+ // stratum reports none on its ConnectorResult, so this is a rule, not an
64
+ // observation. Registered here AND in the `dispatch` validator below, because
65
+ // this list is an allow-list and an unknown field drops the whole event.
66
+ 'transport_derived',
61
67
  ],
62
68
  },
63
69
  settlement: {
@@ -146,6 +152,7 @@ function validateEvent(event, { allowExtra = false } = {}) {
146
152
  optionalNullableNumber(event, field);
147
153
  }
148
154
  optionalString(event, 'note');
155
+ optionalNullableString(event, 'transport_derived');
149
156
  break;
150
157
  case 'settlement':
151
158
  requireString(event, 'dispatch_id');
@@ -6,13 +6,17 @@
6
6
  * model IDs degrade to usd:null rather than crashing — a crashed / future
7
7
  * model still yields a record with partial metrics.
8
8
  *
9
- * Price source: Anthropic public pricing page + OpenAI pricing (as of 2026-07).
9
+ * Claude 5 rates: COMP-FABLE-ASTRA slice 1 (2026-09). Earlier rates retained
10
+ * for historical receipts (Anthropic / OpenAI, 2026-07).
10
11
  * Keys are prefix-matched so dated variants (e.g. claude-sonnet-4-6-20250514)
11
12
  * resolve against the base key.
12
13
  */
13
14
 
14
15
  /** @type {Record<string, { inputPerMTok: number, outputPerMTok: number }>} */
15
16
  const EXPERIMENT_PRICING = {
17
+ 'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
18
+ 'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
19
+ 'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
16
20
  // Claude 4.x
17
21
  'claude-opus-4-8': { inputPerMTok: 5, outputPerMTok: 25 },
18
22
  'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },
package/lib/flow-state.js CHANGED
@@ -35,3 +35,41 @@ export function readFlowRound(flowId) {
35
35
  return Number.isInteger(r) && r >= 0 ? r : 0;
36
36
  } catch { return 0; }
37
37
  }
38
+
39
+ /** Strict persisted snapshot: callers must hold on unreadable or mismatched evidence. */
40
+ export function readFlowSnapshot(flowId, { revisionDigest, gateStepId, gateToken } = {}) {
41
+ const refuse = message => { throw Object.assign(new Error(message), { code: 'WAVE_COST_UNVERIFIED' }); };
42
+ if (typeof flowId !== 'string' || !/^[\w-]+$/.test(flowId)) refuse('Invalid flow identity');
43
+ let state;
44
+ try {
45
+ const root = process.env.STRATUM_STATE_ROOT || join(homedir(), '.stratum', 'ts', 'flows');
46
+ state = JSON.parse(readFileSync(join(root, `${flowId}.json`), 'utf8'));
47
+ } catch (error) { refuse(`Cannot read persisted flow: ${error.message}`); }
48
+ if (state?.id !== flowId || !revisionDigest || state.revisionDigest !== revisionDigest) refuse('Flow revision/identity differs');
49
+ if (gateStepId && (state.steps?.[gateStepId]?.status !== 'waiting_gate'
50
+ || state.steps[gateStepId].gateToken !== gateToken)) refuse('Persisted gate token differs');
51
+ return state;
52
+ }
53
+
54
+ export function readFlowSpend(flowId, options, pending = []) {
55
+ const snapshot = readFlowSnapshot(flowId, options);
56
+ const fail = message => { throw Object.assign(new Error(message), { code: 'WAVE_COST_UNVERIFIED' }); };
57
+ if (pending.some(p => p.state !== 'acknowledged')) fail('Unacknowledged usage receipts');
58
+ if (!Array.isArray(snapshot.receipts)) fail('Missing receipt spine');
59
+ const ids = new Set();
60
+ let spent = 0;
61
+ for (const receipt of snapshot.receipts) {
62
+ if (!receipt.dispatchId || ids.has(receipt.dispatchId)) fail('Invalid/duplicate receipt identity');
63
+ ids.add(receipt.dispatchId);
64
+ const usd = receipt.amount?.usd;
65
+ if (receipt.detail?.costUnknown) fail('Model call has no attributed cost');
66
+ if (usd === undefined) {
67
+ if (receipt.amount?.tokens > 0 || receipt.amount?.ms > 0) fail('Paid call cost missing');
68
+ continue;
69
+ }
70
+ if (!Number.isFinite(usd) || usd < 0 || !['reported', 'estimated'].includes(receipt.usdSource)) fail('Unattributed USD');
71
+ spent += usd;
72
+ }
73
+ for (const p of pending) if (!ids.has(p.dispatchId)) fail('Acknowledged receipt absent from snapshot');
74
+ return { spent, input: snapshot.input };
75
+ }
@@ -20,9 +20,17 @@
20
20
  */
21
21
 
22
22
  import { existsSync, readFileSync } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { join, resolve } from 'node:path';
24
24
 
25
25
  import { getSmartmemoryConfig } from '../smartmemory-config.js';
26
+
27
+ /**
28
+ * Well under the substrate's own membership cap of 100
29
+ * (`auth_repository.py:730`). A portfolio turn costs one round trip per member
30
+ * with no server-side batching, so the practical ceiling is latency, not the
31
+ * substrate.
32
+ */
33
+ const MAX_PORTFOLIO_MEMBERS = 16;
26
34
  import { FluidConfigError, MUTATION_SCOPE, mutationScopeAtLeast } from './provider.js';
27
35
  import { LocalFluidProvider } from './local-provider.js';
28
36
  import { SmartMemoryFluidProvider } from './smartmemory-provider.js';
@@ -74,6 +82,109 @@ function loadFluidConfig(cwd) {
74
82
  return fluid;
75
83
  }
76
84
 
85
+ /**
86
+ * @typedef {object} PortfolioMember
87
+ * @property {string} id the label the user chose; unique within the portfolio
88
+ * @property {string} root absolute path to that member's Compose project
89
+ */
90
+
91
+ /**
92
+ * Parse and validate `fluid.portfolio` (FOH-7).
93
+ *
94
+ * Read HERE, in the authoritative validating reader, and deliberately not in
95
+ * `lib/maya-config.js`: that one swallows a malformed config as `{}`, so a
96
+ * portfolio with a typo in it would come back as "no portfolio declared" and the
97
+ * turn would quietly answer for one product instead of refusing. A config with
98
+ * two readers where only one validates is how a misconfiguration becomes a
99
+ * silent downgrade.
100
+ *
101
+ * @param {string} cwd the declaring root
102
+ * @returns {{members: PortfolioMember[]} | null} null when none is declared
103
+ */
104
+ export function parsePortfolioConfig(cwd) {
105
+ const fluid = loadFluidConfig(cwd);
106
+ const portfolio = fluid.portfolio;
107
+ if (portfolio === undefined || portfolio === null) return null;
108
+
109
+ const where = join(cwd, '.compose/compose.json');
110
+ if (typeof portfolio !== 'object' || Array.isArray(portfolio)) {
111
+ throw new FluidConfigError(
112
+ `compose: fluid.portfolio at ${where} must be an object`, { path: where },
113
+ );
114
+ }
115
+ const declared = portfolio.members;
116
+ if (!Array.isArray(declared) || declared.length === 0) {
117
+ throw new FluidConfigError(
118
+ `compose: fluid.portfolio.members at ${where} must be a non-empty array — ` +
119
+ `a portfolio with no members is a portfolio that cannot answer anything`,
120
+ { path: where },
121
+ );
122
+ }
123
+ if (declared.length > MAX_PORTFOLIO_MEMBERS) {
124
+ throw new FluidConfigError(
125
+ `compose: fluid.portfolio.members at ${where} declares ${declared.length} members, ` +
126
+ `over the limit of ${MAX_PORTFOLIO_MEMBERS}`,
127
+ { path: where },
128
+ );
129
+ }
130
+
131
+ const members = [];
132
+ const seen = new Set();
133
+ for (const entry of declared) {
134
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
135
+ throw new FluidConfigError(
136
+ `compose: every fluid.portfolio.members entry at ${where} must be an object`, { path: where },
137
+ );
138
+ }
139
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
140
+ const rawRoot = typeof entry.root === 'string' ? entry.root.trim() : '';
141
+ if (!id || !rawRoot) {
142
+ throw new FluidConfigError(
143
+ `compose: every fluid.portfolio.members entry at ${where} needs a non-empty "id" and "root"`,
144
+ { path: where },
145
+ );
146
+ }
147
+ if (seen.has(id)) {
148
+ throw new FluidConfigError(
149
+ `compose: fluid.portfolio.members at ${where} declares "${id}" twice — ` +
150
+ `ids label the source of every result, so a duplicate makes the answer ambiguous`,
151
+ { path: where, id },
152
+ );
153
+ }
154
+ seen.add(id);
155
+
156
+ const root = resolve(cwd, rawRoot);
157
+ // A member must be a Compose project, and `.compose/compose.json` is what
158
+ // makes it one. Accepting a bare `.compose/` directory would fall through to
159
+ // the local provider and contribute an empty corpus — indistinguishable, in
160
+ // the answer, from a real product that happens to have no ideas.
161
+ if (!existsSync(join(root, '.compose/compose.json'))) {
162
+ throw new FluidConfigError(
163
+ `compose: fluid.portfolio member "${id}" at ${where} points at ${root}, ` +
164
+ `which is not a Compose project (no .compose/compose.json)`,
165
+ { path: where, id, root },
166
+ );
167
+ }
168
+ members.push({ id, root });
169
+ }
170
+
171
+ // Self-membership is never INFERRED — that would be the discovery D-FOH-7-2
172
+ // forbids. But a portfolio that omits the corpus the user is looking at
173
+ // silently answers without it, so its absence is refused by name rather than
174
+ // producing a quietly smaller result.
175
+ const declaringRoot = resolve(cwd);
176
+ if (!members.some((m) => m.root === declaringRoot)) {
177
+ throw new FluidConfigError(
178
+ `compose: fluid.portfolio at ${where} does not list its own declaring root. ` +
179
+ `Membership is never inferred, so this project would be excluded from its own ` +
180
+ `portfolio — add an entry with "root": "." if that is not what you meant`,
181
+ { path: where, declaringRoot },
182
+ );
183
+ }
184
+
185
+ return { members };
186
+ }
187
+
77
188
  /**
78
189
  * Construct the configured provider.
79
190
  *
@@ -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
+ }