@smartmemory/compose 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -1
- package/bin/compose.js +33 -14
- 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/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- 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 +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -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/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- 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/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,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
|
+
}
|
|
@@ -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
|
+
}
|