@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.
- package/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- 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
|
+
}
|
package/lib/dispatch-ledger.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
}
|
package/lib/fluid/factory.js
CHANGED
|
@@ -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
|
+
}
|