@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/ideabox-recover.js — the supported way out of a stranded ideabox.
|
|
3
|
+
*
|
|
4
|
+
* COMP-IDEABOX-MIGRATE-DIALECT, the escape hatch FU-1 left open.
|
|
5
|
+
*
|
|
6
|
+
* THE STATE THIS EXISTS FOR
|
|
7
|
+
* -------------------------
|
|
8
|
+
* A migration is interrupted, and the document is edited before it is resumed.
|
|
9
|
+
* The manifest is open, its hash no longer matches the file, and the gate
|
|
10
|
+
* refuses — correctly, because resuming would import a document nobody checked
|
|
11
|
+
* and project away whatever was added. But EVERY ideabox command runs that
|
|
12
|
+
* gate, so the project is stranded, and the only exit needing no tooling was
|
|
13
|
+
* destructive: delete the manifest, and the partially migrated store becomes
|
|
14
|
+
* canon so the next render replaces the file.
|
|
15
|
+
*
|
|
16
|
+
* That is FU-1's stranding shape with a worse escape hatch. The refusal is
|
|
17
|
+
* right; having no supported way out is the defect.
|
|
18
|
+
*
|
|
19
|
+
* WHAT THE TWO COMMANDS MEAN
|
|
20
|
+
* --------------------------
|
|
21
|
+
* Both say "I have decided which document is right", which is precisely the
|
|
22
|
+
* judgement the gate refuses to make on the user's behalf:
|
|
23
|
+
*
|
|
24
|
+
* - `adoptFile` — the file on disk is right. Finish the migration against
|
|
25
|
+
* it, updating already-imported records to match.
|
|
26
|
+
* - `discardEdits` — the migration is right. Put the file back as it was
|
|
27
|
+
* read and finish that, after saving a copy first.
|
|
28
|
+
*
|
|
29
|
+
* WHY THESE DO NOT RUN THE GATE
|
|
30
|
+
* -----------------------------
|
|
31
|
+
* Every other mutation calls it first (`ideabox-ops.js`, invariant 1). These
|
|
32
|
+
* two cannot: the gate throws `IDEABOX_MANIFEST_STALE`, which is the state they
|
|
33
|
+
* exist to leave. They are the one deliberate exception, and they are narrower
|
|
34
|
+
* than the gate rather than wider — they refuse unless the project is ACTUALLY
|
|
35
|
+
* stranded, so neither is a general "make the file canon" door.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { dirname, join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
import { parseIdeabox } from '../ideabox.js';
|
|
43
|
+
import {
|
|
44
|
+
hashMarkdown,
|
|
45
|
+
manifestPath,
|
|
46
|
+
openManifest,
|
|
47
|
+
readManifest,
|
|
48
|
+
} from './ideabox-manifest.js';
|
|
49
|
+
import { ensureIdeaboxMigrated } from './ideabox-migrate.js';
|
|
50
|
+
import { assertIdeaboxReadable } from './ideabox-readable.js';
|
|
51
|
+
import { ideaToRecord } from './import-ideabox.js';
|
|
52
|
+
import { KIND } from './provider.js';
|
|
53
|
+
import { UNPATCHABLE, normalizeRecord } from './record-shape.js';
|
|
54
|
+
import { writeIdeaboxProjection } from './render-ideabox.js';
|
|
55
|
+
|
|
56
|
+
/** Nothing to recover: the project is not in the stranded state. */
|
|
57
|
+
export class IdeaboxNotStranded extends Error {
|
|
58
|
+
constructor(ideaboxPath, why) {
|
|
59
|
+
super(
|
|
60
|
+
`compose: nothing to recover for ${ideaboxPath} — ${why}. These commands exist only to ` +
|
|
61
|
+
`resolve a migration that was interrupted and whose source document then changed. Ordinary ` +
|
|
62
|
+
`hand edits to a migrated ideabox are not recovered here: that file is generated output, and ` +
|
|
63
|
+
`\`compose ideabox render\` is the way back from an edit to it.`
|
|
64
|
+
);
|
|
65
|
+
this.name = 'IdeaboxNotStranded';
|
|
66
|
+
this.code = 'IDEABOX_NOT_STRANDED';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The manifest predates the stored source text, so there is nothing to restore. */
|
|
71
|
+
export class IdeaboxNoStoredSource extends Error {
|
|
72
|
+
constructor(ideaboxPath) {
|
|
73
|
+
super(
|
|
74
|
+
`compose: cannot discard the edits to ${ideaboxPath} — the interrupted migration was ` +
|
|
75
|
+
`recorded by an older version of compose that did not keep a copy of the document it read, ` +
|
|
76
|
+
`so there is nothing to put back. Discarding would delete the current text and restore ` +
|
|
77
|
+
`nothing. Use \`compose ideabox adopt-file\` to finish the migration against the file as it ` +
|
|
78
|
+
`now stands; the entries it already imported are unaffected either way.`
|
|
79
|
+
);
|
|
80
|
+
this.name = 'IdeaboxNoStoredSource';
|
|
81
|
+
this.code = 'IDEABOX_NO_STORED_SOURCE';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The stranded state, or null.
|
|
87
|
+
*
|
|
88
|
+
* ONE definition, shared by both commands and by nothing else. Stranded means:
|
|
89
|
+
* an open manifest exists, the file exists, and the manifest's hash does not
|
|
90
|
+
* match it. Every other shape is somebody else's job —
|
|
91
|
+
*
|
|
92
|
+
* - no manifest → migration finished (or never ran). `render` is the
|
|
93
|
+
* repair path for a hand edit to generated output.
|
|
94
|
+
* - hash MATCHES → an interrupted migration with an untouched document,
|
|
95
|
+
* which `ensureIdeaboxMigrated` already resumes on its
|
|
96
|
+
* own. Recovering it here would be a second mechanism
|
|
97
|
+
* for a case that has one.
|
|
98
|
+
* - no lock path → no manifest is ever written (SmartMemory), so such a
|
|
99
|
+
* project cannot reach this state at all.
|
|
100
|
+
*/
|
|
101
|
+
export function strandedState(provider, ideaboxPath) {
|
|
102
|
+
const manifest = readManifest(provider, ideaboxPath);
|
|
103
|
+
if (!manifest) return null;
|
|
104
|
+
if (!existsSync(ideaboxPath)) return null;
|
|
105
|
+
const markdown = readFileSync(ideaboxPath, 'utf8');
|
|
106
|
+
if (manifest.hash === hashMarkdown(markdown)) return null;
|
|
107
|
+
return { manifest, markdown };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireStranded(provider, ideaboxPath) {
|
|
111
|
+
const state = strandedState(provider, ideaboxPath);
|
|
112
|
+
if (state) return state;
|
|
113
|
+
const manifest = readManifest(provider, ideaboxPath);
|
|
114
|
+
if (!manifest) throw new IdeaboxNotStranded(ideaboxPath, 'no interrupted migration is recorded');
|
|
115
|
+
if (!existsSync(ideaboxPath)) throw new IdeaboxNotStranded(ideaboxPath, 'the file does not exist');
|
|
116
|
+
throw new IdeaboxNotStranded(
|
|
117
|
+
ideaboxPath,
|
|
118
|
+
'the interrupted migration matches the file, so re-running any ideabox command finishes it',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `compose ideabox discard-edits` — the migration is right.
|
|
124
|
+
*
|
|
125
|
+
* `onBackup` is called with the copy's path BEFORE the file is overwritten, not
|
|
126
|
+
* returned at the end. The return value only reaches the caller when everything
|
|
127
|
+
* after the overwrite also succeeded, and the resume that follows can throw —
|
|
128
|
+
* at which point the user has been told their text is gone and NOT where the
|
|
129
|
+
* copy is, which is the one moment the path matters. The error message this
|
|
130
|
+
* command is reached from promises the path is printed; a promise kept only on
|
|
131
|
+
* the happy path is not kept.
|
|
132
|
+
*
|
|
133
|
+
* @returns {Promise<{backup: string|null, result: object}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function discardEdits(provider, ideaboxPath, { onBackup = null } = {}) {
|
|
136
|
+
const { manifest, markdown } = requireStranded(provider, ideaboxPath);
|
|
137
|
+
if (typeof manifest.text !== 'string') throw new IdeaboxNoStoredSource(ideaboxPath);
|
|
138
|
+
|
|
139
|
+
// BEFORE ANYTHING IS WRITTEN, including the backup. The stored text was
|
|
140
|
+
// readable when the manifest was opened — the gate proved it — but the
|
|
141
|
+
// manifest is a file on disk like any other, and restoring a document the
|
|
142
|
+
// parser can only half read would patch records toward that subset and then
|
|
143
|
+
// refuse. Checked here, the failure changes nothing at all.
|
|
144
|
+
const restoring = assertIdeaboxReadable(parseIdeabox(manifest.text), ideaboxPath);
|
|
145
|
+
|
|
146
|
+
// THE COPY COMES FIRST, and its path is returned so the caller can print it.
|
|
147
|
+
//
|
|
148
|
+
// "Discard" is a decision someone can make in a hurry, and this is the one
|
|
149
|
+
// command in the ideabox that deliberately destroys text. Refusing to lose it
|
|
150
|
+
// anyway costs one file. Unlike the FU-4 preamble this copy IS transient — it
|
|
151
|
+
// is a safety net for one command, not an input to the projection — so
|
|
152
|
+
// gitignored `.compose/data/` is its right home rather than a tracked sibling.
|
|
153
|
+
const backup = writeBackup(provider, ideaboxPath, markdown);
|
|
154
|
+
if (backup && onBackup) onBackup(backup);
|
|
155
|
+
|
|
156
|
+
// PUT THE RECORDS BACK TOO, not only the file.
|
|
157
|
+
//
|
|
158
|
+
// The two commands can be run in sequence: an `adopt-file` that crashes after
|
|
159
|
+
// its reconcile has already patched records to match the edited document, and
|
|
160
|
+
// the user then changes their mind. Restoring the markdown alone does not undo
|
|
161
|
+
// that — the ordinary import SKIPS records that already exist, so the edited
|
|
162
|
+
// body survived in the store and the very next projection wrote it back into
|
|
163
|
+
// the file that had just been restored. A discard that silently keeps the
|
|
164
|
+
// edits is the content loss this whole feature exists to prevent, inverted.
|
|
165
|
+
//
|
|
166
|
+
// Reconciling BEFORE the overwrite is what keeps a crash here safe: until the
|
|
167
|
+
// file is restored the project is still stranded, so a rerun of this command
|
|
168
|
+
// is still allowed and the reconcile is idempotent. Do it after, and a crash
|
|
169
|
+
// in between leaves a project that is no longer stranded, refuses to discard,
|
|
170
|
+
// and quietly holds the edits.
|
|
171
|
+
const reverted = await reconcileRecords(provider, ideaboxPath, restoring);
|
|
172
|
+
|
|
173
|
+
writeFileAtomic(ideaboxPath, manifest.text);
|
|
174
|
+
// The hash matches again, so the ORDINARY resume path finishes the job. No
|
|
175
|
+
// second import mechanism: recovery hands the existing one a state it can
|
|
176
|
+
// already handle.
|
|
177
|
+
const result = await ensureIdeaboxMigrated(provider, ideaboxPath);
|
|
178
|
+
await writeIdeaboxProjection(provider, ideaboxPath);
|
|
179
|
+
return { backup, result, reverted, leftover: await leftoverFrom(provider, restoring) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What an interrupted adoption left in the store that the restored document
|
|
184
|
+
* does not name — reported, never removed.
|
|
185
|
+
*
|
|
186
|
+
* Discarding puts back every field it can, but two things it cannot take back,
|
|
187
|
+
* and BOTH are deliberate rather than missing:
|
|
188
|
+
*
|
|
189
|
+
* - AN UMBRELLA the adoption created. Deleting records is the one thing this
|
|
190
|
+
* whole feature refuses to do, and the renderer emits an empty umbrella on
|
|
191
|
+
* purpose (a project may create one before filing anything into it), so a
|
|
192
|
+
* leftover shows up in the file with nothing under it.
|
|
193
|
+
* - A DISCUSSION ENTRY typed into the document during the outage. The trail is
|
|
194
|
+
* append-only on the seam because it is evidence, so an entry that reached a
|
|
195
|
+
* record stays on it.
|
|
196
|
+
*
|
|
197
|
+
* Neither loses anything the user had; both leave something they may not expect.
|
|
198
|
+
* Silence is what would make that a defect, so the command says so.
|
|
199
|
+
*/
|
|
200
|
+
async function leftoverFrom(provider, restoring) {
|
|
201
|
+
const named = new Set((restoring.clusters ?? []).map((c) => c.name));
|
|
202
|
+
const clusters = (await provider.listRecords({ kind: KIND.CLUSTER }))
|
|
203
|
+
.filter((c) => !named.has(c.title))
|
|
204
|
+
.map((c) => c.title);
|
|
205
|
+
|
|
206
|
+
const inDoc = new Map(
|
|
207
|
+
[...(restoring.ideas ?? []), ...(restoring.killed ?? [])]
|
|
208
|
+
.map((i) => [i.id, (i.discussion ?? []).length]),
|
|
209
|
+
);
|
|
210
|
+
const discussed = (await provider.listRecords({ kind: KIND.IDEA }))
|
|
211
|
+
.filter((r) => (r.discussion ?? []).length > (inDoc.get(r.handle) ?? 0))
|
|
212
|
+
.map((r) => r.handle);
|
|
213
|
+
|
|
214
|
+
return { clusters, discussed };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `compose ideabox adopt-file` — the file on disk is right.
|
|
219
|
+
*
|
|
220
|
+
* The step order is fixed and it is what makes a crash mid-recovery safe:
|
|
221
|
+
* a crash between 2 and 3 leaves the manifest stale, so this command reruns and
|
|
222
|
+
* step 2 is idempotent; a crash after 3 is an ordinary resumable migration with
|
|
223
|
+
* the updates already applied.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Promise<{updated: string[], discussed: string[], imported: string[], kept: string[], reclustered: string[]}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function adoptFile(provider, ideaboxPath) {
|
|
228
|
+
const { markdown } = requireStranded(provider, ideaboxPath);
|
|
229
|
+
|
|
230
|
+
// 1. READABLE FIRST, changing nothing. Adopting a document the parser cannot
|
|
231
|
+
// fully read would import the subset it understood and project away the
|
|
232
|
+
// rest — the original bug, performed deliberately.
|
|
233
|
+
const parsed = assertIdeaboxReadable(parseIdeabox(markdown), ideaboxPath);
|
|
234
|
+
|
|
235
|
+
// 2. Reconcile what is already in the store against what the file now says.
|
|
236
|
+
const { updated, discussed, kept, reclustered } = await reconcileRecords(provider, ideaboxPath, parsed);
|
|
237
|
+
|
|
238
|
+
// 3. Re-plan, atomically, against the document being adopted.
|
|
239
|
+
openManifest(provider, ideaboxPath, {
|
|
240
|
+
markdown,
|
|
241
|
+
planned: [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id),
|
|
242
|
+
plannedClusters: (parsed.clusters ?? []).map((c) => c.name),
|
|
243
|
+
});
|
|
244
|
+
// NOT recapturing the preamble here, though the recovery must recapture it:
|
|
245
|
+
// step 4's import already does (`import-ideabox.js` writes it from the
|
|
246
|
+
// document it is importing, which after step 3 is this one). A second call
|
|
247
|
+
// would be a redundant writer of the same file, and the version of this bug
|
|
248
|
+
// that keeps recurring is two writers of one thing drifting apart. Pinned by
|
|
249
|
+
// the adopt-file preamble test, which fails if EITHER writer stops.
|
|
250
|
+
|
|
251
|
+
// 4. The hash matches now, so the ordinary resume path imports the handles
|
|
252
|
+
// that were never reached — including any the user added by hand, which
|
|
253
|
+
// the re-planned manifest now vouches for.
|
|
254
|
+
const { imported } = await ensureIdeaboxMigrated(provider, ideaboxPath);
|
|
255
|
+
await writeIdeaboxProjection(provider, ideaboxPath);
|
|
256
|
+
return { updated, discussed, imported, kept, reclustered };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Step 2, exported so a test can interrupt the command exactly here.
|
|
261
|
+
*
|
|
262
|
+
* IDEMPOTENT BY CONSTRUCTION: it patches only fields that differ, so a second
|
|
263
|
+
* pass over an unchanged document reports nothing. That is not a nicety — the
|
|
264
|
+
* crash-safety argument for the step order depends on it.
|
|
265
|
+
*
|
|
266
|
+
* NEVER DELETES. A handle in the store and absent from the file is kept and
|
|
267
|
+
* reported. Removing it would be exactly the silent-loss guess the gate exists
|
|
268
|
+
* to refuse, and the user may have deleted the line by accident.
|
|
269
|
+
*/
|
|
270
|
+
export async function reconcileRecords(provider, ideaboxPath, parsed) {
|
|
271
|
+
const provenance = { origin: 'import:ideabox' };
|
|
272
|
+
const stored = await provider.listRecords({ kind: KIND.IDEA });
|
|
273
|
+
const byHandle = new Map(stored.map((r) => [r.handle, normalizeRecord(r)]));
|
|
274
|
+
|
|
275
|
+
// Clusters first, exactly as the import does: an idea the user moved into a
|
|
276
|
+
// NEW umbrella has nowhere to point until that umbrella exists, and a null
|
|
277
|
+
// cluster here would silently unfile it. `findOrCreateRecord` is the seam's
|
|
278
|
+
// atomic lookup-or-create, so this is idempotent across reruns.
|
|
279
|
+
const clusterHandleByName = new Map();
|
|
280
|
+
const reclustered = [];
|
|
281
|
+
for (const cluster of parsed.clusters ?? []) {
|
|
282
|
+
const { record } = await provider.findOrCreateRecord(
|
|
283
|
+
{ kind: KIND.CLUSTER, title: cluster.name },
|
|
284
|
+
{ body: cluster.theme ?? '', cluster_order: cluster.order, provenance },
|
|
285
|
+
);
|
|
286
|
+
clusterHandleByName.set(cluster.name, record.handle);
|
|
287
|
+
|
|
288
|
+
// AN UMBRELLA THAT ALREADY EXISTS IS RECONCILED LIKE ANY OTHER RECORD.
|
|
289
|
+
// `findOrCreateRecord` returns a known cluster untouched, and step 4's
|
|
290
|
+
// import skips known clusters too — so an edit to the THEME of an umbrella
|
|
291
|
+
// that was already imported reached neither writer, and the projection put
|
|
292
|
+
// the old theme back over it. That is the same content loss as an edited
|
|
293
|
+
// idea body, one record kind over, and it is invisible to any test whose
|
|
294
|
+
// umbrella is new.
|
|
295
|
+
const patch = patchFor(normalizeRecord(record), {
|
|
296
|
+
body: cluster.theme ?? '',
|
|
297
|
+
cluster_order: cluster.order ?? null,
|
|
298
|
+
});
|
|
299
|
+
if (Object.keys(patch).length) {
|
|
300
|
+
await provider.updateRecord(record.handle, patch);
|
|
301
|
+
reclustered.push(cluster.name);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const all = [
|
|
306
|
+
...(parsed.ideas ?? []).map((idea) => ({ idea, killed: false })),
|
|
307
|
+
...(parsed.killed ?? []).map((idea) => ({ idea, killed: true })),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const updated = [];
|
|
311
|
+
const discussed = [];
|
|
312
|
+
for (const { idea, killed } of all) {
|
|
313
|
+
const current = byHandle.get(idea.id);
|
|
314
|
+
// Not in the store yet: step 4 imports it. Creating it here would duplicate
|
|
315
|
+
// the import's own path and skip its `reclaimAborted` handling.
|
|
316
|
+
if (!current) continue;
|
|
317
|
+
|
|
318
|
+
const desired = ideaToRecord(idea, { killed, parsed, clusterHandleByName, provenance });
|
|
319
|
+
const patch = patchFor(current, desired);
|
|
320
|
+
if (Object.keys(patch).length) {
|
|
321
|
+
await provider.updateRecord(idea.id, patch);
|
|
322
|
+
updated.push(idea.id);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Discussion is APPEND-ONLY on the seam — `updateRecord` refuses to patch
|
|
326
|
+
// it, deliberately, because the deliberation trail is evidence rather than
|
|
327
|
+
// a mutable blob (`record-shape.js`). So an entry present in the file and
|
|
328
|
+
// absent from the store is appended, and nothing is ever removed. An entry
|
|
329
|
+
// deleted from the file therefore survives in the record, which is the same
|
|
330
|
+
// direction every other refusal in this bug takes.
|
|
331
|
+
const have = new Set((current.discussion ?? []).map(discussionKey));
|
|
332
|
+
let appended = 0;
|
|
333
|
+
for (const entry of desired.discussion ?? []) {
|
|
334
|
+
if (have.has(discussionKey(entry))) continue;
|
|
335
|
+
await provider.appendDiscussion(idea.id, entry);
|
|
336
|
+
appended += 1;
|
|
337
|
+
}
|
|
338
|
+
if (appended) discussed.push(`${idea.id} (+${appended})`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const inFile = new Set(all.map(({ idea }) => idea.id));
|
|
342
|
+
const kept = stored.map((r) => r.handle).filter((h) => !inFile.has(h));
|
|
343
|
+
return { updated, discussed, kept, reclustered };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Identity of a discussion entry, for "is this one already recorded". */
|
|
347
|
+
function discussionKey(entry) {
|
|
348
|
+
return JSON.stringify([entry?.at ?? null, entry?.author ?? null, entry?.text ?? '']);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The patchable difference between a stored record and what the file now says.
|
|
353
|
+
*
|
|
354
|
+
* `UNPATCHABLE` fields are dropped rather than compared: identity, provenance
|
|
355
|
+
* and the append-only discussion trail are not the file's to change, and
|
|
356
|
+
* including any of them would make `updateRecord` refuse the whole patch.
|
|
357
|
+
*/
|
|
358
|
+
function patchFor(current, desired) {
|
|
359
|
+
const patch = {};
|
|
360
|
+
for (const [key, value] of Object.entries(desired)) {
|
|
361
|
+
if (UNPATCHABLE.includes(key)) continue;
|
|
362
|
+
if (!deepEqual(current[key] ?? null, value ?? null)) patch[key] = value;
|
|
363
|
+
}
|
|
364
|
+
return patch;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function deepEqual(a, b) {
|
|
368
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The saved copy of the document the user is about to discard. */
|
|
372
|
+
function writeBackup(provider, ideaboxPath, markdown) {
|
|
373
|
+
const home = manifestPath(provider, ideaboxPath);
|
|
374
|
+
if (!home) return null;
|
|
375
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
376
|
+
const path = join(dirname(home), `ideabox-discarded-${stamp}-${randomUUID().slice(0, 8)}.md`);
|
|
377
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
378
|
+
writeFileAtomic(path, markdown);
|
|
379
|
+
return path;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Temp + rename, so an interrupted write cannot leave a half-file. */
|
|
383
|
+
function writeFileAtomic(path, body) {
|
|
384
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
385
|
+
const tmp = `${path}.tmp.${randomUUID()}`;
|
|
386
|
+
try {
|
|
387
|
+
writeFileSync(tmp, body, 'utf8');
|
|
388
|
+
renameSync(tmp, path);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
rmSync(tmp, { force: true });
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
}
|