@smartmemory/compose 0.4.1 → 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
|
@@ -21,6 +21,14 @@ import { readFileSync } from 'node:fs';
|
|
|
21
21
|
|
|
22
22
|
import { parseIdeabox } from '../ideabox.js';
|
|
23
23
|
import { toRecordTimestamp } from './ideabox-dates.js';
|
|
24
|
+
import {
|
|
25
|
+
IdeaboxSourceChangedDuringImport,
|
|
26
|
+
closeManifest,
|
|
27
|
+
hashMarkdown,
|
|
28
|
+
openManifest,
|
|
29
|
+
} from './ideabox-manifest.js';
|
|
30
|
+
import { writePreamble } from './ideabox-preamble.js';
|
|
31
|
+
import { assertIdeaboxReadable } from './ideabox-readable.js';
|
|
24
32
|
import { KIND } from './provider.js';
|
|
25
33
|
|
|
26
34
|
/** Markdown status token → canonical fluid status. */
|
|
@@ -59,6 +67,12 @@ function toPriority(raw) {
|
|
|
59
67
|
export async function importIdeabox(provider, { markdown, path, dryRun = false } = {}) {
|
|
60
68
|
const source = markdown ?? readFileSync(path, 'utf8');
|
|
61
69
|
const parsed = parseIdeabox(source);
|
|
70
|
+
// FU-2. This function is directly callable, and it used to read a failed
|
|
71
|
+
// parse as an empty document — importing only the subset it recognised and
|
|
72
|
+
// leaving the rest to be projected away by the next render. Same shape as the
|
|
73
|
+
// bug that destroyed 18 ideas, one level up: the guard was on the destructive
|
|
74
|
+
// path, not on the parse.
|
|
75
|
+
assertIdeaboxReadable(parsed, path ?? '(in-memory ideabox)');
|
|
62
76
|
|
|
63
77
|
const provenance = { origin: 'import:ideabox' };
|
|
64
78
|
|
|
@@ -71,6 +85,43 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
|
|
|
71
85
|
const skipped = [];
|
|
72
86
|
const clusterHandles = [];
|
|
73
87
|
|
|
88
|
+
// FU-1: THE INTENTION, WRITTEN DOWN BEFORE THE FIRST WRITE.
|
|
89
|
+
//
|
|
90
|
+
// Records are created one at a time. A crash on idea 2 leaves ideas 3..N
|
|
91
|
+
// never issued, so they carry no event, so the gate reads them as hand-added
|
|
92
|
+
// strays and refuses — and every recovery it names runs the same gate. The
|
|
93
|
+
// log can only testify about handles the import REACHED; the unattempted tail
|
|
94
|
+
// leaves no trace, so it has to be declared in advance or it cannot be
|
|
95
|
+
// recovered at all.
|
|
96
|
+
//
|
|
97
|
+
// Written before the first `createRecord` of either population, and removed
|
|
98
|
+
// only on success, so "open" means "an import started here and did not
|
|
99
|
+
// finish". A dry run declares nothing because it writes nothing.
|
|
100
|
+
const plannedHandles = [
|
|
101
|
+
...(parsed.ideas ?? []).map((i) => i.id),
|
|
102
|
+
...(parsed.killed ?? []).map((i) => i.id),
|
|
103
|
+
];
|
|
104
|
+
if (!dryRun && plannedHandles.length) {
|
|
105
|
+
openManifest(provider, path, {
|
|
106
|
+
markdown: source,
|
|
107
|
+
planned: plannedHandles,
|
|
108
|
+
plannedClusters: (parsed.clusters ?? []).map((c) => c.name),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// THE DOCUMENT AROUND THE IDEAS (FU-4).
|
|
113
|
+
//
|
|
114
|
+
// A project's own title and introduction are content too, and the projection
|
|
115
|
+
// used to replace them with the standard template on the first render after
|
|
116
|
+
// migration. Captured here, at the one moment the source document is
|
|
117
|
+
// authoritative, from the PARSER'S OWN output rather than a second scan of
|
|
118
|
+
// the same text. It goes to a TRACKED sibling of the ideabox,
|
|
119
|
+
// `<ideabox>.preamble.md` — not beside the migration manifest, which is
|
|
120
|
+
// gitignored, because a tracked projection generated from untracked input
|
|
121
|
+
// loses the heading on every clone that did not run the migration. See
|
|
122
|
+
// `ideabox-preamble.js`.
|
|
123
|
+
if (!dryRun) writePreamble(path, parsed.preamble);
|
|
124
|
+
|
|
74
125
|
// ---- clusters first: members reference them by handle --------------------
|
|
75
126
|
const clusterHandleByName = new Map();
|
|
76
127
|
for (const cluster of parsed.clusters ?? []) {
|
|
@@ -112,52 +163,12 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
|
|
|
112
163
|
skipped.push(idea.id);
|
|
113
164
|
continue;
|
|
114
165
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const record = {
|
|
121
|
-
kind: KIND.IDEA,
|
|
122
|
-
// Verbatim. The whole point of the caller-supplied handle path.
|
|
123
|
-
handle: idea.id,
|
|
124
|
-
title: idea.title,
|
|
125
|
-
body: idea.description ?? '',
|
|
126
|
-
status: killed ? 'killed' : toStatus(idea.status),
|
|
127
|
-
// Keep the author's token when the canonical enum cannot hold it, so a
|
|
128
|
-
// closed enum does not quietly flatten `RE-AIMED (2026-07-21)` to `NEW`.
|
|
129
|
-
status_label: STATUS_MAP[String(idea.status ?? '').trim().toUpperCase()]
|
|
130
|
-
? null
|
|
131
|
-
: (idea.status || null),
|
|
132
|
-
priority: toPriority(idea.priority),
|
|
133
|
-
// Carried, not dropped. `parseIdeabox` already validates both against
|
|
134
|
-
// their enums and yields null otherwise (`lib/ideabox.js:304-311`), so
|
|
135
|
-
// there is nothing to re-check here — but omitting them is not a harmless
|
|
136
|
-
// gap. This function is the first-use migration gate every upgrading
|
|
137
|
-
// install runs, and the render that follows it rewrites the markdown from
|
|
138
|
-
// the records. A dropped field is therefore deleted from the user's file
|
|
139
|
-
// on upgrade, silently, with no way back. No idea in THIS repo carries
|
|
140
|
-
// either, which is exactly why it went unnoticed.
|
|
141
|
-
effort: idea.effort ?? null,
|
|
142
|
-
impact: idea.impact ?? null,
|
|
143
|
-
cluster: clusterHandle,
|
|
144
|
-
cluster_order: clusterOrder,
|
|
145
|
-
tags: idea.tags ?? [],
|
|
146
|
-
source: idea.source || null,
|
|
147
|
-
links: idea.mapsTo ? [{ type: 'maps_to', target: idea.mapsTo }] : [],
|
|
148
|
-
killed: (killed || idea.killedReason)
|
|
149
|
-
? {
|
|
150
|
-
at: toRecordTimestamp(idea.killedDate),
|
|
151
|
-
reason: idea.killedReason || 'reason not recorded in markdown',
|
|
152
|
-
}
|
|
153
|
-
: null,
|
|
154
|
-
discussion: (idea.discussion ?? []).map((d) => ({
|
|
155
|
-
at: toRecordTimestamp(d.date),
|
|
156
|
-
text: d.text ?? '',
|
|
157
|
-
author: d.author ?? null,
|
|
158
|
-
})),
|
|
166
|
+
const record = ideaToRecord(idea, {
|
|
167
|
+
killed,
|
|
168
|
+
parsed,
|
|
169
|
+
clusterHandleByName,
|
|
159
170
|
provenance,
|
|
160
|
-
};
|
|
171
|
+
});
|
|
161
172
|
|
|
162
173
|
if (dryRun) {
|
|
163
174
|
imported.push(idea.id);
|
|
@@ -175,6 +186,36 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
|
|
|
175
186
|
imported.push(idea.id);
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// THE DOCUMENT IS STILL THE USER'S UNTIL THIS FINISHES.
|
|
190
|
+
//
|
|
191
|
+
// Every check up to here compares IDs, and an edit to an idea that ALREADY
|
|
192
|
+
// has a record changes no ID at all: the assessment sees a file whose handles
|
|
193
|
+
// are all known, calls it consistent, and the projection replaces the edited
|
|
194
|
+
// body with the one imported from the version read at the start. The edit is
|
|
195
|
+
// destroyed and it existed nowhere else.
|
|
196
|
+
//
|
|
197
|
+
// The scope is what makes this different from a hand edit to generated
|
|
198
|
+
// output. DURING the migration this file is the source document — nothing in
|
|
199
|
+
// it has reached the store yet — so an edit to it is unrecoverable content.
|
|
200
|
+
// AFTER the migration it is output, and discarding hand edits is exactly what
|
|
201
|
+
// `render` is for (`lib/ideabox-cli.js`); that contract is untouched. The
|
|
202
|
+
// open manifest is precisely the marker that separates the two states.
|
|
203
|
+
//
|
|
204
|
+
// Failing here leaves the manifest OPEN on purpose. Nothing has been
|
|
205
|
+
// destroyed, and the next run meets the refusal that already exists for this
|
|
206
|
+
// shape: the hash no longer matches, so the gate stops with
|
|
207
|
+
// `IDEABOX_MANIFEST_STALE` naming the mid-migration edit.
|
|
208
|
+
if (!dryRun && path && plannedHandles.length) {
|
|
209
|
+
if (hashMarkdown(readFileSync(path, 'utf8')) !== hashMarkdown(source)) {
|
|
210
|
+
throw new IdeaboxSourceChangedDuringImport(path);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The import completed. Closing the manifest is what makes an OPEN one mean
|
|
215
|
+
// "interrupted" — and it is what keeps the protection intact, since a
|
|
216
|
+
// hand-added idea after a completed migration must still be refused.
|
|
217
|
+
if (!dryRun) closeManifest(provider, path);
|
|
218
|
+
|
|
178
219
|
return {
|
|
179
220
|
imported,
|
|
180
221
|
skipped,
|
|
@@ -184,3 +225,105 @@ export async function importIdeabox(provider, { markdown, path, dryRun = false }
|
|
|
184
225
|
alreadyImported: imported.length === 0 && skipped.length > 0,
|
|
185
226
|
};
|
|
186
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One markdown idea → one fluid record. THE ONLY MAPPING.
|
|
231
|
+
*
|
|
232
|
+
* Extracted from the import loop so the recovery path (`ideabox-recover.js`)
|
|
233
|
+
* can reconcile an already-imported record against the current file using the
|
|
234
|
+
* SAME rules the import used. A second mapping of this shape is the two-readers
|
|
235
|
+
* pattern that produced this entire bug class: the two would agree on the day
|
|
236
|
+
* they were written and drift apart on the first field either one gained.
|
|
237
|
+
*
|
|
238
|
+
* Pure. No provider, no I/O — the caller resolves cluster handles and decides
|
|
239
|
+
* whether to create or patch.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} idea a `parseIdeabox` idea
|
|
242
|
+
* @param {object} ctx
|
|
243
|
+
* @param {boolean} ctx.killed whether it came from the killed section
|
|
244
|
+
* @param {object} ctx.parsed the whole parse, for cluster order
|
|
245
|
+
* @param {Map<string,string>} ctx.clusterHandleByName resolved cluster handles
|
|
246
|
+
* @param {object} ctx.provenance stamped on every record this writes
|
|
247
|
+
*/
|
|
248
|
+
export function ideaToRecord(idea, { killed = false, parsed, clusterHandleByName, provenance }) {
|
|
249
|
+
const clusterHandle = idea.cluster ? clusterHandleByName.get(idea.cluster) ?? null : null;
|
|
250
|
+
const clusterOrder = idea.cluster
|
|
251
|
+
? (parsed.clusters ?? []).find((c) => c.name === idea.cluster)?.order ?? null
|
|
252
|
+
: null;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
kind: KIND.IDEA,
|
|
256
|
+
// Verbatim. The whole point of the caller-supplied handle path.
|
|
257
|
+
handle: idea.id,
|
|
258
|
+
title: idea.title,
|
|
259
|
+
body: idea.description ?? '',
|
|
260
|
+
status: killed ? 'killed' : toStatus(idea.status),
|
|
261
|
+
// Keep the author's token when the canonical enum cannot hold it, so a
|
|
262
|
+
// closed enum does not quietly flatten `RE-AIMED (2026-07-21)` to `NEW`.
|
|
263
|
+
status_label: STATUS_MAP[String(idea.status ?? '').trim().toUpperCase()]
|
|
264
|
+
? null
|
|
265
|
+
: (idea.status || null),
|
|
266
|
+
priority: toPriority(idea.priority),
|
|
267
|
+
// Carried, not dropped. `parseIdeabox` already validates both against
|
|
268
|
+
// their enums and yields null otherwise (`lib/ideabox.js:304-311`), so
|
|
269
|
+
// there is nothing to re-check here — but omitting them is not a harmless
|
|
270
|
+
// gap. This function is the first-use migration gate every upgrading
|
|
271
|
+
// install runs, and the render that follows it rewrites the markdown from
|
|
272
|
+
// the records. A dropped field is therefore deleted from the user's file
|
|
273
|
+
// on upgrade, silently, with no way back. No idea in THIS repo carries
|
|
274
|
+
// either, which is exactly why it went unnoticed.
|
|
275
|
+
effort: idea.effort ?? null,
|
|
276
|
+
impact: idea.impact ?? null,
|
|
277
|
+
cluster: clusterHandle,
|
|
278
|
+
cluster_order: clusterOrder,
|
|
279
|
+
tags: idea.tags ?? [],
|
|
280
|
+
source: idea.source || null,
|
|
281
|
+
// Whatever the parser could not name, kept rather than discarded — MINUS
|
|
282
|
+
// the fields that have a typed home. `**Promoted to:**` is one the legacy
|
|
283
|
+
// parser does not know, so it lands in `_extraLines`; carrying it as an
|
|
284
|
+
// opaque extra puts it beyond the reach of `promoteIdea`, which filters
|
|
285
|
+
// stale `promoted_to` links before adding the new one
|
|
286
|
+
// (`ideabox-ops.js`). The record would then render BOTH the old target and
|
|
287
|
+
// the current one. A field with a typed representation must be imported
|
|
288
|
+
// into it, not carried around it.
|
|
289
|
+
extra_fields: extrasWithoutTypedFields(idea._extraLines ?? []),
|
|
290
|
+
links: [
|
|
291
|
+
...(promotedTargetOf(idea._extraLines ?? [])
|
|
292
|
+
? [{ type: 'promoted_to', target: promotedTargetOf(idea._extraLines ?? []) }]
|
|
293
|
+
: []),
|
|
294
|
+
...(idea.mapsTo ? [{ type: 'maps_to', target: idea.mapsTo }] : []),
|
|
295
|
+
],
|
|
296
|
+
killed: (killed || idea.killedReason)
|
|
297
|
+
? {
|
|
298
|
+
at: toRecordTimestamp(idea.killedDate),
|
|
299
|
+
reason: idea.killedReason || 'reason not recorded in markdown',
|
|
300
|
+
}
|
|
301
|
+
: null,
|
|
302
|
+
discussion: (idea.discussion ?? []).map((d) => ({
|
|
303
|
+
at: toRecordTimestamp(d.date),
|
|
304
|
+
text: d.text ?? '',
|
|
305
|
+
author: d.author ?? null,
|
|
306
|
+
})),
|
|
307
|
+
provenance,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Typed fields hiding in the unrecognised pile
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** `**Promoted to:** X` written by hand, which the parser does not recognise. */
|
|
316
|
+
const PROMOTED_TO_RE = /^\*\*Promoted to:\*\*\s*(.+)$/;
|
|
317
|
+
|
|
318
|
+
function promotedTargetOf(extraLines) {
|
|
319
|
+
for (const line of extraLines) {
|
|
320
|
+
const m = String(line).match(PROMOTED_TO_RE);
|
|
321
|
+
if (m) return m[1].trim();
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Drop the lines lifted into typed fields so they are not rendered twice. */
|
|
327
|
+
function extrasWithoutTypedFields(extraLines) {
|
|
328
|
+
return extraLines.filter((l) => !PROMOTED_TO_RE.test(String(l)));
|
|
329
|
+
}
|
|
@@ -397,6 +397,12 @@ export class LocalFluidProvider extends FluidProvider {
|
|
|
397
397
|
cluster_order: input.cluster_order ?? null,
|
|
398
398
|
tags: input.tags ?? [],
|
|
399
399
|
source: input.source ?? null,
|
|
400
|
+
// Unrecognised hand-authored markdown fields, carried verbatim. This
|
|
401
|
+
// literal is one of FOUR places the record's fields are enumerated (the
|
|
402
|
+
// contract, `record-shape.js`, and both providers); a field missing from
|
|
403
|
+
// any one of them is silently dropped on write
|
|
404
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT).
|
|
405
|
+
extra_fields: input.extra_fields ?? [],
|
|
400
406
|
links: input.links ?? [],
|
|
401
407
|
killed: input.killed ?? null,
|
|
402
408
|
discussion: input.discussion ?? [],
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/portfolio.js — the cross-product aggregator (COMP-FOH FOH-7).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT
|
|
5
|
+
* ---------------------------------------------
|
|
6
|
+
* A consumer-side aggregator that sits ABOVE the provider seam and fans a single
|
|
7
|
+
* question out across N independently-configured products.
|
|
8
|
+
*
|
|
9
|
+
* It is **not** a `FluidProvider` and must not be registered as one. A provider
|
|
10
|
+
* implies a store, and this has none; and putting fan-out below the seam would
|
|
11
|
+
* place it under per-workspace capability semantics, where "does this support
|
|
12
|
+
* recall" stops having a single answer. Members may use different providers —
|
|
13
|
+
* that is the point of aggregating above the seam rather than below it.
|
|
14
|
+
*
|
|
15
|
+
* PARTIALITY IS THE NORMAL CASE
|
|
16
|
+
* -----------------------------
|
|
17
|
+
* With N independently-configured products reached over N connections, some
|
|
18
|
+
* subset being unavailable is the expected steady state, not an error path. The
|
|
19
|
+
* contract is therefore that **every absent source is NAMED**: a portfolio answer
|
|
20
|
+
* that quietly covers three products when the user declared five is worse than
|
|
21
|
+
* no answer, because nothing in it looks wrong.
|
|
22
|
+
*
|
|
23
|
+
* The one thing that is an error is EVERY member failing. An empty result set
|
|
24
|
+
* and a total outage must never be presented identically.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { parsePortfolioConfig } from './factory.js';
|
|
28
|
+
import { getFluidWorkspaceId } from '../maya-config.js';
|
|
29
|
+
import { fluidProviderFor } from './factory.js';
|
|
30
|
+
import { KIND, SEMANTIC_CAP } from './provider.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-member deadline.
|
|
34
|
+
*
|
|
35
|
+
* Explicit, and not inherited: the colleague composer's 35s `withDeadline` wraps
|
|
36
|
+
* only its three findings calls, so there is no ambient whole-turn bound to sit
|
|
37
|
+
* inside (blueprint C2). A portfolio turn costs one round trip per member with no
|
|
38
|
+
* server-side batching, so an unbounded member holds the whole turn open.
|
|
39
|
+
*/
|
|
40
|
+
export const MEMBER_DEADLINE_MS = 20_000;
|
|
41
|
+
|
|
42
|
+
/** How many records a storage-only member contributes when it cannot search. */
|
|
43
|
+
const LISTED_FALLBACK_LIMIT = 25;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} PortfolioSource
|
|
47
|
+
* @property {string} id the declared label
|
|
48
|
+
* @property {string} root the member's absolute root — carried because two
|
|
49
|
+
* products can hold the same handle, and the id alone is a name the user chose
|
|
50
|
+
* while the root is what actually disambiguates
|
|
51
|
+
* @property {import('./provider.js').RecallHit[]} hits
|
|
52
|
+
* @property {boolean} [listedNotSearched] true when the member could not search
|
|
53
|
+
* and these are its records listed, never a synthesized query result
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Construct one provider per declared member, concurrently.
|
|
58
|
+
*
|
|
59
|
+
* A member that cannot be opened is an omission, never a thrown turn: the whole
|
|
60
|
+
* point is that one broken product does not silence the others.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} cwd the declaring root
|
|
63
|
+
* @returns {Promise<{sources: Array<{id, root, provider}>, omissions: string[]}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function openPortfolio(cwd) {
|
|
66
|
+
const config = parsePortfolioConfig(cwd);
|
|
67
|
+
if (!config) return { sources: [], omissions: [], declared: 0 };
|
|
68
|
+
|
|
69
|
+
const settled = await Promise.allSettled(
|
|
70
|
+
config.members.map(async (m) => ({ ...m, provider: await fluidProviderFor(m.root) })),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const sources = [];
|
|
74
|
+
const omissions = [];
|
|
75
|
+
settled.forEach((outcome, i) => {
|
|
76
|
+
const member = config.members[i];
|
|
77
|
+
if (outcome.status === 'fulfilled') sources.push(outcome.value);
|
|
78
|
+
else omissions.push(`${member.id} misconfigured: ${reasonOf(outcome.reason)}`);
|
|
79
|
+
});
|
|
80
|
+
return { sources, omissions, declared: config.members.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask every member the same question.
|
|
85
|
+
*
|
|
86
|
+
* @param {{sources: Array<{id, root, provider}>, omissions: string[]}} portfolio
|
|
87
|
+
* @param {string} query
|
|
88
|
+
* @param {{deadlineMs?: number, limit?: number}} [opts]
|
|
89
|
+
* @returns {Promise<{sources: PortfolioSource[], omissions: string[]}>}
|
|
90
|
+
*/
|
|
91
|
+
export async function recallAcrossPortfolio(portfolio, query, opts = {}) {
|
|
92
|
+
const deadlineMs = opts.deadlineMs ?? MEMBER_DEADLINE_MS;
|
|
93
|
+
const omissions = [...portfolio.omissions];
|
|
94
|
+
|
|
95
|
+
const settled = await Promise.allSettled(
|
|
96
|
+
portfolio.sources.map((source) => withDeadline(askOne(source, query, opts), deadlineMs, source.id)),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const sources = [];
|
|
100
|
+
settled.forEach((outcome, i) => {
|
|
101
|
+
const source = portfolio.sources[i];
|
|
102
|
+
if (outcome.status === 'rejected') {
|
|
103
|
+
omissions.push(`${source.id} ${classify(outcome.reason)}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const { hits, listedNotSearched, omission } = outcome.value;
|
|
107
|
+
if (omission) omissions.push(omission);
|
|
108
|
+
sources.push({
|
|
109
|
+
id: source.id,
|
|
110
|
+
root: source.root,
|
|
111
|
+
hits,
|
|
112
|
+
...(listedNotSearched ? { listedNotSearched: true } : {}),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// An empty answer and a total outage must not look the same. This is the one
|
|
117
|
+
// partiality that is NOT the normal case.
|
|
118
|
+
const declared = portfolio.declared ?? portfolio.sources.length;
|
|
119
|
+
if (!sources.length && declared > 0) {
|
|
120
|
+
const err = new Error(
|
|
121
|
+
`compose: every portfolio member failed — ${omissions.join('; ')}`,
|
|
122
|
+
);
|
|
123
|
+
err.code = 'PORTFOLIO_ALL_FAILED';
|
|
124
|
+
err.omissions = omissions;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { sources, omissions };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Internals
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One member's contribution.
|
|
137
|
+
*
|
|
138
|
+
* A member that cannot search is not skipped and is not faked: it contributes
|
|
139
|
+
* what it genuinely has — its records, listed — flagged as such, plus a named
|
|
140
|
+
* omission. Returning its list as if it were a query result would be the silent
|
|
141
|
+
* substitution this feature exists to avoid.
|
|
142
|
+
*/
|
|
143
|
+
async function askOne(source, query, opts) {
|
|
144
|
+
const { provider, id } = source;
|
|
145
|
+
// The DECLARED constant, never a bare string: `has()` is a plain Set lookup
|
|
146
|
+
// on `capabilities()`, and the FOH-7 live-fire (2026-09-06) found `'recall'`
|
|
147
|
+
// here against a provider declaring `'RECALL'` — every member, SmartMemory
|
|
148
|
+
// included, silently took the listed-not-searched path and the suite stayed
|
|
149
|
+
// green because its stubs replaced `has()` outright.
|
|
150
|
+
const canRecall = typeof provider.has === 'function' ? provider.has(SEMANTIC_CAP.RECALL) : true;
|
|
151
|
+
|
|
152
|
+
if (!canRecall) {
|
|
153
|
+
const records = await provider.listRecords({ kind: KIND.IDEA });
|
|
154
|
+
// BY RECENCY, before the limit. `listRecords` returns the local provider's
|
|
155
|
+
// storage order (cluster, then handle number), so slicing it directly hands
|
|
156
|
+
// back a product's OLDEST ideas — an established product would omit its
|
|
157
|
+
// newest decisions every time, which is the worst possible subset to show
|
|
158
|
+
// for a question about what was recently decided.
|
|
159
|
+
const recent = [...records].sort((a, b) =>
|
|
160
|
+
String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')));
|
|
161
|
+
return {
|
|
162
|
+
hits: recent.slice(0, opts.limit ?? LISTED_FALLBACK_LIMIT).map((record) => ({
|
|
163
|
+
handle: record.handle,
|
|
164
|
+
// Null, never 0: this member did not rank anything, and 0 would read as
|
|
165
|
+
// "ranked, and terrible" (see RecallHit in provider.js).
|
|
166
|
+
score: null,
|
|
167
|
+
record,
|
|
168
|
+
})),
|
|
169
|
+
listedNotSearched: true,
|
|
170
|
+
omission: `${id} cannot search (no recall capability) — listed its records instead`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const hits = await provider.recall(query, opts.limit ? { limit: opts.limit } : {});
|
|
175
|
+
return { hits: hits ?? [], listedNotSearched: false, omission: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reject after `ms`, naming the member so the omission can name it too. */
|
|
179
|
+
function withDeadline(promise, ms, id) {
|
|
180
|
+
let timer;
|
|
181
|
+
const bell = new Promise((_, reject) => {
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
const err = new Error(`did not answer within ${ms}ms`);
|
|
184
|
+
err.code = 'PORTFOLIO_MEMBER_TIMEOUT';
|
|
185
|
+
err.memberId = id;
|
|
186
|
+
reject(err);
|
|
187
|
+
}, ms);
|
|
188
|
+
});
|
|
189
|
+
return Promise.race([promise, bell]).finally(() => clearTimeout(timer));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const reasonOf = (err) => String(err?.message ?? err ?? 'unknown').slice(0, 160);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Turn a failure into the omission's reason class.
|
|
196
|
+
*
|
|
197
|
+
* The vocabulary reuses wording the cockpit already uses ("SmartMemory
|
|
198
|
+
* unreachable", `RecallTab.jsx:29`) rather than coining a parallel one, and the
|
|
199
|
+
* 403 branch depends on `scopeError` being retained on the error — see the
|
|
200
|
+
* blueprint's D1.
|
|
201
|
+
*/
|
|
202
|
+
function classify(err) {
|
|
203
|
+
if (err?.code === 'PORTFOLIO_MEMBER_TIMEOUT') return `unreachable: ${reasonOf(err)}`;
|
|
204
|
+
const status = typeof err?.status === 'number' ? err.status : null;
|
|
205
|
+
if (status === 403 || status === 401) {
|
|
206
|
+
const scope = err?.scopeError;
|
|
207
|
+
if (scope === 'not-a-member') return 'unauthorized: not a member of that workspace';
|
|
208
|
+
if (scope === 'missing-scope') return 'unauthorized: the key lacks the required scope';
|
|
209
|
+
return 'unauthorized: reason undetermined';
|
|
210
|
+
}
|
|
211
|
+
if (status === 0 || /ECONNREFUSED|ENOTFOUND|fetch failed|network/i.test(reasonOf(err))) {
|
|
212
|
+
return `unreachable: ${reasonOf(err)}`;
|
|
213
|
+
}
|
|
214
|
+
return `unavailable: ${reasonOf(err)}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Refuse a portfolio where any member's fluid workspace is the colleague's own.
|
|
219
|
+
*
|
|
220
|
+
* The existing guard (`lib/maya-identity.js`) compares Maya's identity claim
|
|
221
|
+
* against ONE configured workspace — the declaring root's. A portfolio adds N
|
|
222
|
+
* more workspaces the turn will read from, and none of them was checked: the
|
|
223
|
+
* shallow-binding isolation this protects is not a property of the declaring
|
|
224
|
+
* root, it is a property of every workspace the turn touches. Without it a
|
|
225
|
+
* member configured at Maya's own workspace reaches a successful chat, which is
|
|
226
|
+
* the isolation failure the declaring-root check exists to prevent, arriving
|
|
227
|
+
* through a door that check does not cover.
|
|
228
|
+
*
|
|
229
|
+
* Deliberately NOT member-vs-member: two declared products may legitimately
|
|
230
|
+
* share a workspace, and refusing that would be our policy rather than a
|
|
231
|
+
* required invariant.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} cwd the declaring root
|
|
234
|
+
* @param {string|null} identityWorkspaceId Maya's own verified workspace
|
|
235
|
+
* @throws {Error} code PORTFOLIO_WORKSPACE_COLLISION
|
|
236
|
+
*/
|
|
237
|
+
export function assertMemberWorkspacesDistinct(cwd, identityWorkspaceId) {
|
|
238
|
+
if (!identityWorkspaceId) return;
|
|
239
|
+
const config = parsePortfolioConfig(cwd);
|
|
240
|
+
if (!config) return;
|
|
241
|
+
|
|
242
|
+
const collisions = config.members
|
|
243
|
+
.filter((m) => getFluidWorkspaceId(m.root) === identityWorkspaceId)
|
|
244
|
+
.map((m) => m.id);
|
|
245
|
+
|
|
246
|
+
if (collisions.length) {
|
|
247
|
+
const err = new Error(
|
|
248
|
+
`compose: portfolio member(s) ${collisions.join(', ')} are configured at the colleague's own ` +
|
|
249
|
+
`workspace (${identityWorkspaceId}) — a turn cannot read the memory it is writing from`,
|
|
250
|
+
);
|
|
251
|
+
err.code = 'PORTFOLIO_WORKSPACE_COLLISION';
|
|
252
|
+
err.collisions = collisions;
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -204,6 +204,13 @@ export function normalizeRecord(record) {
|
|
|
204
204
|
cluster_order: record.cluster_order ?? null,
|
|
205
205
|
tags: Array.isArray(record.tags) ? [...record.tags] : [],
|
|
206
206
|
source: record.source ?? null,
|
|
207
|
+
// Hand-authored field lines the markdown parser did not recognise. Carried
|
|
208
|
+
// verbatim so the upgrade path is LOSSLESS: this shape is an allowlist, so
|
|
209
|
+
// a field absent here is deleted from the user's ideabox by the first
|
|
210
|
+
// render after migration, silently and with no way back
|
|
211
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT). `effort` and `impact` were rescued from
|
|
212
|
+
// exactly this fate one slice earlier; this is the general case they left.
|
|
213
|
+
extra_fields: Array.isArray(record.extra_fields) ? [...record.extra_fields] : [],
|
|
207
214
|
links: Array.isArray(record.links) ? record.links.map((l) => ({ ...l })) : [],
|
|
208
215
|
killed: record.killed ? { ...record.killed } : null,
|
|
209
216
|
discussion: Array.isArray(record.discussion) ? record.discussion.map((d) => ({ ...d })) : [],
|