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