@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,877 @@
1
+ /**
2
+ * lib/judgment-writer.js — the typed writer for judgment canon (S05).
3
+ *
4
+ * The six operations of design Decision 4, transport-free `(cwd, args)`
5
+ * (journal-writer template): position create/amend, joint add, transition,
6
+ * ledger append, and the read op. All writes:
7
+ * - validate synchronously BEFORE the idempotency wrapper (feature-writer
8
+ * ordering);
9
+ * - run under the judgment advisory lock (`.compose/data/judgment.lock`,
10
+ * journal-writer recipe: mkdir lock, stale recovery, bounded retry);
11
+ * - replay pending intents first (the reconciler runs on every write and
12
+ * on getJudgmentState);
13
+ * - stamp provenance themselves — no caller can set any provenance field;
14
+ * - regenerate projections, with compensating rollback on failure
15
+ * (`JUDGMENT_PARTIAL_WRITE` carries the cause);
16
+ * - append a best-effort audit event AFTER commit (feature-events);
17
+ * - return small typed results (AUDIT-19), errors carrying `code`/`cause`.
18
+ *
19
+ * Transitions are intent-first (design D4, rounds 2–3): the COMPLETE
20
+ * mutation (joint + atomic ledger events + spawned predictions) is persisted
21
+ * as a pending-intent record BEFORE the guard call; on a verdict it is
22
+ * applied and the intent cleared; the reconciler replays incomplete intents
23
+ * idempotently — guard state authoritative for the edge, the intent
24
+ * authoritative for the payload. Where `capabilities.guard` is true the
25
+ * Stratum guard (via the untouched `guardedTransition` adapter, mode
26
+ * 'judgment' — a data-only LIFECYCLE_MODES entry) is the single lifecycle
27
+ * authority; where absent, the write-guard's identical edge table enforces
28
+ * (graph parity is contract-tested).
29
+ *
30
+ * ONE-UNDER-TEST is enforced writer-locally inside the advisory lock (the
31
+ * floor). Guard-predicate expressibility: STRAT-GUARD edge predicates are
32
+ * per-resource deterministic file checks (`server_file_exists`); a
33
+ * population invariant across joints is not expressible today — probed
34
+ * against the predicate surface in lifecycle-guard.js (edgePredicates’
35
+ * `deterministic` statements), noted in the implementation report, and the
36
+ * upstream population-invariant ask is filed in Stratum's tracker either way.
37
+ */
38
+ import {
39
+ readFileSync, writeFileSync, renameSync, unlinkSync,
40
+ mkdirSync, rmSync, statSync, existsSync, utimesSync,
41
+ } from 'node:fs';
42
+ import { join, dirname } from 'node:path';
43
+
44
+ import { checkOrInsert } from './idempotency.js';
45
+ import { appendEvent } from './feature-events.js';
46
+ import { createJudgmentStore } from './judgment/store/index.js';
47
+ import { regenerateProjections } from './judgment-gen.js';
48
+ import {
49
+ assertValidRecord,
50
+ assertGrounding,
51
+ assertEdgeArtifact,
52
+ assertMethodGate,
53
+ } from './judgment-write-guard.js';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Errors
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function typedError(code, message, cause) {
60
+ const err = new Error(message);
61
+ err.code = code;
62
+ if (cause !== undefined) err.cause = cause;
63
+ return err;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Advisory lock (journal-writer recipe: mkdir lock + stale recovery)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ // A judgment critical section can legitimately span a guard subprocess call
71
+ // (Stratum client timeout: 10s), so unlike the journal recipe the lock is
72
+ // HEARTBEATED: the holder refreshes the dir mtime every second, and only a
73
+ // lock whose heartbeat has stopped for LOCK_STALE_MS is stealable. An owner
74
+ // token inside the dir makes release/steal safe against the ABA case (my
75
+ // stale lock was stolen; I must not delete the thief's lock on release).
76
+ // The heartbeat runs on the event loop, so a SYNCHRONOUS block longer than
77
+ // LOCK_STALE_MS would defeat it — the threshold is therefore set well above
78
+ // any sync section this codebase can produce (sync work here is small-file
79
+ // fs I/O; the long operations — guard subprocess calls — are async and keep
80
+ // the loop free). 20s of contiguous sync blocking is the documented bound.
81
+ const LOCK_STALE_MS = 20000;
82
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
83
+ const LOCK_HEARTBEAT_MS = 1000;
84
+ const LOCK_RETRY_MS = 25;
85
+
86
+ function judgmentLockPath(cwd) {
87
+ return join(cwd, '.compose', 'data', 'judgment.lock');
88
+ }
89
+
90
+ async function acquireJudgmentLock(cwd) {
91
+ const path = judgmentLockPath(cwd);
92
+ const ownerFile = join(path, 'owner');
93
+ mkdirSync(dirname(path), { recursive: true });
94
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
95
+ const start = Date.now();
96
+ // eslint-disable-next-line no-constant-condition
97
+ while (true) {
98
+ try {
99
+ mkdirSync(path);
100
+ writeFileSync(ownerFile, token);
101
+ const heartbeat = setInterval(() => {
102
+ try { utimesSync(path, new Date(), new Date()); } catch { /* lock stolen/gone — release will no-op */ }
103
+ }, LOCK_HEARTBEAT_MS);
104
+ heartbeat.unref?.();
105
+ return () => {
106
+ clearInterval(heartbeat);
107
+ try {
108
+ if (readFileSync(ownerFile, 'utf8') === token) rmSync(path, { recursive: true, force: true });
109
+ } catch { /* not ours anymore — leave it */ }
110
+ };
111
+ } catch (err) {
112
+ if (err.code !== 'EEXIST') throw err;
113
+ try {
114
+ const { mtimeMs } = statSync(path);
115
+ if (Date.now() - mtimeMs > LOCK_STALE_MS) {
116
+ rmSync(path, { recursive: true, force: true });
117
+ continue;
118
+ }
119
+ } catch { /* stat raced; loop and retry */ }
120
+ if (Date.now() - start > LOCK_ACQUIRE_TIMEOUT_MS) {
121
+ throw typedError('JUDGMENT_LOCK_TIMEOUT', `judgment-writer lock timeout after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${path}`);
122
+ }
123
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
124
+ }
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Provenance + config
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Writer-stamped provenance. `session` is best-effort diagnostic garnish
134
+ * (cross-process MCP reality — nothing may depend on it); the load-bearing
135
+ * stamps are `actor` and `written_at`. `via`/`writtenAt` are INTERNAL opts
136
+ * for the importer only — never reachable through the MCP surface.
137
+ */
138
+ function stampProvenance(internal = {}) {
139
+ const provenance = {
140
+ actor: 'agent',
141
+ session: process.env.CLAUDE_SESSION_ID || null,
142
+ written_at: internal.writtenAt ?? new Date().toISOString(),
143
+ };
144
+ if (internal.via) provenance.via = internal.via;
145
+ return provenance;
146
+ }
147
+
148
+ function guardEnabled(cwd) {
149
+ try {
150
+ const config = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf8'));
151
+ return config?.capabilities?.guard === true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Compensating rollback
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function atomicWrite(path, content) {
162
+ const tmp = `${path}.tmp.${process.pid}`;
163
+ try {
164
+ writeFileSync(tmp, content);
165
+ renameSync(tmp, path);
166
+ } catch (err) {
167
+ try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
168
+ throw err;
169
+ }
170
+ }
171
+
172
+ class UndoLog {
173
+ constructor() { this.entries = []; }
174
+
175
+ /** Record a file's prior bytes (null = did not exist) before it is touched. */
176
+ note(path) {
177
+ this.entries.push({ path, prior: existsSync(path) ? readFileSync(path, 'utf8') : null });
178
+ }
179
+
180
+ restore() {
181
+ for (const { path, prior } of [...this.entries].reverse()) {
182
+ try {
183
+ if (prior === null) { try { unlinkSync(path); } catch { /* gone */ } } else atomicWrite(path, prior);
184
+ } catch { /* best-effort compensation */ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Run record mutations + projection regen with compensating rollback: on any
191
+ * failure the touched records are restored, projections re-regenerated from
192
+ * the restored records, and JUDGMENT_PARTIAL_WRITE surfaces the cause.
193
+ */
194
+ function commitWithProjections(cwd, undo, mutate) {
195
+ try {
196
+ mutate();
197
+ regenerateProjections(cwd);
198
+ } catch (err) {
199
+ undo.restore();
200
+ try { regenerateProjections(cwd); } catch { /* projections follow restored records on next regen */ }
201
+ throw typedError('JUDGMENT_PARTIAL_WRITE', `judgment write rolled back: ${err.message}`, err);
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Intent replay (the reconciler)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ let _intentCounter = 0;
210
+ function newIntentId() {
211
+ _intentCounter += 1;
212
+ return `intent-${Date.now()}-${process.pid}-${_intentCounter}`;
213
+ }
214
+
215
+ async function callGuard(cwd, { slug, from, to }) {
216
+ // Lazy import: keeps this module leaf-light and cycle-free; the adapter is
217
+ // only loaded where capabilities.guard is on.
218
+ const { guardedTransition } = await import('../server/lifecycle-guard.js');
219
+ return guardedTransition({ featureCode: slug, from, to, workspaceRoot: cwd, mode: 'judgment' });
220
+ }
221
+
222
+ /** Apply an intent payload idempotently (joint write, deduped events, predictions). */
223
+ function applyPayload(store, payload) {
224
+ if (payload.joint) store.writeJoint(payload.joint);
225
+ if (Array.isArray(payload.events) && payload.events.length > 0) {
226
+ const existing = new Set(store.readLedgerEvents().map((e) => JSON.stringify(e)));
227
+ for (const event of payload.events) {
228
+ if (!existing.has(JSON.stringify(event))) store.appendLedgerEvent(event);
229
+ }
230
+ }
231
+ for (const prediction of payload.predictions ?? []) store.writePrediction(prediction);
232
+ }
233
+
234
+ /**
235
+ * Replay every pending intent. MUST be called with the judgment lock held.
236
+ * Guard state is authoritative for the edge (re-issuing the transition:
237
+ * applied/replayed → apply payload; refused → drop intent, divergence
238
+ * surfaced; unreachable → keep intent for the next replay); the intent is
239
+ * authoritative for the payload — recovery restores the WHOLE mutation,
240
+ * never a bare state flip.
241
+ */
242
+ /**
243
+ * Drop an intent DURABLY: the ledger note lands (idempotent on its title,
244
+ * which embeds the intent id) BEFORE the intent is cleared. If the append
245
+ * fails the intent survives and the error propagates — a dropped divergence
246
+ * must never silently vanish.
247
+ */
248
+ function dropIntentDurably(store, intent, reason, detail) {
249
+ const payload = intent.payload ?? {};
250
+ const title = `intent dropped (${intent.id}): ${payload.slug} ${payload.from} → ${payload.to} — ${reason}`;
251
+ const already = store.readLedgerEvents().some((e) => e.kind === 'note' && e.title === title);
252
+ if (!already) {
253
+ store.appendLedgerEvent({
254
+ kind: 'note',
255
+ title,
256
+ body: JSON.stringify(detail),
257
+ anchor: `joint:${payload.slug}`,
258
+ provenance: stampProvenance(),
259
+ });
260
+ }
261
+ store.clearIntent(intent.id);
262
+ }
263
+
264
+ async function replayIntentsLocked(cwd) {
265
+ const store = createJudgmentStore(cwd);
266
+ const intents = store.readIntents();
267
+ let replayed = 0;
268
+ let mutated = false;
269
+ const divergences = [];
270
+ for (const intent of intents) {
271
+ const payload = intent.payload ?? {};
272
+ // Population invariant FIRST — before any guard call, so recovery can
273
+ // never advance the authoritative guard for an intent it is about to
274
+ // drop (stacked intents must not split guard state from records).
275
+ if (payload.to === 'under_test') {
276
+ const occupant = store.listJoints().find((j) => j.slug !== payload.slug && j.state === 'under_test');
277
+ if (occupant) {
278
+ dropIntentDurably(store, intent, `ONE-UNDER-TEST refused on replay (${occupant.slug} already under test)`, { occupant: occupant.slug });
279
+ divergences.push({ intent: intent.id, slug: payload.slug, refused: true, oneUnderTest: occupant.slug });
280
+ mutated = true;
281
+ continue;
282
+ }
283
+ }
284
+ if (guardEnabled(cwd) && payload.to && payload.from !== payload.to) {
285
+ const guard = await callGuard(cwd, { slug: payload.slug, from: payload.from, to: payload.to });
286
+ if (!guard.applied) {
287
+ // guard unreachable → keep the intent; the next replay retries
288
+ if (guard.error) continue;
289
+ // The crash window: the guard ALREADY advanced this edge in the
290
+ // interrupted attempt, so re-issuing is refused with currentState at
291
+ // the target. Guard state is authoritative for the edge — the intent
292
+ // is authoritative for the payload: roll the whole mutation forward.
293
+ const alreadyAdvanced = guard.currentState === payload.to;
294
+ if (!alreadyAdvanced) {
295
+ if (!guard.refused) continue; // ambiguous non-verdict → retry later
296
+ dropIntentDurably(store, intent, 'refused by guard', {
297
+ verdict: guard.verdict ?? null, currentState: guard.currentState ?? null,
298
+ });
299
+ divergences.push({ intent: intent.id, slug: payload.slug, refused: true, verdict: guard.verdict ?? null });
300
+ mutated = true;
301
+ continue;
302
+ }
303
+ }
304
+ }
305
+ applyPayload(store, payload);
306
+ store.clearIntent(intent.id);
307
+ replayed += 1;
308
+ mutated = true;
309
+ }
310
+ if (mutated) regenerateProjections(cwd);
311
+ return { replayed, divergences };
312
+ }
313
+
314
+ /**
315
+ * Public reconciler: replay pending intents under the lock. Also runs
316
+ * automatically at the head of every write op and getJudgmentState.
317
+ */
318
+ export async function replayPendingIntents(cwd) {
319
+ const release = await acquireJudgmentLock(cwd);
320
+ try {
321
+ return await replayIntentsLocked(cwd);
322
+ } finally {
323
+ release();
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Op wrapper: validate → (idempotency) → lock → replay → execute → audit
329
+ // ---------------------------------------------------------------------------
330
+
331
+ async function runOp(cwd, args, { tool, validate, execute }) {
332
+ if (args?.provenance !== undefined) {
333
+ throw typedError('JUDGMENT_INPUT', `${tool}: provenance is writer-stamped and cannot be set by the caller`);
334
+ }
335
+ const prepared = validate ? validate() : undefined; // sync, BEFORE idempotency (A1/A4)
336
+
337
+ const doWrite = async () => {
338
+ const release = await acquireJudgmentLock(cwd);
339
+ try {
340
+ await replayIntentsLocked(cwd);
341
+ return await execute(prepared);
342
+ } finally {
343
+ release();
344
+ }
345
+ };
346
+
347
+ let result;
348
+ let cached = false;
349
+ if (args?.idempotency_key) {
350
+ ({ result, cached } = await checkOrInsert(cwd, `${tool}:${args.idempotency_key}`, doWrite));
351
+ } else {
352
+ result = await doWrite();
353
+ }
354
+
355
+ if (!cached) {
356
+ // Best-effort audit AFTER commit (A5) — never let audit failure surface.
357
+ try { appendEvent(cwd, { tool, result }); } catch { /* best-effort */ }
358
+ }
359
+ return result;
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // judgment_position_create
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /**
367
+ * Create a position revision: a new chain, a new revision on an existing
368
+ * chain (P1b update), a superseding revision (`supersedes: <slug>#r<N>`), or
369
+ * a tombstone (`retracted: true`). Always a NEW immutable r<N>.json.
370
+ */
371
+ export async function judgmentPositionCreate(cwd, args, internal = {}) {
372
+ return runOp(cwd, args, {
373
+ tool: 'judgment_position_create',
374
+ validate: () => {
375
+ const record = {
376
+ slug: args.slug,
377
+ claims: Array.isArray(args.claims) ? args.claims : [],
378
+ conviction: args.conviction,
379
+ provenance: stampProvenance(internal),
380
+ };
381
+ if (args.rejected_alternatives !== undefined) record.rejected_alternatives = args.rejected_alternatives;
382
+ if (args.supersedes !== undefined) record.supersedes = args.supersedes;
383
+ if (args.retracted !== undefined) record.retracted = args.retracted;
384
+ if (args.provider_ids !== undefined) record.provider_ids = args.provider_ids;
385
+ assertGrounding(record, { via: internal.via });
386
+ assertValidRecord('position_revision', record);
387
+ return record;
388
+ },
389
+ execute: (record) => {
390
+ const store = createJudgmentStore(cwd);
391
+ if (record.supersedes) {
392
+ const [targetSlug, revPart] = record.supersedes.split('#r');
393
+ if (!store.readPositionRevision(targetSlug, Number(revPart))) {
394
+ throw typedError('JUDGMENT_NOT_FOUND', `supersedes target ${record.supersedes} does not exist`);
395
+ }
396
+ }
397
+ const undo = new UndoLog();
398
+ let written;
399
+ commitWithProjections(cwd, undo, () => {
400
+ written = store.writePositionRevision(record);
401
+ undo.note(written.path); // written before noted? note AFTER write would store new bytes —
402
+ // the file is brand new, so restore() must delete it: record prior=null explicitly.
403
+ undo.entries[undo.entries.length - 1].prior = null;
404
+ });
405
+ return { slug: record.slug, rev: written.rev, ref: written.ref, status: store.derivePositionStatus(record.slug) };
406
+ },
407
+ });
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // judgment_position_amend
412
+ // ---------------------------------------------------------------------------
413
+
414
+ const AMEND_ALLOWED_KEYS = new Set(['slug', 'claim_id', 'grounding', 'elicitation', 'conviction', 'idempotency_key']);
415
+
416
+ /**
417
+ * Scoped amendment (P6 SHAKE-GROUNDING): a new revision whose delta is
418
+ * restricted to one claim's grounding and/or the conviction block. Anything
419
+ * else — claim text, branches, rejected alternatives — is supersession.
420
+ */
421
+ export async function judgmentPositionAmend(cwd, args, internal = {}) {
422
+ return runOp(cwd, args, {
423
+ tool: 'judgment_position_amend',
424
+ validate: () => {
425
+ const illegal = Object.keys(args ?? {}).filter((k) => !AMEND_ALLOWED_KEYS.has(k));
426
+ if (illegal.length > 0) {
427
+ throw typedError(
428
+ 'JUDGMENT_INPUT',
429
+ `judgment_position_amend: delta is restricted to grounding/conviction (illegal: ${illegal.join(', ')}) — for any other change create a superseding revision via judgment_position_create with supersedes: <slug>#r<N> (supersession path)`,
430
+ );
431
+ }
432
+ if (!args.grounding && !args.conviction) {
433
+ throw typedError('JUDGMENT_INPUT', 'judgment_position_amend: nothing to amend — provide grounding (with claim_id) and/or conviction');
434
+ }
435
+ if (args.grounding && !args.claim_id) {
436
+ throw typedError('JUDGMENT_INPUT', 'judgment_position_amend: grounding amendment requires claim_id');
437
+ }
438
+ // Sync shape validation BEFORE idempotency (A1/A4).
439
+ if (args.grounding !== undefined && !['EXT', 'INT', 'ASSERT', 'DERIVED', 'AGENT'].includes(args.grounding)) {
440
+ throw typedError('JUDGMENT_INPUT', `judgment_position_amend: unknown grounding "${args.grounding}"`);
441
+ }
442
+ if (args.conviction !== undefined) assertValidRecord('conviction', args.conviction);
443
+ if (args.elicitation !== undefined) assertValidRecord('elicitation', args.elicitation);
444
+ return null;
445
+ },
446
+ execute: async () => {
447
+ const store = createJudgmentStore(cwd);
448
+ const latest = store.latestPositionRevision(args.slug);
449
+ if (!latest) throw typedError('JUDGMENT_NOT_FOUND', `position ${args.slug} does not exist`);
450
+ if (latest.retracted) throw typedError('JUDGMENT_CONFLICT', `position ${args.slug} is retracted — a tombstone cannot be amended`);
451
+
452
+ const record = { ...latest };
453
+ delete record.rev;
454
+ record.claims = latest.claims.map((c) => ({ ...c }));
455
+ if (args.grounding) {
456
+ const claim = record.claims.find((c) => c.id === args.claim_id);
457
+ if (!claim) throw typedError('JUDGMENT_NOT_FOUND', `claim ${args.claim_id} not found on ${args.slug}`);
458
+ claim.grounding = args.grounding;
459
+ if (args.elicitation) claim.elicitation = args.elicitation;
460
+ if (args.grounding !== 'ASSERT') delete claim.elicitation;
461
+ }
462
+ if (args.conviction) record.conviction = args.conviction;
463
+ record.provenance = stampProvenance(internal);
464
+
465
+ assertGrounding(record, { via: internal.via });
466
+ assertValidRecord('position_revision', record);
467
+
468
+ const undo = new UndoLog();
469
+ let written;
470
+ commitWithProjections(cwd, undo, () => {
471
+ written = store.writePositionRevision(record);
472
+ undo.entries.push({ path: written.path, prior: null });
473
+ });
474
+ return { slug: args.slug, rev: written.rev, ref: written.ref };
475
+ },
476
+ });
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // judgment_joint_add
481
+ // ---------------------------------------------------------------------------
482
+
483
+ /**
484
+ * Add a joint. State is writer-stamped 'open' — never caller-set. Exception:
485
+ * the importer (`internal.via === 'import'`, unreachable through MCP) may
486
+ * transcribe a joint in its historical state with its resolution/dissolution
487
+ * artifacts — import is transcription, not replayed transitions.
488
+ */
489
+ export async function judgmentJointAdd(cwd, args, internal = {}) {
490
+ const importing = internal.via === 'import';
491
+ return runOp(cwd, args, {
492
+ tool: 'judgment_joint_add',
493
+ validate: () => {
494
+ if (args.state !== undefined && !importing) {
495
+ throw typedError('JUDGMENT_INPUT', 'judgment_joint_add: state is the transition artifact — joints are born open');
496
+ }
497
+ const record = {
498
+ slug: args.slug,
499
+ question: args.question,
500
+ branch_true: args.branch_true,
501
+ branch_false: args.branch_false,
502
+ resolve_by: args.resolve_by,
503
+ cost: args.cost,
504
+ rank: args.rank,
505
+ state: importing ? (args.state ?? 'open') : 'open',
506
+ provenance: stampProvenance(internal),
507
+ };
508
+ if (args.ext !== undefined) record.ext = args.ext;
509
+ if (args.straddle !== undefined) record.straddle = args.straddle;
510
+ if (args.flags !== undefined) record.flags = args.flags;
511
+ if (importing) {
512
+ if (args.resolution !== undefined) record.resolution = args.resolution;
513
+ if (args.dissolution !== undefined) record.dissolution = args.dissolution;
514
+ }
515
+ assertValidRecord('joint', record);
516
+ return record;
517
+ },
518
+ execute: (record) => {
519
+ const store = createJudgmentStore(cwd);
520
+ if (store.readJoint(record.slug)) {
521
+ throw typedError('JUDGMENT_CONFLICT', `joint ${record.slug} already exists`);
522
+ }
523
+ const undo = new UndoLog();
524
+ commitWithProjections(cwd, undo, () => {
525
+ undo.entries.push({ path: store._jointPath(record.slug), prior: null });
526
+ store.writeJoint(record);
527
+ });
528
+ return { slug: record.slug, state: record.state, rank: record.rank };
529
+ },
530
+ });
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // judgment_transition
535
+ // ---------------------------------------------------------------------------
536
+
537
+ function noteEvent(slug, title, body, provenance) {
538
+ return { kind: 'note', title, body, anchor: `joint:${slug}`, provenance };
539
+ }
540
+
541
+ /**
542
+ * Joint state machine + rank changes. Intent-first: the complete mutation is
543
+ * persisted before the guard call; guard refusal/unavailability never leaves
544
+ * a half-applied write. Rank changes (alone or alongside an edge) atomically
545
+ * emit their `rank` ledger event in the same locked operation.
546
+ */
547
+ const JOINT_STATES = ['open', 'under_test', 'resolved', 'inconclusive', 'superseded', 'dissolved'];
548
+ const RESOLVE_METHODS = ['EXT', 'INT', 'CONSTRUCT', 'ASSERT', 'STRADDLE'];
549
+
550
+ export async function judgmentTransition(cwd, args, internal = {}) {
551
+ return runOp(cwd, args, {
552
+ tool: 'judgment_transition',
553
+ validate: () => {
554
+ // Full sync shape validation BEFORE the idempotency wrapper (A1/A4):
555
+ // a cached key must never mask malformed input.
556
+ if (!args.slug) throw typedError('JUDGMENT_INPUT', 'judgment_transition: slug is required');
557
+ if (!args.to && !args.rank) {
558
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: provide a target state (to) and/or a rank change');
559
+ }
560
+ if (args.to !== undefined && !JOINT_STATES.includes(args.to)) {
561
+ throw typedError('JUDGMENT_INPUT', `judgment_transition: unknown target state "${args.to}"`);
562
+ }
563
+ if (args.resolution !== undefined) assertValidRecord('resolution', args.resolution);
564
+ if (args.dissolution !== undefined) assertValidRecord('dissolution', args.dissolution);
565
+ if (args.ext !== undefined) assertValidRecord('ext_package', args.ext);
566
+ if (args.straddle !== undefined) assertValidRecord('straddle_package', args.straddle);
567
+ if (args.reopen !== undefined && typeof args.reopen.shaken_evidence_ref !== 'string') {
568
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: reopen requires { shaken_evidence_ref }');
569
+ }
570
+ if (args.redispose !== undefined) {
571
+ if (!RESOLVE_METHODS.includes(args.redispose.new_resolve_by)) {
572
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: redispose requires new_resolve_by (EXT|INT|CONSTRUCT|ASSERT|STRADDLE)');
573
+ }
574
+ if (args.redispose.ext !== undefined) assertValidRecord('ext_package', args.redispose.ext);
575
+ if (args.redispose.straddle !== undefined) assertValidRecord('straddle_package', args.redispose.straddle);
576
+ }
577
+ if (args.rank !== undefined && !['high', 'medium'].includes(args.rank.to)) {
578
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: rank change requires rank.to (high|medium)');
579
+ }
580
+ return null;
581
+ },
582
+ execute: async () => {
583
+ const store = createJudgmentStore(cwd);
584
+ const joint = store.readJoint(args.slug);
585
+ if (!joint) throw typedError('JUDGMENT_NOT_FOUND', `joint ${args.slug} does not exist`);
586
+ const from = joint.state;
587
+ const provenance = stampProvenance(internal);
588
+ const events = [];
589
+ const mutated = { ...joint };
590
+
591
+ const isEdge = args.to !== undefined && args.to !== from;
592
+ if (args.to !== undefined && args.to === from) {
593
+ throw typedError('JUDGMENT_INPUT', `judgment_transition: joint ${args.slug} is already ${from}`);
594
+ }
595
+
596
+ if (isEdge) {
597
+ assertEdgeArtifact(from, args.to, args);
598
+
599
+ // Method packages attach ONLY at dispose (open → under_test) or via
600
+ // the re-dispose input — never on resolution edges, where a supplied
601
+ // package could silently erase a stored judgment-dispatch stamp.
602
+ if ((args.ext || args.straddle) && !(from === 'open' && args.to === 'under_test')) {
603
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: ext/straddle packages attach at dispose (open → under_test) or inside redispose — not on this edge');
604
+ }
605
+ // Re-dispose is exclusively the inconclusive → under_test|open input;
606
+ // on any other edge it would swap the method and erase the stored
607
+ // package (incl. a judgment-dispatch stamp) before the gates run.
608
+ if (args.redispose && from !== 'inconclusive') {
609
+ throw typedError('JUDGMENT_INPUT', 'judgment_transition: redispose is only legal on inconclusive → under_test|open (P3 retry-with-different-method)');
610
+ }
611
+ if (args.redispose) {
612
+ mutated.resolve_by = args.redispose.new_resolve_by;
613
+ delete mutated.ext;
614
+ delete mutated.straddle;
615
+ if (args.redispose.ext) mutated.ext = args.redispose.ext;
616
+ if (args.redispose.straddle) mutated.straddle = args.redispose.straddle;
617
+ }
618
+ if (args.ext) mutated.ext = args.ext;
619
+ if (args.straddle) mutated.straddle = args.straddle;
620
+
621
+ assertMethodGate(mutated, args.to, args);
622
+
623
+ // ONE-UNDER-TEST, inside the advisory lock (the writer-local floor).
624
+ // A pending intent targeting under_test (kept alive by a guard
625
+ // outage) occupies the slot too — recovery must not roll past one.
626
+ if (args.to === 'under_test') {
627
+ const other = store.listJoints().find((j) => j.slug !== args.slug && j.state === 'under_test');
628
+ if (other) {
629
+ throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: joint ${other.slug} is already under test`);
630
+ }
631
+ const pending = store.readIntents().find((i) => i.payload?.to === 'under_test' && i.payload?.slug !== args.slug);
632
+ if (pending) {
633
+ throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: pending intent ${pending.id} already targets under_test for ${pending.payload.slug}`);
634
+ }
635
+ }
636
+
637
+ // Per-edge apply semantics.
638
+ if (['resolved', 'inconclusive', 'superseded'].includes(args.to)) {
639
+ mutated.resolution = args.resolution;
640
+ } else if (args.to === 'dissolved') {
641
+ mutated.dissolution = args.dissolution;
642
+ } else if (args.to === 'open' || (args.to === 'under_test' && args.redispose)) {
643
+ // open-returning edges + re-dispose: the artifact is ledgered, the
644
+ // stale resolution must not survive on the joint.
645
+ if (args.resolution) {
646
+ events.push(noteEvent(args.slug, `failed_to_run: ${args.slug}`, args.resolution.reason, provenance));
647
+ }
648
+ if (args.reopen) {
649
+ events.push(noteEvent(
650
+ args.slug,
651
+ `reopened: ${args.slug}`,
652
+ `shaken evidence: ${args.reopen.shaken_evidence_ref}${joint.resolution ? `; prior resolution: ${JSON.stringify(joint.resolution)}` : ''}`,
653
+ provenance,
654
+ ));
655
+ }
656
+ if (args.redispose) {
657
+ events.push(noteEvent(
658
+ args.slug,
659
+ `re-disposed: ${args.slug}`,
660
+ `new method: ${args.redispose.new_resolve_by}${joint.resolution ? `; prior resolution: ${JSON.stringify(joint.resolution)}` : ''}`,
661
+ provenance,
662
+ ));
663
+ }
664
+ delete mutated.resolution;
665
+ }
666
+ mutated.state = args.to;
667
+ }
668
+
669
+ let rankChange = null;
670
+ if (args.rank) {
671
+ if (!args.rank.to) throw typedError('JUDGMENT_INPUT', 'judgment_transition: rank change requires rank.to');
672
+ if (args.rank.to !== joint.rank) {
673
+ rankChange = { joint: args.slug, from: joint.rank, to: args.rank.to };
674
+ mutated.rank = args.rank.to;
675
+ events.push({
676
+ kind: 'rank',
677
+ title: `rank ${args.slug}: ${rankChange.from} → ${rankChange.to}`,
678
+ refs: [args.slug],
679
+ rank_change: rankChange,
680
+ provenance,
681
+ });
682
+ }
683
+ }
684
+ if (!isEdge && !rankChange) {
685
+ throw typedError('JUDGMENT_INPUT', `judgment_transition: rank is already ${joint.rank} — nothing to do`);
686
+ }
687
+
688
+ assertValidRecord('joint', mutated);
689
+ for (const event of events) assertValidRecord('ledger_event', event);
690
+
691
+ // Intent-first: persist the COMPLETE mutation before the guard call.
692
+ const intent = {
693
+ id: newIntentId(),
694
+ op: 'judgment_transition',
695
+ payload: { slug: args.slug, from, to: mutated.state, joint: mutated, events, predictions: [] },
696
+ created_at: provenance.written_at,
697
+ };
698
+ assertValidRecord('pending_intent', intent);
699
+ store.persistIntent(intent);
700
+
701
+ let guardInfo = null;
702
+ if (isEdge && guardEnabled(cwd)) {
703
+ const guard = await callGuard(cwd, { slug: args.slug, from, to: args.to });
704
+ if (!guard.applied) {
705
+ if (guard.refused) {
706
+ // Local graph said legal, guard said no — surfaced, never hidden,
707
+ // and DURABLY: the drop lands as a ledger note before the intent
708
+ // clears, so the refusal survives even if the caller drops the
709
+ // transient result.
710
+ dropIntentDurably(store, intent, 'refused by guard', {
711
+ verdict: guard.verdict ?? null, currentState: guard.currentState ?? null,
712
+ });
713
+ regenerateProjections(cwd);
714
+ return {
715
+ applied: false,
716
+ refused: true,
717
+ slug: args.slug,
718
+ from,
719
+ to: args.to,
720
+ guard: { verdict: guard.verdict ?? null, ledgerRef: guard.ledgerRef ?? null, currentState: guard.currentState ?? null },
721
+ divergence: true,
722
+ };
723
+ }
724
+ // Guard ERROR (unreachable/timeout) is ambiguous — the guard may
725
+ // have advanced before the response was lost. KEEP the intent: the
726
+ // reconciler resolves it on the next write/read (fresh verdict →
727
+ // roll forward; currentState at target → roll forward; genuine
728
+ // refusal → durable drop).
729
+ throw typedError('JUDGMENT_GUARD_UNAVAILABLE', `guard transition failed closed (intent ${intent.id} kept for replay): ${guard.error?.message ?? JSON.stringify(guard.error)}`, guard.error);
730
+ }
731
+ guardInfo = { verdict: guard.verdict ?? null, ledgerRef: guard.ledgerRef ?? null, currentState: guard.currentState ?? null };
732
+ }
733
+
734
+ const undo = new UndoLog();
735
+ commitWithProjections(cwd, undo, () => {
736
+ undo.note(store._jointPath(args.slug));
737
+ undo.note(store._ledgerPath());
738
+ for (const p of intent.payload.predictions) undo.note(store._predictionPath(p.id));
739
+ applyPayload(store, intent.payload);
740
+ });
741
+ store.clearIntent(intent.id);
742
+
743
+ const result = { applied: true, slug: args.slug, from, to: mutated.state, state: mutated.state, rank: mutated.rank };
744
+ if (guardInfo) result.guard = guardInfo;
745
+ return result;
746
+ },
747
+ });
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // judgment_ledger_append
752
+ // ---------------------------------------------------------------------------
753
+
754
+ const LEDGER_PASSTHROUGH_KEYS = [
755
+ 'kind', 'title', 'body', 'refs', 'rejected', 'conviction', 'trigger',
756
+ 'open_joints', 'prediction', 'disposition', 'recall_verdict', 'attribution',
757
+ 'prediction_ref', 'prediction_grade', 'reason', 'anchor', 'rank_change',
758
+ 'elicitation',
759
+ ];
760
+
761
+ function nextPredictionId(store) {
762
+ return `p-${store.listPredictions().length + 1}`;
763
+ }
764
+
765
+ /**
766
+ * Append a ledger event (kind-specific required fields enforced by the
767
+ * schema). Writer-internal side-effects: a commit-moment `decide` (kind
768
+ * decide + trigger) and a CONSTRUCT-disposition event spawn prediction
769
+ * records from their embedded prediction; a `postmortem` carrying
770
+ * prediction_ref + prediction_grade flips that prediction to graded.
771
+ */
772
+ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
773
+ return runOp(cwd, args, {
774
+ tool: 'judgment_ledger_append',
775
+ validate: () => {
776
+ const event = { provenance: stampProvenance(internal) };
777
+ for (const key of LEDGER_PASSTHROUGH_KEYS) {
778
+ if (args[key] !== undefined) event[key] = args[key];
779
+ }
780
+ assertValidRecord('ledger_event', event);
781
+ return event;
782
+ },
783
+ execute: (event) => {
784
+ const store = createJudgmentStore(cwd);
785
+
786
+ let spawned = null;
787
+ const isCommitDecide = event.kind === 'decide' && event.trigger !== undefined;
788
+ const isConstructDisposition = event.disposition === 'CONSTRUCT';
789
+ if ((isCommitDecide || isConstructDisposition) && event.prediction) {
790
+ spawned = {
791
+ id: nextPredictionId(store),
792
+ text: event.prediction.text,
793
+ outcome_criteria: event.prediction.outcome_criteria,
794
+ made_at: event.provenance.written_at,
795
+ context: isCommitDecide ? 'commit' : 'construct',
796
+ refs: event.refs ?? [],
797
+ status: 'open',
798
+ provenance: event.provenance,
799
+ };
800
+ assertValidRecord('prediction', spawned);
801
+ }
802
+
803
+ let graded = null;
804
+ if (event.kind === 'postmortem' && event.prediction_ref) {
805
+ const prediction = store.readPrediction(event.prediction_ref);
806
+ if (!prediction) throw typedError('JUDGMENT_NOT_FOUND', `prediction ${event.prediction_ref} does not exist`);
807
+ graded = { ...prediction, status: 'graded', grade: event.prediction_grade };
808
+ assertValidRecord('prediction', graded);
809
+ }
810
+
811
+ const undo = new UndoLog();
812
+ let seq;
813
+ commitWithProjections(cwd, undo, () => {
814
+ undo.note(store._ledgerPath());
815
+ if (spawned) undo.entries.push({ path: store._predictionPath(spawned.id), prior: null });
816
+ if (graded) undo.note(store._predictionPath(graded.id));
817
+ ({ seq } = store.appendLedgerEvent(event));
818
+ if (spawned) store.writePrediction(spawned);
819
+ if (graded) store.writePrediction(graded);
820
+ });
821
+
822
+ const result = { seq, kind: event.kind };
823
+ if (spawned) result.prediction_id = spawned.id;
824
+ if (graded) result.graded = graded.id;
825
+ return result;
826
+ },
827
+ });
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------
831
+ // get_judgment_state (read)
832
+ // ---------------------------------------------------------------------------
833
+
834
+ /**
835
+ * Session-load / P6-sweep read: register + under-test + open predictions +
836
+ * recent ledger. Small result (AUDIT-19) — titles and refs, never full
837
+ * document text. Replays pending intents first (reconciler-on-read).
838
+ */
839
+ export async function getJudgmentState(cwd) {
840
+ const release = await acquireJudgmentLock(cwd);
841
+ let replay;
842
+ try {
843
+ replay = await replayIntentsLocked(cwd);
844
+ } finally {
845
+ release();
846
+ }
847
+ const store = createJudgmentStore(cwd);
848
+ const positions = store.listPositionSlugs().map((slug) => {
849
+ const latest = store.latestPositionRevision(slug);
850
+ return {
851
+ slug,
852
+ ref: `${slug}#r${latest.rev}`,
853
+ status: store.derivePositionStatus(slug),
854
+ conviction: latest.conviction?.level ?? null,
855
+ };
856
+ });
857
+ const joints = store.listJoints().map((j) => ({
858
+ slug: j.slug,
859
+ state: j.state,
860
+ resolve_by: j.resolve_by,
861
+ cost: j.cost,
862
+ rank: j.rank,
863
+ }));
864
+ const openPredictions = store.listPredictions({ status: 'open' })
865
+ .map((p) => ({ id: p.id, text: p.text, context: p.context }));
866
+ const recentLedger = store.readLedgerEvents().slice(-10)
867
+ .map((e) => ({ kind: e.kind, title: e.title }));
868
+ return {
869
+ positions,
870
+ joints,
871
+ under_test: joints.filter((j) => j.state === 'under_test').map((j) => j.slug),
872
+ open_predictions: openPredictions,
873
+ recent_ledger: recentLedger,
874
+ intents_replayed: replay.replayed,
875
+ divergences: replay.divergences,
876
+ };
877
+ }