@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
|
@@ -34,9 +34,16 @@
|
|
|
34
34
|
import { existsSync, readFileSync } from 'node:fs';
|
|
35
35
|
|
|
36
36
|
import { parseIdeabox } from '../ideabox.js';
|
|
37
|
+
import { hashMarkdown, readManifest } from './ideabox-manifest.js';
|
|
38
|
+
// FU-2: the readability check lives in its own leaf module so the PARSER's own
|
|
39
|
+
// module can import it without closing a cycle through this one. Re-exported
|
|
40
|
+
// here because this was its address for its whole life so far.
|
|
41
|
+
import { IdeaboxUnreadable, assertIdeaboxReadable } from './ideabox-readable.js';
|
|
37
42
|
import { importIdeabox } from './import-ideabox.js';
|
|
38
43
|
import { KIND } from './provider.js';
|
|
39
44
|
|
|
45
|
+
export { IdeaboxUnreadable, assertIdeaboxReadable };
|
|
46
|
+
|
|
40
47
|
export class IdeaboxMigrationConflict extends Error {
|
|
41
48
|
constructor(missing, ideaboxPath) {
|
|
42
49
|
super(
|
|
@@ -54,43 +61,129 @@ export class IdeaboxMigrationConflict extends Error {
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
57
|
-
*
|
|
64
|
+
* An interrupted migration whose source document has changed underneath it.
|
|
58
65
|
*
|
|
59
|
-
*
|
|
60
|
-
* the
|
|
66
|
+
* Distinct from a conflict: the entries are ones this store PLANNED to write,
|
|
67
|
+
* so they are not strays — but the plan was made against a different version of
|
|
68
|
+
* the file, and importing the current one would import a document nobody
|
|
69
|
+
* checked. FU-1.
|
|
70
|
+
*/
|
|
71
|
+
export class IdeaboxManifestStale extends Error {
|
|
72
|
+
constructor(ideaboxPath) {
|
|
73
|
+
super(
|
|
74
|
+
`compose: the ideabox at ${ideaboxPath} has an unfinished migration recorded against a ` +
|
|
75
|
+
`DIFFERENT version of this file — the document was most likely edited while the migration ` +
|
|
76
|
+
`was running, or after it was interrupted. Resuming would import a version nobody checked, ` +
|
|
77
|
+
`and the entries added since would be projected away. Nothing has been changed. Decide which ` +
|
|
78
|
+
`document is right and say so, with one of:\n` +
|
|
79
|
+
` compose ideabox adopt-file the file on disk is right — finish the migration against ` +
|
|
80
|
+
`it, updating what was already imported to match and importing the rest\n` +
|
|
81
|
+
` compose ideabox discard-edits the migration is right — put the file back as it was read ` +
|
|
82
|
+
`and finish it (a copy of the current file is saved first, and its path printed)\n` +
|
|
83
|
+
`Deleting the record of the unfinished migration is NOT a shortcut: it makes the partially ` +
|
|
84
|
+
`migrated store canon, and the next render replaces this file with a projection of it.`
|
|
85
|
+
);
|
|
86
|
+
this.name = 'IdeaboxManifestStale';
|
|
87
|
+
this.code = 'IDEABOX_MANIFEST_STALE';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The source document changed between the gate's check and the projection's
|
|
93
|
+
* write. FU-3.
|
|
61
94
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
95
|
+
* The remedy is genuinely different from a conflict's — nothing is wrong with
|
|
96
|
+
* the file or the store, the two writers simply interleaved — so it does not
|
|
97
|
+
* reuse `IdeaboxMigrationConflict`, whose text tells the user to re-add ideas
|
|
98
|
+
* by hand.
|
|
99
|
+
*/
|
|
100
|
+
export class IdeaboxChangedUnderLock extends Error {
|
|
101
|
+
constructor(ideaboxPath, reason) {
|
|
102
|
+
super(
|
|
103
|
+
`compose: the ideabox at ${ideaboxPath} changed between the check and the write ` +
|
|
104
|
+
`(${reason}), so the projection was abandoned rather than replacing content that was ` +
|
|
105
|
+
`never checked. Nothing has been changed. Run the command again.`
|
|
106
|
+
);
|
|
107
|
+
this.name = 'IdeaboxChangedUnderLock';
|
|
108
|
+
this.code = 'IDEABOX_CHANGED_UNDER_LOCK';
|
|
109
|
+
this.reason = reason;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* PURE. What the gate WOULD do, computed without doing any of it.
|
|
65
115
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
116
|
+
* Split out of `ensureIdeaboxMigrated` for FU-3: the projection has to re-run
|
|
117
|
+
* this decision INSIDE its write lock, and it can only do that if making the
|
|
118
|
+
* decision writes nothing and takes no lock. Reads exactly three things —
|
|
119
|
+
* `listRecords`, `readEvents`, the markdown — none of which lock, so running it
|
|
120
|
+
* under the projection lock cannot deadlock the way moving the whole gate under
|
|
121
|
+
* it does (measured: a full 30s lock timeout, see `render-ideabox.js`).
|
|
68
122
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
123
|
+
* Refusals are RETURNED, not thrown, because a decision procedure that throws
|
|
124
|
+
* cannot be used to ask a question.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{action: 'none'|'import', markdown?: string} |
|
|
127
|
+
* {action: 'refuse', reason: string, error: Error}>}
|
|
72
128
|
*/
|
|
73
|
-
export async function
|
|
129
|
+
export async function assessIdeabox(provider, ideaboxPath) {
|
|
74
130
|
const records = await provider.listRecords({ kind: KIND.IDEA });
|
|
75
131
|
const markdown = existsSync(ideaboxPath) ? readFileSync(ideaboxPath, 'utf8') : null;
|
|
76
132
|
|
|
77
|
-
if (markdown === null) return {
|
|
133
|
+
if (markdown === null) return { action: 'none' };
|
|
78
134
|
|
|
79
135
|
const parsed = parseIdeabox(markdown);
|
|
80
136
|
const inMarkdown = [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id);
|
|
81
137
|
|
|
138
|
+
try {
|
|
139
|
+
assertIdeaboxReadable(parsed, ideaboxPath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { action: 'refuse', reason: 'unreadable', error };
|
|
142
|
+
}
|
|
143
|
+
|
|
82
144
|
const known = new Set(records.map((r) => r.handle));
|
|
83
145
|
const missing = inMarkdown.filter((id) => !known.has(id));
|
|
84
146
|
|
|
85
147
|
if (records.length === 0) {
|
|
86
|
-
if (inMarkdown.length === 0) return {
|
|
148
|
+
if (inMarkdown.length === 0) return { action: 'none' };
|
|
87
149
|
// The upgrade path.
|
|
88
|
-
|
|
89
|
-
|
|
150
|
+
return { action: 'import', markdown };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// AN OPEN MANIFEST MEANS THE MIGRATION NEVER FINISHED, and that is a fact
|
|
154
|
+
// about the DOCUMENT, not only about the handles that happen to be absent.
|
|
155
|
+
//
|
|
156
|
+
// Scoping this to the missing-handles branch was wrong in a way the ID
|
|
157
|
+
// comparison hides: an edit to an idea that ALREADY has a record changes no
|
|
158
|
+
// ID, so nothing is missing, and the gate waved a file through that the
|
|
159
|
+
// projection then rewrote from the version read at the start of the import.
|
|
160
|
+
// Until the migration completes this file is still the SOURCE document, so
|
|
161
|
+
// the question here is whether it is the same document, not whether its ids
|
|
162
|
+
// line up. (After the migration there is no manifest, so `render` still
|
|
163
|
+
// discards hand edits to what is by then generated output — that contract is
|
|
164
|
+
// untouched.)
|
|
165
|
+
const manifest = readManifest(provider, ideaboxPath);
|
|
166
|
+
const planned = new Set();
|
|
167
|
+
if (manifest) {
|
|
168
|
+
if (manifest.hash !== hashMarkdown(markdown)) {
|
|
169
|
+
// Planned against a different document. Every handle it names is suspect,
|
|
170
|
+
// and so is every handle in the file now.
|
|
171
|
+
return {
|
|
172
|
+
action: 'refuse',
|
|
173
|
+
reason: 'manifest-stale',
|
|
174
|
+
error: new IdeaboxManifestStale(ideaboxPath),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
for (const handle of manifest.planned) planned.add(handle);
|
|
178
|
+
// Same document, unfinished import. Finishing it is a no-op for handles
|
|
179
|
+
// that already landed, and it is what closes the manifest — so a crash
|
|
180
|
+
// between the last write and the close heals on the next command instead of
|
|
181
|
+
// leaving the store in a state every later check has to reason about.
|
|
182
|
+
if (missing.length === 0) return { action: 'import', markdown };
|
|
90
183
|
}
|
|
91
184
|
|
|
92
185
|
if (missing.length) {
|
|
93
|
-
// RESUME versus REFUSE, decided PER HANDLE
|
|
186
|
+
// RESUME versus REFUSE, decided PER HANDLE.
|
|
94
187
|
//
|
|
95
188
|
// A crash partway through the first-use import leaves some records written
|
|
96
189
|
// and the rest missing, which lands here rather than in the empty-store
|
|
@@ -99,29 +192,84 @@ export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
|
|
|
99
192
|
// fails and there is no way out — and the reclaim path built for exactly
|
|
100
193
|
// this case is never reached.
|
|
101
194
|
//
|
|
102
|
-
// The log distinguishes the two populations. A handle the import already
|
|
103
|
-
// burned carries an event; `importIdeabox` skips live records and reclaims
|
|
104
|
-
// its own aborted allocations, so resuming is safe and lossless.
|
|
105
|
-
//
|
|
106
195
|
// The evidence has to be per-handle, not "did an import ever run". Once the
|
|
107
196
|
// first import succeeds the log carries `imported` events forever, so a
|
|
108
197
|
// global check would quietly import anything later hand-added to what is
|
|
109
198
|
// now generated output — losing the very protection this gate exists for.
|
|
110
|
-
// A handle with no event was never issued here: it
|
|
111
|
-
// by hand, and importing it would treat the
|
|
112
|
-
// it no longer is.
|
|
113
|
-
//
|
|
199
|
+
// A handle with no event and no manifest entry was never issued here: it
|
|
200
|
+
// was typed into the file by hand, and importing it would treat the
|
|
201
|
+
// markdown as authoritative when it no longer is.
|
|
202
|
+
//
|
|
203
|
+
// Two sources of evidence, because neither alone covers the whole failure:
|
|
204
|
+
//
|
|
205
|
+
// - THE EVENT LOG covers handles the import REACHED. `importIdeabox`
|
|
206
|
+
// skips live records and reclaims its own aborted allocations, so a
|
|
207
|
+
// handle that was issued and not deleted is a create that crashed, and
|
|
208
|
+
// resuming it is safe and lossless.
|
|
209
|
+
// - THE MANIFEST covers the handles it never reached. An import that dies
|
|
210
|
+
// on idea 2 leaves ideas 3..N with no event anywhere, and no amount of
|
|
211
|
+
// reading the log afterwards can distinguish them from strays — the
|
|
212
|
+
// intention has to have been written down first (FU-1).
|
|
213
|
+
//
|
|
214
|
+
// Three populations, and only two of them are resumable:
|
|
114
215
|
// - issued, no `deleted` event → a create that crashed. RESUME.
|
|
216
|
+
// - planned by an OPEN manifest whose hash still matches this file
|
|
217
|
+
// → never attempted. RESUME.
|
|
115
218
|
// - issued, `deleted` event → deliberately retired; this file is just
|
|
116
219
|
// stale output. REFUSE (a render fixes it,
|
|
117
220
|
// and importing would resurrect it).
|
|
118
|
-
// -
|
|
221
|
+
// - neither → hand-typed into generated output. REFUSE.
|
|
119
222
|
const { issued, deleted } = await handleHistory(provider);
|
|
120
|
-
|
|
223
|
+
|
|
224
|
+
const resumable = missing.filter(
|
|
225
|
+
(id) => (issued.has(id) && !deleted.has(id)) || (planned.has(id) && !deleted.has(id)),
|
|
226
|
+
);
|
|
121
227
|
const strays = missing.filter((id) => !resumable.includes(id));
|
|
122
|
-
if (strays.length)
|
|
228
|
+
if (strays.length) {
|
|
229
|
+
return {
|
|
230
|
+
action: 'refuse',
|
|
231
|
+
reason: 'stray-entries',
|
|
232
|
+
error: new IdeaboxMigrationConflict(strays, ideaboxPath),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { action: 'import', markdown };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { action: 'none' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ensure the record store reflects the markdown before any mutation touches it.
|
|
244
|
+
*
|
|
245
|
+
* Runs before every mutating ideabox command. Three states, one of which stops
|
|
246
|
+
* the command:
|
|
247
|
+
*
|
|
248
|
+
* - **no records, markdown has entries** → import it (the upgrade path)
|
|
249
|
+
* - **records exist, markdown adds nothing** → already migrated, proceed
|
|
250
|
+
* - **records exist, markdown has entries with no record** → refuse
|
|
251
|
+
*
|
|
252
|
+
* A fresh project with no markdown and no records is the trivial first case and
|
|
253
|
+
* simply proceeds.
|
|
254
|
+
*
|
|
255
|
+
* Assess, then act. The decision is `assessIdeabox` and this is the only thing
|
|
256
|
+
* that executes it; the split exists so the projection can ask the same
|
|
257
|
+
* question under its lock (FU-3) without re-implementing the answer.
|
|
258
|
+
*
|
|
259
|
+
* @param {import('./provider.js').FluidProvider} provider
|
|
260
|
+
* @param {string} ideaboxPath absolute path to the markdown ideabox
|
|
261
|
+
* @returns {Promise<{migrated: boolean, imported: string[]}>}
|
|
262
|
+
*/
|
|
263
|
+
export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
|
|
264
|
+
const assessment = await assessIdeabox(provider, ideaboxPath);
|
|
265
|
+
|
|
266
|
+
if (assessment.action === 'refuse') throw assessment.error;
|
|
123
267
|
|
|
124
|
-
|
|
268
|
+
if (assessment.action === 'import') {
|
|
269
|
+
const result = await importIdeabox(provider, {
|
|
270
|
+
markdown: assessment.markdown,
|
|
271
|
+
path: ideaboxPath,
|
|
272
|
+
});
|
|
125
273
|
return { migrated: true, imported: result.imported };
|
|
126
274
|
}
|
|
127
275
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/ideabox-preamble.js — a project's own heading and introduction,
|
|
3
|
+
* kept beside the projection rather than inside it.
|
|
4
|
+
*
|
|
5
|
+
* COMP-IDEABOX-MIGRATE-DIALECT FU-4.
|
|
6
|
+
*
|
|
7
|
+
* THE DEFECT
|
|
8
|
+
* ----------
|
|
9
|
+
* `renderIdeabox` emits a hardcoded template preamble, so the first projection
|
|
10
|
+
* after a migration replaced a project's own title and introductory prose with
|
|
11
|
+
* the standard one. Ideas, clusters, custom fields and bodies all survived; the
|
|
12
|
+
* document AROUND them did not. Measured on forge-top: `# Forge Ideabox` and
|
|
13
|
+
* its two-line introduction were destroyed.
|
|
14
|
+
*
|
|
15
|
+
* WHY A SIDECAR AND NOT A RECORD
|
|
16
|
+
* ------------------------------
|
|
17
|
+
* The obvious fix — carry the preamble forward from the file being replaced —
|
|
18
|
+
* was implemented and rejected, because it makes the DESTINATION authoritative
|
|
19
|
+
* and `render` is documented as the way back from any hand edit
|
|
20
|
+
* (`lib/ideabox-cli.js`). A projection that reads its own destination cannot
|
|
21
|
+
* repair it.
|
|
22
|
+
*
|
|
23
|
+
* The alternative was to make the preamble a record. The owner rejected that:
|
|
24
|
+
* it means a new ontology type in every SmartMemory tenant and a decision about
|
|
25
|
+
* what a remote store does with per-document text. The preamble is not a
|
|
26
|
+
* property of the idea corpus at all — it is a property of THIS FILE, and the
|
|
27
|
+
* file is local no matter which provider holds the records.
|
|
28
|
+
*
|
|
29
|
+
* The projection therefore stays independent of its destination: it is rendered
|
|
30
|
+
* from the records plus this sidecar, and a hand edit to the file's heading is
|
|
31
|
+
* still discarded by the next render, exactly as a hand edit to an idea is.
|
|
32
|
+
*
|
|
33
|
+
* WHY IT IS TRACKED, AND NOT BESIDE THE MANIFEST
|
|
34
|
+
* ----------------------------------------------
|
|
35
|
+
* The first version of this put the sidecar in gitignored `.compose/data/`,
|
|
36
|
+
* next to the migration manifest, keyed off `provider.lockPath`. That was
|
|
37
|
+
* wrong twice.
|
|
38
|
+
*
|
|
39
|
+
* It made a TRACKED projection depend on UNTRACKED input. The heading then
|
|
40
|
+
* survived only on the machine that ran the migration: any other clone — a
|
|
41
|
+
* teammate, CI — found no sidecar, rendered the template over the custom
|
|
42
|
+
* heading and committed that, and the migrating machine restored it on its next
|
|
43
|
+
* render. FU-4's own defect, recurring on every clone, plus git churn on a
|
|
44
|
+
* tracked file. This is the exact failure the owner already ruled on: records
|
|
45
|
+
* were moved OUT of gitignored `vision-state.json` in the S3 entry-gate ruling
|
|
46
|
+
* of 2026-08-04 (see the header of `local-provider.js`) because canon that a
|
|
47
|
+
* tracked file is generated from cannot itself be ignored. The manifest is a
|
|
48
|
+
* fair neighbour for none of this: it is TRANSIENT, alive only between the
|
|
49
|
+
* start and the end of one import, while the preamble is durable content with
|
|
50
|
+
* the same lifecycle as the records.
|
|
51
|
+
*
|
|
52
|
+
* And keying off `provider.lockPath` excluded SmartMemory, which has no lock —
|
|
53
|
+
* the very provider whose existence was the argument for a local sidecar rather
|
|
54
|
+
* than a record. Keyed off the ideabox path instead, every provider gets one.
|
|
55
|
+
*
|
|
56
|
+
* Beside the document it describes, it is also discoverable: the hand-written
|
|
57
|
+
* recovery below is a file someone can find, not a hashed name under a hidden
|
|
58
|
+
* directory. A deliberately named, deliberately committed file is not the stray
|
|
59
|
+
* temp file that tracked directories have to be protected from.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { randomUUID } from 'node:crypto';
|
|
63
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
64
|
+
import { dirname } from 'node:path';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The preamble file for this ideabox: a tracked sibling of it.
|
|
68
|
+
*
|
|
69
|
+
* `docs/product/ideabox.md` → `docs/product/ideabox.preamble.md`. Derived from
|
|
70
|
+
* the ideabox path alone, so it does not depend on which provider holds the
|
|
71
|
+
* records and every provider gets one. Plain markdown rather than JSON so a
|
|
72
|
+
* project that has to write one by hand can just write it.
|
|
73
|
+
*/
|
|
74
|
+
export function preamblePath(ideaboxPath) {
|
|
75
|
+
if (!ideaboxPath) return null;
|
|
76
|
+
return String(ideaboxPath).replace(/(\.md)?$/i, '.preamble.md');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The generated banner is not part of anyone's introduction.
|
|
81
|
+
*
|
|
82
|
+
* A document being imported is normally a legacy one and carries no banner, but
|
|
83
|
+
* a store whose records were removed re-imports its own PROJECTION — and the
|
|
84
|
+
* parser hands back everything before `## Ideas`, banner included. Capturing
|
|
85
|
+
* that would make the next render emit the banner twice, and the one after that
|
|
86
|
+
* three times.
|
|
87
|
+
*/
|
|
88
|
+
function withoutBanner(text) {
|
|
89
|
+
const lines = String(text ?? '').split('\n');
|
|
90
|
+
if (!/^\s*<!--/.test(lines[0] ?? '') || !/GENERATED FILE/.test(lines[0] ?? '')) return text;
|
|
91
|
+
const end = lines.findIndex((l) => /-->/.test(l));
|
|
92
|
+
if (end === -1) return text;
|
|
93
|
+
return lines.slice(end + 1).join('\n').replace(/^\n+/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Capture the source document's own preamble.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} ideaboxPath
|
|
100
|
+
* @param {string} text `parseIdeabox(...).preamble` — the parser's own output,
|
|
101
|
+
* never a second scan of the same document. Two readers of one document is
|
|
102
|
+
* the exact shape that produced this whole bug class.
|
|
103
|
+
* @returns {string|null} the path written, or null when there is nothing to
|
|
104
|
+
* write or nowhere to write it
|
|
105
|
+
*/
|
|
106
|
+
export function writePreamble(ideaboxPath, text) {
|
|
107
|
+
const path = preamblePath(ideaboxPath);
|
|
108
|
+
if (!path) return null;
|
|
109
|
+
const body = withoutBanner(text);
|
|
110
|
+
if (!body || !body.trim()) return null;
|
|
111
|
+
// Nothing to do when it already says this. Same reasoning as the manifest: a
|
|
112
|
+
// rewrite that changes nothing is a window in which a crash can lose
|
|
113
|
+
// something, for no gain.
|
|
114
|
+
if (readPreamble(ideaboxPath) === body) return path;
|
|
115
|
+
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
const tmp = `${path}.tmp.${randomUUID()}`;
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(tmp, body, 'utf8');
|
|
120
|
+
renameSync(tmp, path);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
rmSync(tmp, { force: true });
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
return path;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The captured preamble, or null.
|
|
130
|
+
*
|
|
131
|
+
* Null is the ordinary answer for a project that migrated before this existed,
|
|
132
|
+
* and it means the renderer falls back to the standard template — which is what
|
|
133
|
+
* that project already has in its file, so nothing changes for it.
|
|
134
|
+
*/
|
|
135
|
+
export function readPreamble(ideaboxPath) {
|
|
136
|
+
const path = preamblePath(ideaboxPath);
|
|
137
|
+
if (!path || !existsSync(path)) return null;
|
|
138
|
+
try {
|
|
139
|
+
const text = readFileSync(path, 'utf8');
|
|
140
|
+
if (!text.trim()) return null;
|
|
141
|
+
// Trailing blank lines are structural, not content — the renderer puts its
|
|
142
|
+
// own separator before `## Ideas`, and the parser pops them on the way back
|
|
143
|
+
// (`lib/ideabox.js`), so leaving one here costs the fixed point that the
|
|
144
|
+
// whole cutover rests on. `writePreamble` never stores one because it
|
|
145
|
+
// stores what the parser already popped; a sidecar written BY HAND does,
|
|
146
|
+
// because every editor ends a file with a newline — and writing one by hand
|
|
147
|
+
// is the documented recovery for a project that migrated before this
|
|
148
|
+
// existed.
|
|
149
|
+
const lines = text.split('\n');
|
|
150
|
+
while (lines.length && lines.at(-1).trim() === '') lines.pop();
|
|
151
|
+
return lines.length ? lines.join('\n') : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/ideabox-readable.js — ONE readability check, for every caller.
|
|
3
|
+
*
|
|
4
|
+
* COMP-IDEABOX-MIGRATE-DIALECT FU-2.
|
|
5
|
+
*
|
|
6
|
+
* The original bug was a parse that failed being read as "this file has no
|
|
7
|
+
* ideas", after which the next projection write replaced the file with a
|
|
8
|
+
* projection that did not contain them — 18 ideas destroyed by one command.
|
|
9
|
+
* The fix for that bug put the readability check in the MIGRATION GATE. That
|
|
10
|
+
* guarded the destructive path but not the parse: every other caller of
|
|
11
|
+
* `parseIdeabox` still read failure as absence.
|
|
12
|
+
*
|
|
13
|
+
* This module is the check itself, extracted so there is one implementation
|
|
14
|
+
* instead of one per caller. It is a LEAF on purpose: it takes an already
|
|
15
|
+
* parsed document and knows nothing about providers, stores or files.
|
|
16
|
+
* `lib/ideabox.js` (the parser) has to be able to import it, and
|
|
17
|
+
* `ideabox-migrate.js` already imports `parseIdeabox` FROM `lib/ideabox.js` —
|
|
18
|
+
* so keeping the assertion in the gate and wiring it into `readIdeabox` would
|
|
19
|
+
* close an import cycle. Hence its own module, re-exported from
|
|
20
|
+
* `ideabox-migrate.js` so existing importers are unaffected.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export class IdeaboxUnreadable extends Error {
|
|
24
|
+
constructor(unread, ideaboxPath) {
|
|
25
|
+
super(
|
|
26
|
+
`compose: the ideabox at ${ideaboxPath} declares ${unread.length} idea(s) this version cannot ` +
|
|
27
|
+
`read: ${unread.join(', ')}. Refusing rather than proceeding, because continuing would treat ` +
|
|
28
|
+
`them as absent and the next write would overwrite this file with a projection that does not ` +
|
|
29
|
+
`contain them. This usually means the file is in a dialect newer or older than this install, ` +
|
|
30
|
+
`or is partly converted. Nothing has been changed. Back the file up, then either upgrade ` +
|
|
31
|
+
`compose or convert the entries by hand.`
|
|
32
|
+
);
|
|
33
|
+
this.name = 'IdeaboxUnreadable';
|
|
34
|
+
this.code = 'IDEABOX_UNREADABLE';
|
|
35
|
+
this.unread = unread;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Throw unless the parse actually understood the document.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} parsed a `parseIdeabox` result
|
|
43
|
+
* @param {string} ideaboxPath named in the error, for the human who has to fix it
|
|
44
|
+
* @returns {object} the same `parsed`, so callers can `return assertIdeaboxReadable(...)`
|
|
45
|
+
*/
|
|
46
|
+
export function assertIdeaboxReadable(parsed, ideaboxPath) {
|
|
47
|
+
const inMarkdown = [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id);
|
|
48
|
+
|
|
49
|
+
// READABILITY BEFORE SEMANTICS.
|
|
50
|
+
//
|
|
51
|
+
// Every caller reads `inMarkdown` as a statement about what the user has.
|
|
52
|
+
// That is only true if the parse actually understood the file. When it did
|
|
53
|
+
// not, an id vanishes from `inMarkdown` and every branch silently reads its
|
|
54
|
+
// absence as consent — "no ideas here" — and the next write projects over it.
|
|
55
|
+
// That is not a hypothetical: it destroyed 18 ideas in one command
|
|
56
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT).
|
|
57
|
+
//
|
|
58
|
+
// So compare what the file DECLARES against what the parser PRODUCED, and
|
|
59
|
+
// stop on any gap. This deliberately catches more than the empty parse that
|
|
60
|
+
// motivated it: a half-converted file yields some ideas and hides the rest,
|
|
61
|
+
// which every count-based check (`inMarkdown.length === 0`) waves straight
|
|
62
|
+
// through while it is just as destructive.
|
|
63
|
+
// What the parse could see but not read. Taken from the parser itself, not
|
|
64
|
+
// from a second scan of the same text: two readers of one document is the
|
|
65
|
+
// exact shape that produced this bug and three of its follow-ons.
|
|
66
|
+
const unread = [...new Set(parsed.unconsumed ?? [])].filter((id) => !inMarkdown.includes(id));
|
|
67
|
+
if (unread.length) throw new IdeaboxUnreadable(unread, ideaboxPath);
|
|
68
|
+
|
|
69
|
+
// The same id declared twice is the half-converted document: the parser reads
|
|
70
|
+
// one copy, so the check above is satisfied while the other — routinely the
|
|
71
|
+
// older, richer one — is invisible and would be deleted by the next render.
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const duplicated = [...new Set([
|
|
74
|
+
...[...inMarkdown, ...(parsed.unconsumed ?? [])].filter((id) => seen.size === seen.add(id).size),
|
|
75
|
+
// An umbrella named after an idea that also exists here as a real idea:
|
|
76
|
+
// the half-converted document, where the richer original survives only as
|
|
77
|
+
// the heading the parser turned into a cluster.
|
|
78
|
+
...(parsed.collisions ?? []),
|
|
79
|
+
])];
|
|
80
|
+
if (duplicated.length) throw new IdeaboxUnreadable(duplicated, ideaboxPath);
|
|
81
|
+
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|