@smartmemory/compose 0.3.5-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.
- package/bin/judgment-import.js +565 -0
- package/contracts/judgment-record.schema.json +400 -0
- package/dist/assets/{App-BG92y_CY.js → App-BG3ngu8H.js} +92 -92
- package/dist/assets/{abnfDiagram-VRR7QNED-Bip6cMjP.js → abnfDiagram-VRR7QNED-CjB_sD3D.js} +1 -1
- package/dist/assets/{arc-CyMTM07c.js → arc-_v4hR_uD.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-BukV0CJw.js → architectureDiagram-ZJ3FMSHR-DreJmzXQ.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-Bjb_MNB4.js → blockDiagram-677ZJIJ3-BG9-c0O1.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-CUmTCrMW.js → c4Diagram-LMCZKHZV-DZf45Fbz.js} +1 -1
- package/dist/assets/channel-B3U5wFAT.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-2Q5K7J3B-Dn1spZYu.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.js → chunk-32BRIVSS-pURGrJDk.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-5VM5RSS4-BY4_PV5H.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-G_MZEji_.js → chunk-EX3LRPZG-DdELs1qP.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-JWPE2WC7-_7ujgd_Q.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CQZQYMcz.js → chunk-MOJQB5TN-D-ky35G-.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-CmAda4sZ.js → chunk-RYQCIY6F-Dag_kVlO.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-CbjBz-Ek.js → chunk-V7JOEXUC-BtewURat.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-VR4S4FIN-Dt9NZ67m.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-XXDRQBXY-DfdVhbmA.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-G8l05tXy.js → cose-bilkent-JH36ORCC-BieYif4o.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-C3n1PNJC.js → cynefin-VYW2F7L2-CT2BA6KE.js} +1 -1
- package/dist/assets/{cynefinDiagram-TSTJHNR4-C8N_CvKJ.js → cynefinDiagram-TSTJHNR4-Bh6exbyg.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-B2pkwDhN.js → dagre-VKFMJZFB-aXMLSmQL.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-DPHD_t-1.js → diagram-FQU43EPY-Dr7JAOuQ.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-DGmHIZRr.js → diagram-G47NLZAW-DUvA3FQK.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-BlLWwKVt.js → diagram-NH7WQ7WH-BQUARqcu.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DEv704Re.js → diagram-OA4YK3LP-dDUc1zHi.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-Bj6PGUCe.js → diagram-WEI45ONY-B2h5Qlb1.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-Ch-icksT.js → ebnfDiagram-CCIWWBDH-DThRGupB.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-CI3lwEFA.js → erDiagram-Q63AITRT-BUCsprO2.js} +1 -1
- package/dist/assets/{flowDiagram-23GEKE2U-DcUANpAz.js → flowDiagram-23GEKE2U-DXtNNi6r.js} +1 -1
- package/dist/assets/{ganttDiagram-NO4QXBWP-Cy0Ie8KS.js → ganttDiagram-NO4QXBWP-D4zbBHh_.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BswI52az.js → gitGraphDiagram-IHSO6WYX-DpoQws0W.js} +1 -1
- package/dist/assets/{index-B1H7-G2y.js → index-3ZH5eMcZ.js} +2 -2
- package/dist/assets/{infoDiagram-FWYZ7A6U-BBunqvr-.js → infoDiagram-FWYZ7A6U-Bbas2GAo.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-Hm4zSDlM.js → ishikawaDiagram-FXEZZL3T-CzEB9fQS.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-5HDEW3XC-Bz8TCdz2.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.js → kanban-definition-HUTT4EX6-tozrMoV_.js} +1 -1
- package/dist/assets/{linear-CsXqv70E.js → linear-Ck7gpa5N.js} +1 -1
- package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.js → mindmap-definition-LN4V7U3C-DTcHO0DJ.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-HpQyrDcm.js → pegDiagram-2B236MQR-CHiINrNy.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-xkcpHwnY.js → pieDiagram-ENE6RG2P-CfS4YFlR.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-kebZIOmu.js → quadrantDiagram-ABIIQ3AL-CadesS9w.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-DNvGwWm9.js → railroadDiagram-RFXS5EU6-CgWEspBN.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-D1lG1P7h.js → requirementDiagram-TGXJPOKE-bnI2zJeT.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-DOEEcn52.js → sankeyDiagram-HTMAVEWB-YWKFgOGw.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-DynIVCk_.js → sequenceDiagram-DBY2YBRQ-BvkNOyF9.js} +1 -1
- package/dist/assets/{sizeCapture-X5ZJPWSS-DJXFjUUL.js → sizeCapture-X5ZJPWSS-DlFPA2yO.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-OZ7trFh5.js → stateDiagram-2N3HPSRC-h8NIx0kQ.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-DULYAFmB.js → swimlanes-5IMT3BWC-CT5n22kG.js} +2 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-CzqqUDvt.js → timeline-definition-FHXFAJF6-D267GQFF.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-CdKmtcFL.js → vennDiagram-L72KCM5P-Dj-wWLYG.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-DknGwjCA.js → wardleyDiagram-EHGQE667-BxCeYxkG.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-DdYyKfQ3.js → xychartDiagram-FW5EYKEG-DMFqWn7z.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/idempotency.js +33 -8
- package/lib/judgment/schema.js +23 -0
- package/lib/judgment/store/index.js +49 -0
- package/lib/judgment/store/records.js +246 -0
- package/lib/judgment-gen.js +342 -0
- package/lib/judgment-write-guard.js +230 -0
- package/lib/judgment-writer.js +877 -0
- package/lib/lifecycle-modes.js +42 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +34 -0
- package/server/compose-mcp.js +125 -0
- package/server/mcp-tool-policy.js +1 -0
- package/dist/assets/channel-BzkmtaLy.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
- 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
|
+
}
|