@smartmemory/compose 0.3.4-beta → 0.3.6-beta

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.
Files changed (76) hide show
  1. package/bin/judgment-import.js +565 -0
  2. package/contracts/judgment-record.schema.json +400 -0
  3. package/dist/assets/{App-BG92y_CY.js → App-BG3ngu8H.js} +92 -92
  4. package/dist/assets/{abnfDiagram-VRR7QNED-Bip6cMjP.js → abnfDiagram-VRR7QNED-CjB_sD3D.js} +1 -1
  5. package/dist/assets/{arc-CyMTM07c.js → arc-_v4hR_uD.js} +1 -1
  6. package/dist/assets/{architectureDiagram-ZJ3FMSHR-BukV0CJw.js → architectureDiagram-ZJ3FMSHR-DreJmzXQ.js} +1 -1
  7. package/dist/assets/{blockDiagram-677ZJIJ3-Bjb_MNB4.js → blockDiagram-677ZJIJ3-BG9-c0O1.js} +1 -1
  8. package/dist/assets/{c4Diagram-LMCZKHZV-CUmTCrMW.js → c4Diagram-LMCZKHZV-DZf45Fbz.js} +1 -1
  9. package/dist/assets/channel-B3U5wFAT.js +1 -0
  10. package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-2Q5K7J3B-Dn1spZYu.js} +1 -1
  11. package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.js → chunk-32BRIVSS-pURGrJDk.js} +1 -1
  12. package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-5VM5RSS4-BY4_PV5H.js} +1 -1
  13. package/dist/assets/{chunk-EX3LRPZG-G_MZEji_.js → chunk-EX3LRPZG-DdELs1qP.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-JWPE2WC7-_7ujgd_Q.js} +1 -1
  15. package/dist/assets/{chunk-MOJQB5TN-CQZQYMcz.js → chunk-MOJQB5TN-D-ky35G-.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-CmAda4sZ.js → chunk-RYQCIY6F-Dag_kVlO.js} +1 -1
  17. package/dist/assets/{chunk-V7JOEXUC-CbjBz-Ek.js → chunk-V7JOEXUC-BtewURat.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-VR4S4FIN-Dt9NZ67m.js} +1 -1
  19. package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-XXDRQBXY-DfdVhbmA.js} +1 -1
  20. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +1 -0
  21. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +1 -0
  22. package/dist/assets/{cose-bilkent-JH36ORCC-G8l05tXy.js → cose-bilkent-JH36ORCC-BieYif4o.js} +1 -1
  23. package/dist/assets/{cynefin-VYW2F7L2-C3n1PNJC.js → cynefin-VYW2F7L2-CT2BA6KE.js} +1 -1
  24. package/dist/assets/{cynefinDiagram-TSTJHNR4-C8N_CvKJ.js → cynefinDiagram-TSTJHNR4-Bh6exbyg.js} +1 -1
  25. package/dist/assets/{dagre-VKFMJZFB-B2pkwDhN.js → dagre-VKFMJZFB-aXMLSmQL.js} +1 -1
  26. package/dist/assets/{diagram-FQU43EPY-DPHD_t-1.js → diagram-FQU43EPY-Dr7JAOuQ.js} +1 -1
  27. package/dist/assets/{diagram-G47NLZAW-DGmHIZRr.js → diagram-G47NLZAW-DUvA3FQK.js} +1 -1
  28. package/dist/assets/{diagram-NH7WQ7WH-BlLWwKVt.js → diagram-NH7WQ7WH-BQUARqcu.js} +1 -1
  29. package/dist/assets/{diagram-OA4YK3LP-DEv704Re.js → diagram-OA4YK3LP-dDUc1zHi.js} +1 -1
  30. package/dist/assets/{diagram-WEI45ONY-Bj6PGUCe.js → diagram-WEI45ONY-B2h5Qlb1.js} +1 -1
  31. package/dist/assets/{ebnfDiagram-CCIWWBDH-Ch-icksT.js → ebnfDiagram-CCIWWBDH-DThRGupB.js} +1 -1
  32. package/dist/assets/{erDiagram-Q63AITRT-CI3lwEFA.js → erDiagram-Q63AITRT-BUCsprO2.js} +1 -1
  33. package/dist/assets/{flowDiagram-23GEKE2U-DcUANpAz.js → flowDiagram-23GEKE2U-DXtNNi6r.js} +1 -1
  34. package/dist/assets/{ganttDiagram-NO4QXBWP-Cy0Ie8KS.js → ganttDiagram-NO4QXBWP-D4zbBHh_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-IHSO6WYX-BswI52az.js → gitGraphDiagram-IHSO6WYX-DpoQws0W.js} +1 -1
  36. package/dist/assets/{index-B1H7-G2y.js → index-3ZH5eMcZ.js} +2 -2
  37. package/dist/assets/{infoDiagram-FWYZ7A6U-BBunqvr-.js → infoDiagram-FWYZ7A6U-Bbas2GAo.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-FXEZZL3T-Hm4zSDlM.js → ishikawaDiagram-FXEZZL3T-CzEB9fQS.js} +1 -1
  39. package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-5HDEW3XC-Bz8TCdz2.js} +1 -1
  40. package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.js → kanban-definition-HUTT4EX6-tozrMoV_.js} +1 -1
  41. package/dist/assets/{linear-CsXqv70E.js → linear-Ck7gpa5N.js} +1 -1
  42. package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.js → mindmap-definition-LN4V7U3C-DTcHO0DJ.js} +1 -1
  43. package/dist/assets/{pegDiagram-2B236MQR-HpQyrDcm.js → pegDiagram-2B236MQR-CHiINrNy.js} +1 -1
  44. package/dist/assets/{pieDiagram-ENE6RG2P-xkcpHwnY.js → pieDiagram-ENE6RG2P-CfS4YFlR.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-ABIIQ3AL-kebZIOmu.js → quadrantDiagram-ABIIQ3AL-CadesS9w.js} +1 -1
  46. package/dist/assets/{railroadDiagram-RFXS5EU6-DNvGwWm9.js → railroadDiagram-RFXS5EU6-CgWEspBN.js} +1 -1
  47. package/dist/assets/{requirementDiagram-TGXJPOKE-D1lG1P7h.js → requirementDiagram-TGXJPOKE-bnI2zJeT.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-HTMAVEWB-DOEEcn52.js → sankeyDiagram-HTMAVEWB-YWKFgOGw.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-DBY2YBRQ-DynIVCk_.js → sequenceDiagram-DBY2YBRQ-BvkNOyF9.js} +1 -1
  50. package/dist/assets/{sizeCapture-X5ZJPWSS-DJXFjUUL.js → sizeCapture-X5ZJPWSS-DlFPA2yO.js} +1 -1
  51. package/dist/assets/{stateDiagram-2N3HPSRC-OZ7trFh5.js → stateDiagram-2N3HPSRC-h8NIx0kQ.js} +1 -1
  52. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +1 -0
  53. package/dist/assets/{swimlanes-5IMT3BWC-DULYAFmB.js → swimlanes-5IMT3BWC-CT5n22kG.js} +2 -2
  54. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +8 -0
  55. package/dist/assets/{timeline-definition-FHXFAJF6-CzqqUDvt.js → timeline-definition-FHXFAJF6-D267GQFF.js} +1 -1
  56. package/dist/assets/{vennDiagram-L72KCM5P-CdKmtcFL.js → vennDiagram-L72KCM5P-Dj-wWLYG.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-EHGQE667-DknGwjCA.js → wardleyDiagram-EHGQE667-BxCeYxkG.js} +1 -1
  58. package/dist/assets/{xychartDiagram-FW5EYKEG-DdYyKfQ3.js → xychartDiagram-FW5EYKEG-DMFqWn7z.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/idempotency.js +33 -8
  61. package/lib/judgment/schema.js +23 -0
  62. package/lib/judgment/store/index.js +49 -0
  63. package/lib/judgment/store/records.js +246 -0
  64. package/lib/judgment-gen.js +342 -0
  65. package/lib/judgment-write-guard.js +230 -0
  66. package/lib/judgment-writer.js +877 -0
  67. package/lib/lifecycle-modes.js +42 -0
  68. package/package.json +1 -1
  69. package/server/compose-mcp-tools.js +34 -0
  70. package/server/compose-mcp.js +125 -0
  71. package/server/mcp-tool-policy.js +1 -0
  72. package/dist/assets/channel-BzkmtaLy.js +0 -1
  73. package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
  74. package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
  75. package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
  76. package/dist/assets/swimlanesDiagram-G3AALYLV-BB0gCy01.js +0 -8
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/judgment-import.js — one-time markdown → records import (S07/W3).
4
+ *
5
+ * Parses the hand-written judgment canon (REGISTER.md, LEDGER.md,
6
+ * OBJECTIVE.md, positions/*.md) into records and writes them THROUGH the
7
+ * writer with `via: 'import'` — historical transcription, not new
8
+ * authorship: original dates and `[ASSERT]` grounding are preserved
9
+ * (ASSERT claims gain a citation-elicitation pointing at the imported
10
+ * source), `minutes` costs map to `hours` with a note flag, and curated
11
+ * banners/prose become `note` records with placement anchors.
12
+ *
13
+ * Ledger entries that predate the schema's kind-specific requirements and
14
+ * cannot be mapped losslessly (e.g. the slot-assignment `rank` entry) fall
15
+ * back to `note` records that keep the original heading in the title —
16
+ * deterministic, honest, and reviewable in the cutover diff.
17
+ *
18
+ * Dry-run stages the import in a temp workspace and diffs the regenerated
19
+ * projections against the current hand-written files; the real run is
20
+ * refused if records already exist (import is one-time). Kept for provider
21
+ * migrations (PROVIDER-SEAM: switching is a re-import, never a sync).
22
+ */
23
+ import { readFileSync, readdirSync, existsSync, mkdtempSync } from 'node:fs';
24
+ import { join, basename } from 'node:path';
25
+ import { tmpdir } from 'node:os';
26
+
27
+ import {
28
+ judgmentPositionCreate,
29
+ judgmentJointAdd,
30
+ judgmentLedgerAppend,
31
+ } from '../lib/judgment-writer.js';
32
+ import { getJudgmentValidator } from '../lib/judgment/schema.js';
33
+
34
+ const METHODS = ['EXT', 'INT', 'CONSTRUCT', 'ASSERT', 'STRADDLE'];
35
+ const RULED_COSTS = ['hours', 'days', 'weeks', 'months'];
36
+ const DEFAULT_DATE = '2026-07-20';
37
+
38
+ /** Map a raw cost cell onto the ruled COARSE-BUCKETS. */
39
+ export function mapCost(raw) {
40
+ const cost = String(raw ?? '').toLowerCase().trim();
41
+ if (RULED_COSTS.includes(cost)) return { cost, note: null };
42
+ if (cost === 'minutes') return { cost: 'hours', note: 'import: cost "minutes" mapped to "hours" (COARSE-BUCKETS)' };
43
+ return { cost: 'hours', note: `import: cost "${raw}" is not a ruled bucket — defaulted to "hours"` };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Shared parsing helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function cells(line) {
51
+ return line.replace(/^\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
52
+ }
53
+
54
+ function plain(cell) {
55
+ return cell.replace(/\*\*/g, '').replace(/`/g, '').replace(/~~/g, '').trim();
56
+ }
57
+
58
+ function ts(date) {
59
+ return `${date}T12:00:00Z`;
60
+ }
61
+
62
+ function convictionFrom(text) {
63
+ const t = (text ?? '').toLowerCase();
64
+ const level = ['high', 'medium', 'low'].find((l) => t.includes(l)) ?? 'medium';
65
+ const source = t.includes('inferred') ? 'inferred' : (t.includes('stated') || t.includes('owner') ? 'stated' : 'inferred');
66
+ return { level, source };
67
+ }
68
+
69
+ function isTableRow(line) {
70
+ return line.startsWith('|') && !/^\|[\s\-:|]+\|?\s*$/.test(line);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // REGISTER.md → joints + anchored notes
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function parseRegister(text, objectiveQuestions) {
78
+ const lines = text.split('\n');
79
+ const joints = [];
80
+ const notes = [];
81
+ const struckRows = new Map(); // slug → question (for dissolved joints)
82
+
83
+ let section = 'banner';
84
+ const bannerLines = [];
85
+ const footerBlocks = []; // { title, body[] }
86
+ let currentFooter = null;
87
+
88
+ const sectionOf = (heading) => {
89
+ if (/^## Under test/.test(heading)) return { name: 'table', state: 'under_test', rank: 'high' };
90
+ if (/^## Open — high/.test(heading)) return { name: 'table', state: 'open', rank: 'high' };
91
+ if (/^## Open — medium/.test(heading)) return { name: 'table', state: 'open', rank: 'medium' };
92
+ if (/^## Resolved/.test(heading)) return { name: 'resolved' };
93
+ if (/^## Dissolved/.test(heading)) return { name: 'dissolved' };
94
+ return { name: 'footer', title: heading.replace(/^##\s*/, '') };
95
+ };
96
+
97
+ let mode = { name: 'banner' };
98
+ let dissolvedBody = [];
99
+
100
+ for (const raw of lines) {
101
+ const line = raw.trimEnd();
102
+ if (/^# /.test(line)) continue;
103
+ if (/^## /.test(line)) {
104
+ mode = sectionOf(line);
105
+ if (mode.name === 'footer') {
106
+ currentFooter = { title: mode.title, body: [] };
107
+ footerBlocks.push(currentFooter);
108
+ }
109
+ continue;
110
+ }
111
+ if (mode.name === 'banner') {
112
+ bannerLines.push(line);
113
+ continue;
114
+ }
115
+ if (mode.name === 'footer') {
116
+ currentFooter.body.push(line);
117
+ continue;
118
+ }
119
+ if (mode.name === 'dissolved') {
120
+ dissolvedBody.push(line);
121
+ continue;
122
+ }
123
+ if (mode.name === 'table' || mode.name === 'resolved') {
124
+ if (!isTableRow(line)) {
125
+ // inter-table prose (Moved here…, Re-weighted…, demotion notes)
126
+ if (line.trim()) footerBlocks.push({ title: 'register prose', body: [line] });
127
+ continue;
128
+ }
129
+ const row = cells(line);
130
+ if (row.length < 4 || plain(row[0]).toLowerCase() === 'joint') continue;
131
+ if (raw.includes('~~')) {
132
+ struckRows.set(plain(row[0]), plain(row[1]));
133
+ continue;
134
+ }
135
+ if (mode.name === 'resolved') {
136
+ const [slugCell, dateCell, byCell, outcomeCell] = row;
137
+ const slug = plain(slugCell);
138
+ const date = plain(dateCell);
139
+ const question = objectiveQuestions.get(slug) ?? '(question not recorded at import)';
140
+ const refMatch = /\(`?(decide: [a-z0-9-]+)`?\)/.exec(outcomeCell);
141
+ const { cost, note } = mapCost('minutes'); // the register records these as minutes-scale ASSERT rulings
142
+ joints.push({
143
+ date,
144
+ record: {
145
+ slug,
146
+ question,
147
+ branch_true: '(not recorded at import)',
148
+ branch_false: '(not recorded at import)',
149
+ resolve_by: METHODS.find((m) => byCell.includes(m)) ?? 'ASSERT',
150
+ cost,
151
+ rank: 'medium',
152
+ state: 'resolved',
153
+ flags: [note].filter(Boolean),
154
+ resolution: {
155
+ outcome: 'resolved',
156
+ evidence: plain(outcomeCell),
157
+ elicitation: {
158
+ asked: question,
159
+ answered_at: ts(date),
160
+ answer_ref: refMatch ? refMatch[1] : 'docs/judgment/LEDGER.md',
161
+ },
162
+ },
163
+ },
164
+ });
165
+ continue;
166
+ }
167
+ // open / under_test tables
168
+ const [slugCell, qCell, tCell, fCell, byCell, costCell] = row;
169
+ const slug = plain(slugCell);
170
+ // Strip flag tokens BEFORE method detection — 'EXT-UNREACHABLE' must not
171
+ // read as method EXT (already-knew resolves by INT).
172
+ const byClean = plain(byCell ?? '').replace(/EXT-UNREACHABLE/g, '').replace(/blocked on sharpening/gi, '');
173
+ const method = (/\b(EXT|INT|CONSTRUCT|ASSERT|STRADDLE)\b/.exec(byClean) ?? [])[1] ?? 'INT';
174
+ const { cost, note } = mapCost(plain(costCell ?? ''));
175
+ const flags = [];
176
+ if (/EXT-UNREACHABLE/.test(byCell ?? '')) flags.push('EXT-UNREACHABLE');
177
+ if (/blocked on sharpening/i.test(byCell ?? '')) flags.push('blocked-on-sharpening');
178
+ const methodNote = byClean
179
+ .replace(new RegExp(`\\b${method}\\b`), '')
180
+ .replace(/\s{2,}/g, ' ')
181
+ .replace(/^[\s—·-]+|[\s—·-]+$/g, '');
182
+ if (methodNote) flags.push(`method-note: ${methodNote}`);
183
+ if (note) flags.push(note);
184
+ joints.push({
185
+ date: DEFAULT_DATE,
186
+ record: {
187
+ slug,
188
+ question: plain(qCell),
189
+ branch_true: plain(tCell),
190
+ branch_false: plain(fCell),
191
+ resolve_by: method,
192
+ cost,
193
+ rank: mode.rank,
194
+ state: mode.state,
195
+ flags,
196
+ },
197
+ });
198
+ }
199
+ }
200
+
201
+ // Dissolved joints from struck rows + the Dissolved section prose.
202
+ const dissolvedText = dissolvedBody.join('\n');
203
+ for (const [slug, question] of struckRows) {
204
+ const halves = [...new Set([...dissolvedText.matchAll(/\bthe ([A-Z][a-z]+)\b/g)].map((m) => m[1].toLowerCase()))];
205
+ joints.push({
206
+ date: DEFAULT_DATE,
207
+ record: {
208
+ slug,
209
+ question: question || '(question not recorded at import)',
210
+ branch_true: '(not recorded at import)',
211
+ branch_false: '(not recorded at import)',
212
+ resolve_by: 'CONSTRUCT',
213
+ cost: 'hours',
214
+ rank: 'medium',
215
+ state: 'dissolved',
216
+ flags: ['import: resolve_by/cost not recorded on the dissolved row — defaulted'],
217
+ dissolution: { decomposed_into: halves.length ? halves : ['recorded-in-dissolution-note'] },
218
+ },
219
+ });
220
+ }
221
+
222
+ notes.push({
223
+ date: '2026-07-22',
224
+ event: { kind: 'note', title: 'Register banner (imported)', body: bannerLines.filter(Boolean).join('\n'), anchor: 'register-header' },
225
+ });
226
+ if (dissolvedText.trim()) {
227
+ footerBlocks.push({ title: 'Dissolved', body: dissolvedBody });
228
+ }
229
+ for (const block of footerBlocks) {
230
+ const body = block.body.filter(Boolean).join('\n').trim();
231
+ if (!body) continue;
232
+ notes.push({
233
+ date: '2026-07-22',
234
+ event: { kind: 'note', title: `Register: ${block.title} (imported)`, body, anchor: 'register-footer' },
235
+ });
236
+ }
237
+ return { joints, notes };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // OBJECTIVE.md → position 'objective' + question map
242
+ // ---------------------------------------------------------------------------
243
+
244
+ function parseObjective(text) {
245
+ const questions = new Map();
246
+ for (const line of text.split('\n')) {
247
+ if (!isTableRow(line)) continue;
248
+ const row = cells(line);
249
+ if (row.length >= 3 && /`[a-z0-9-]+`/.test(row[0])) {
250
+ questions.set(plain(row[0]), plain(row[1]));
251
+ }
252
+ }
253
+ const primary = /\*\*Primary:\*\*\s*([\s\S]*?)\n\n/.exec(text)?.[1]?.replace(/\n/g, ' ').trim()
254
+ ?? '(objective not parseable at import)';
255
+ // The file's own header tags it `[ASSERT]` — preserved verbatim (binding:
256
+ // import preserves grounding). The elicitation citation records honestly
257
+ // that this was back-inferred, never elicited; the health warning survives
258
+ // in the anchored prose note.
259
+ const position = {
260
+ date: DEFAULT_DATE,
261
+ slug: 'objective',
262
+ record: {
263
+ slug: 'objective',
264
+ claims: [{
265
+ id: 'c1',
266
+ text: primary,
267
+ grounding: 'ASSERT',
268
+ supports: [],
269
+ elicitation: {
270
+ asked: 'What are we optimizing for?',
271
+ answered_at: ts(DEFAULT_DATE),
272
+ answer_ref: 'import:docs/judgment/OBJECTIVE.md (back-inferred draft — NOT owner-confirmed, see health warning)',
273
+ },
274
+ }],
275
+ conviction: { level: 'low', source: 'inferred' },
276
+ },
277
+ };
278
+ const note = {
279
+ date: DEFAULT_DATE,
280
+ event: { kind: 'note', title: 'OBJECTIVE.md (imported prose)', body: text.trim(), anchor: 'position:objective' },
281
+ };
282
+ return { position, note, questions };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // positions/*.md → position records + prose notes
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function parsePositionFile(slug, text, sourceRel) {
290
+ const date = /\*\*Held since:\*\*\s*(\d{4}-\d{2}-\d{2})/.exec(text)?.[1]
291
+ ?? /(\d{4}-\d{2}-\d{2})/.exec(text)?.[1] ?? DEFAULT_DATE;
292
+ const conviction = convictionFrom(/\*\*Conviction:\*\*([^\n]*)/.exec(text)?.[1] ?? '');
293
+
294
+ const claims = [];
295
+ const cite = (asked) => ({ asked, answered_at: ts(date), answer_ref: `import:${sourceRel}` });
296
+
297
+ // Numbered argument table: | # | Step | Grounding |
298
+ for (const line of text.split('\n')) {
299
+ if (!isTableRow(line)) continue;
300
+ const row = cells(line);
301
+ if (row.length >= 3 && /^\d+$/.test(plain(row[0]))) {
302
+ const id = `c${plain(row[0])}`;
303
+ const step = plain(row[1]);
304
+ const groundingCell = row[2];
305
+ const derivedFrom = /derived from (\d+)/i.exec(groundingCell);
306
+ let claim;
307
+ if (derivedFrom) {
308
+ claim = { id, text: step, grounding: 'DERIVED', supports: [`c${derivedFrom[1]}`] };
309
+ } else {
310
+ // Preserve the recorded grounding verbatim (binding); AGENT only when
311
+ // the cell names none of the vocabulary.
312
+ const grounding = (/\b(EXT|INT|ASSERT|DERIVED|AGENT)\b/.exec(groundingCell) ?? [])[1] ?? 'AGENT';
313
+ claim = { id, text: step, grounding, supports: [] };
314
+ if (grounding === 'ASSERT') claim.elicitation = cite(step);
315
+ }
316
+ if (/owner-locked/.test(groundingCell)) claim.owner_locked = true;
317
+ claims.push(claim);
318
+ }
319
+ }
320
+ // Evidence bullets: - `[EXT]` text
321
+ if (claims.length === 0) {
322
+ let n = 0;
323
+ for (const line of text.split('\n')) {
324
+ const m = /^-\s*`\[(EXT|INT|ASSERT|AGENT|DERIVED)\]`\s*(.+)$/.exec(line.trim());
325
+ if (!m) continue;
326
+ n += 1;
327
+ const claim = { id: `c${n}`, text: plain(m[2]), grounding: m[1], supports: [] };
328
+ if (m[1] === 'ASSERT') claim.elicitation = cite(claim.text);
329
+ if (/owner-locked/.test(line)) claim.owner_locked = true;
330
+ claims.push(claim);
331
+ }
332
+ }
333
+ if (claims.length === 0) {
334
+ const claimSection = /## (?:The )?[Cc]laim\n+([\s\S]*?)(?:\n## |$)/.exec(text)?.[1]?.replace(/\n/g, ' ').trim();
335
+ claims.push({ id: 'c1', text: claimSection ?? '(claim not parseable at import)', grounding: 'AGENT', supports: [] });
336
+ }
337
+
338
+ return {
339
+ position: { date, slug, record: { slug, claims, conviction } },
340
+ note: {
341
+ date,
342
+ event: { kind: 'note', title: `${basename(sourceRel)} (imported prose)`, body: text.trim(), anchor: `position:${slug}` },
343
+ },
344
+ };
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // LEDGER.md → ledger events (schema-mapped, note fallback)
349
+ // ---------------------------------------------------------------------------
350
+
351
+ const LEDGER_KINDS = ['decide', 'kill', 'override', 'escalate', 'calibrate', 'postmortem', 'rank', 'note', 'correct', 'open'];
352
+
353
+ function parseLedger(text) {
354
+ const lines = text.split('\n');
355
+ const entries = [];
356
+ let banner = [];
357
+ let sawSection = false;
358
+ let currentDate = DEFAULT_DATE;
359
+ let current = null;
360
+
361
+ const flush = () => {
362
+ if (current) entries.push(current);
363
+ current = null;
364
+ };
365
+
366
+ for (const raw of lines) {
367
+ const line = raw.trimEnd();
368
+ const heading = /^###\s+([a-z]+):\s*(.+)$/.exec(line);
369
+ if (/^## /.test(line)) {
370
+ flush();
371
+ sawSection = true;
372
+ const date = /(\d{4}-\d{2}-\d{2})/.exec(line)?.[1];
373
+ if (date) currentDate = date;
374
+ entries.push({ kind: 'note', title: `Ledger section: ${line.replace(/^##\s*/, '')} (imported)`, body: [], anchor: 'ledger', date: currentDate });
375
+ continue;
376
+ }
377
+ if (heading && LEDGER_KINDS.includes(heading[1])) {
378
+ flush();
379
+ const rest = heading[2].trim();
380
+ const date = /(\d{4}-\d{2}-\d{2})/.exec(rest)?.[1] ?? currentDate;
381
+ current = { kind: heading[1], title: rest, body: [], anchor: null, date };
382
+ continue;
383
+ }
384
+ if (line === '---') { flush(); continue; }
385
+ if (!sawSection && !current) { banner.push(line); continue; }
386
+ if (current) current.body.push(line);
387
+ else if (line.trim()) entries.push({ kind: 'note', title: 'Ledger prose (imported)', body: [line], anchor: 'ledger', date: currentDate });
388
+ }
389
+ flush();
390
+
391
+ const validator = getJudgmentValidator();
392
+ const probeProvenance = { actor: 'agent', written_at: ts(DEFAULT_DATE), via: 'import' };
393
+
394
+ const events = [];
395
+ if (banner.filter(Boolean).length) {
396
+ events.push({
397
+ date: DEFAULT_DATE,
398
+ event: { kind: 'note', title: 'Ledger banner (imported)', body: banner.filter(Boolean).join('\n'), anchor: 'ledger-header' },
399
+ });
400
+ }
401
+ for (const entry of entries) {
402
+ const body = (entry.body ?? []).filter(Boolean).join('\n').trim();
403
+ const event = { kind: entry.kind, title: entry.title, body };
404
+ if (entry.anchor) event.anchor = entry.anchor;
405
+ if (entry.kind === 'decide' || entry.kind === 'kill') {
406
+ const rejectedLine = /\*\*Rejected[^:]*:\*\*\s*([\s\S]*?)(?:\n\*\*|$)/.exec(body)?.[1];
407
+ if (entry.kind === 'decide') {
408
+ event.rejected = rejectedLine
409
+ ? [{ what: rejectedLine.replace(/\n/g, ' ').trim(), why: 'recorded in the imported entry body' }]
410
+ : [];
411
+ event.conviction = convictionFrom(/\*\*Conviction:\*\*([^\n]*)/.exec(body)?.[1] ?? '');
412
+ }
413
+ }
414
+ if (entry.kind === 'override') {
415
+ event.reason = body.split('\n')[0] || entry.title;
416
+ }
417
+ if (!body) delete event.body;
418
+
419
+ const { valid } = validator.validate('ledger_event', { ...event, provenance: probeProvenance });
420
+ if (valid) {
421
+ events.push({ date: entry.date, event });
422
+ } else {
423
+ // pre-schema entry shape → honest note fallback, original heading kept
424
+ events.push({
425
+ date: entry.date,
426
+ event: { kind: 'note', title: `${entry.kind}: ${entry.title} (imported as note)`, body: body || entry.title, anchor: 'ledger' },
427
+ });
428
+ }
429
+ }
430
+ return events;
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // The import
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function readSources(cwd) {
438
+ const dir = join(cwd, 'docs', 'judgment');
439
+ const positionsDir = join(dir, 'positions');
440
+ const positionFiles = existsSync(positionsDir)
441
+ ? readdirSync(positionsDir).filter((f) => f.endsWith('.md')).sort()
442
+ : [];
443
+ return {
444
+ register: readFileSync(join(dir, 'REGISTER.md'), 'utf8'),
445
+ ledger: readFileSync(join(dir, 'LEDGER.md'), 'utf8'),
446
+ objective: readFileSync(join(dir, 'OBJECTIVE.md'), 'utf8'),
447
+ positions: positionFiles.map((f) => ({
448
+ slug: f.replace(/\.md$/, ''),
449
+ rel: `docs/judgment/positions/${f}`,
450
+ text: readFileSync(join(positionsDir, f), 'utf8'),
451
+ })),
452
+ };
453
+ }
454
+
455
+ function buildPlan(sources) {
456
+ const objective = parseObjective(sources.objective);
457
+ const register = parseRegister(sources.register, objective.questions);
458
+ const ledger = parseLedger(sources.ledger);
459
+
460
+ const positions = [objective.position];
461
+ const notes = [objective.note];
462
+ for (const file of sources.positions) {
463
+ const parsed = parsePositionFile(file.slug, file.text, file.rel);
464
+ positions.push(parsed.position);
465
+ notes.push(parsed.note);
466
+ }
467
+ // Event order: ledger banner + entries (chronology), then register notes,
468
+ // then position prose notes.
469
+ const events = [...ledger, ...register.notes, ...notes];
470
+ return { positions, joints: register.joints, events };
471
+ }
472
+
473
+ async function executePlan(cwd, plan) {
474
+ for (const p of plan.positions) {
475
+ await judgmentPositionCreate(cwd, p.record, { via: 'import', writtenAt: ts(p.date) });
476
+ }
477
+ for (const j of plan.joints) {
478
+ await judgmentJointAdd(cwd, j.record, { via: 'import', writtenAt: ts(j.date) });
479
+ }
480
+ for (const e of plan.events) {
481
+ await judgmentLedgerAppend(cwd, e.event, { via: 'import', writtenAt: ts(e.date) });
482
+ }
483
+ }
484
+
485
+ function diffProjections(stagingCwd, cwd) {
486
+ const stagingDir = join(stagingCwd, 'docs', 'judgment');
487
+ const files = ['REGISTER.md', 'LEDGER.md', 'OBJECTIVE.md', 'index.md',
488
+ ...readdirSync(join(stagingDir, 'positions')).filter((f) => f.endsWith('.md')).map((f) => `positions/${f}`)];
489
+ return files.map((rel) => {
490
+ const generated = readFileSync(join(stagingDir, rel), 'utf8');
491
+ const currentPath = join(cwd, 'docs', 'judgment', rel);
492
+ const current = existsSync(currentPath) ? readFileSync(currentPath, 'utf8') : null;
493
+ return { file: `docs/judgment/${rel}`, changed: current !== generated, new: current === null };
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Run the import. Dry-run: stage in a temp workspace, report counts + the
499
+ * projection diff, write nothing into cwd. Real run: refuse if records
500
+ * exist, then write records through the writer (projections cut over as a
501
+ * side-effect of each write).
502
+ *
503
+ * @param {string} cwd
504
+ * @param {{ dryRun?: boolean }} [opts]
505
+ */
506
+ export async function runJudgmentImport(cwd, { dryRun = false } = {}) {
507
+ // Preflight the configured canon provider: if judgment.provider selects an
508
+ // unimplemented/unknown backend this throws HERE, before any staging or
509
+ // copying — floor records must never land under a config that rejects them.
510
+ const { createJudgmentStore } = await import('../lib/judgment/store/index.js');
511
+ createJudgmentStore(cwd);
512
+ if (existsSync(join(cwd, 'docs', 'judgment', 'records'))) {
513
+ throw new Error(
514
+ 'judgment records already exist under docs/judgment/records/ — import is one-time (remove records/ to re-run; '
515
+ + 'if a cutover was interrupted after records landed, regenerate projections via lib/judgment-gen.js#regenerateProjections)',
516
+ );
517
+ }
518
+ const sources = readSources(cwd);
519
+ const plan = buildPlan(sources);
520
+
521
+ // Stage the FULL import in a temp workspace: every record goes through the
522
+ // writer there, and the staged projections are what the human gate diffs
523
+ // against the current hand-written files (staging path is returned so the
524
+ // diff is actually inspectable).
525
+ const staging = mkdtempSync(join(tmpdir(), 'judgment-import-staging-'));
526
+ await executePlan(staging, plan);
527
+ const diff = diffProjections(staging, cwd);
528
+
529
+ if (!dryRun) {
530
+ // Crash-safe cutover: the writer-produced records land in cwd via copy →
531
+ // single rename (no partial records/ tree on interruption), then the
532
+ // projections regenerate from them. A crash after the rename self-heals:
533
+ // every subsequent writer op regenerates all projections.
534
+ const { cpSync, renameSync: rename } = await import('node:fs');
535
+ const { regenerateProjections } = await import('../lib/judgment-gen.js');
536
+ const dst = join(cwd, 'docs', 'judgment', 'records');
537
+ const tmpDst = `${dst}.importing.${process.pid}`;
538
+ cpSync(join(staging, 'docs', 'judgment', 'records'), tmpDst, { recursive: true });
539
+ rename(tmpDst, dst);
540
+ regenerateProjections(cwd);
541
+ }
542
+
543
+ return {
544
+ dryRun,
545
+ staging,
546
+ counts: {
547
+ positions: plan.positions.length,
548
+ joints: plan.joints.length,
549
+ ledger_events: plan.events.length,
550
+ },
551
+ diff,
552
+ };
553
+ }
554
+
555
+ // CLI: node bin/judgment-import.js [--dry-run] [cwd]
556
+ if (import.meta.url === `file://${process.argv[1]}`) {
557
+ const dryRun = process.argv.includes('--dry-run');
558
+ const cwd = process.argv.slice(2).find((a) => !a.startsWith('--')) ?? process.cwd();
559
+ runJudgmentImport(cwd, { dryRun }).then((result) => {
560
+ console.log(JSON.stringify(result, null, 2));
561
+ }).catch((err) => {
562
+ console.error(err.message);
563
+ process.exit(1);
564
+ });
565
+ }