@lcv-ideas-software/cross-review 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +2568 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +26 -0
  4. package/README.md +208 -0
  5. package/SECURITY.md +52 -0
  6. package/dist/scripts/api-streaming-smoke.d.ts +1 -0
  7. package/dist/scripts/api-streaming-smoke.js +78 -0
  8. package/dist/scripts/api-streaming-smoke.js.map +1 -0
  9. package/dist/scripts/runtime-default-smoke.d.ts +1 -0
  10. package/dist/scripts/runtime-default-smoke.js +88 -0
  11. package/dist/scripts/runtime-default-smoke.js.map +1 -0
  12. package/dist/scripts/runtime-smoke.d.ts +1 -0
  13. package/dist/scripts/runtime-smoke.js +148 -0
  14. package/dist/scripts/runtime-smoke.js.map +1 -0
  15. package/dist/scripts/smoke.d.ts +1 -0
  16. package/dist/scripts/smoke.js +6156 -0
  17. package/dist/scripts/smoke.js.map +1 -0
  18. package/dist/src/core/cache-manifest.d.ts +22 -0
  19. package/dist/src/core/cache-manifest.js +133 -0
  20. package/dist/src/core/cache-manifest.js.map +1 -0
  21. package/dist/src/core/caller-tokens.d.ts +32 -0
  22. package/dist/src/core/caller-tokens.js +240 -0
  23. package/dist/src/core/caller-tokens.js.map +1 -0
  24. package/dist/src/core/config.d.ts +9 -0
  25. package/dist/src/core/config.js +643 -0
  26. package/dist/src/core/config.js.map +1 -0
  27. package/dist/src/core/convergence.d.ts +5 -0
  28. package/dist/src/core/convergence.js +186 -0
  29. package/dist/src/core/convergence.js.map +1 -0
  30. package/dist/src/core/cost.d.ts +59 -0
  31. package/dist/src/core/cost.js +359 -0
  32. package/dist/src/core/cost.js.map +1 -0
  33. package/dist/src/core/file-config.d.ts +316 -0
  34. package/dist/src/core/file-config.js +490 -0
  35. package/dist/src/core/file-config.js.map +1 -0
  36. package/dist/src/core/orchestrator.d.ts +199 -0
  37. package/dist/src/core/orchestrator.js +3430 -0
  38. package/dist/src/core/orchestrator.js.map +1 -0
  39. package/dist/src/core/prompt-parts.d.ts +58 -0
  40. package/dist/src/core/prompt-parts.js +122 -0
  41. package/dist/src/core/prompt-parts.js.map +1 -0
  42. package/dist/src/core/relator-lottery.d.ts +23 -0
  43. package/dist/src/core/relator-lottery.js +112 -0
  44. package/dist/src/core/relator-lottery.js.map +1 -0
  45. package/dist/src/core/reports.d.ts +2 -0
  46. package/dist/src/core/reports.js +82 -0
  47. package/dist/src/core/reports.js.map +1 -0
  48. package/dist/src/core/session-store.d.ts +149 -0
  49. package/dist/src/core/session-store.js +1923 -0
  50. package/dist/src/core/session-store.js.map +1 -0
  51. package/dist/src/core/status.d.ts +61 -0
  52. package/dist/src/core/status.js +249 -0
  53. package/dist/src/core/status.js.map +1 -0
  54. package/dist/src/core/timeouts.d.ts +2 -0
  55. package/dist/src/core/timeouts.js +3 -0
  56. package/dist/src/core/timeouts.js.map +1 -0
  57. package/dist/src/core/types.d.ts +604 -0
  58. package/dist/src/core/types.js +36 -0
  59. package/dist/src/core/types.js.map +1 -0
  60. package/dist/src/dashboard/server.d.ts +2 -0
  61. package/dist/src/dashboard/server.js +339 -0
  62. package/dist/src/dashboard/server.js.map +1 -0
  63. package/dist/src/mcp/server.d.ts +54 -0
  64. package/dist/src/mcp/server.js +1584 -0
  65. package/dist/src/mcp/server.js.map +1 -0
  66. package/dist/src/observability/logger.d.ts +9 -0
  67. package/dist/src/observability/logger.js +24 -0
  68. package/dist/src/observability/logger.js.map +1 -0
  69. package/dist/src/peers/anthropic.d.ts +14 -0
  70. package/dist/src/peers/anthropic.js +290 -0
  71. package/dist/src/peers/anthropic.js.map +1 -0
  72. package/dist/src/peers/base.d.ts +72 -0
  73. package/dist/src/peers/base.js +416 -0
  74. package/dist/src/peers/base.js.map +1 -0
  75. package/dist/src/peers/deepseek.d.ts +12 -0
  76. package/dist/src/peers/deepseek.js +246 -0
  77. package/dist/src/peers/deepseek.js.map +1 -0
  78. package/dist/src/peers/errors.d.ts +2 -0
  79. package/dist/src/peers/errors.js +185 -0
  80. package/dist/src/peers/errors.js.map +1 -0
  81. package/dist/src/peers/gemini.d.ts +13 -0
  82. package/dist/src/peers/gemini.js +215 -0
  83. package/dist/src/peers/gemini.js.map +1 -0
  84. package/dist/src/peers/grok.d.ts +17 -0
  85. package/dist/src/peers/grok.js +346 -0
  86. package/dist/src/peers/grok.js.map +1 -0
  87. package/dist/src/peers/model-selection.d.ts +4 -0
  88. package/dist/src/peers/model-selection.js +260 -0
  89. package/dist/src/peers/model-selection.js.map +1 -0
  90. package/dist/src/peers/openai.d.ts +14 -0
  91. package/dist/src/peers/openai.js +299 -0
  92. package/dist/src/peers/openai.js.map +1 -0
  93. package/dist/src/peers/perplexity.d.ts +18 -0
  94. package/dist/src/peers/perplexity.js +375 -0
  95. package/dist/src/peers/perplexity.js.map +1 -0
  96. package/dist/src/peers/registry.d.ts +3 -0
  97. package/dist/src/peers/registry.js +77 -0
  98. package/dist/src/peers/registry.js.map +1 -0
  99. package/dist/src/peers/retry.d.ts +2 -0
  100. package/dist/src/peers/retry.js +36 -0
  101. package/dist/src/peers/retry.js.map +1 -0
  102. package/dist/src/peers/stub.d.ts +13 -0
  103. package/dist/src/peers/stub.js +344 -0
  104. package/dist/src/peers/stub.js.map +1 -0
  105. package/dist/src/peers/text.d.ts +18 -0
  106. package/dist/src/peers/text.js +39 -0
  107. package/dist/src/peers/text.js.map +1 -0
  108. package/dist/src/security/redact.d.ts +2 -0
  109. package/dist/src/security/redact.js +128 -0
  110. package/dist/src/security/redact.js.map +1 -0
  111. package/docs/api-keys.md +34 -0
  112. package/docs/architecture.md +118 -0
  113. package/docs/caching.md +135 -0
  114. package/docs/costs.md +40 -0
  115. package/docs/evidence-preflight.md +88 -0
  116. package/docs/github-security-baseline.md +32 -0
  117. package/docs/model-selection.md +105 -0
  118. package/docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md +354 -0
  119. package/docs/reports/cross-review-v2-format-recovery-findings-2026-04-28.md +223 -0
  120. package/docs/reports/cross-review-v2-official-provider-docs-refresh-2026-05-05.md +60 -0
  121. package/docs/reports/cross-review-v2-token-streaming-smoke-2026-04-30.md +119 -0
  122. package/package.json +88 -0
@@ -0,0 +1,1923 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { PEERS } from "./types.js";
5
+ import { mergeCost, mergeUsage } from "./cost.js";
6
+ import { redact } from "../security/redact.js";
7
+ export const SWEEP_MIN_IDLE_MS = 24 * 60 * 60 * 1000;
8
+ function now() {
9
+ return new Date().toISOString();
10
+ }
11
+ // v2.4.0 / audit closure (P1.3): atomicWriteFile retry on Windows.
12
+ // `fs.renameSync` in Win32 fails with EPERM/EACCES/EBUSY when the
13
+ // destination is briefly held by another handle (AV scan, indexing,
14
+ // concurrent reader). Pre-v2.4.0 the rename threw and left the .tmp
15
+ // orphaned in the session directory. Now we (a) try rename, (b) on
16
+ // transient EPERM/EACCES/EBUSY/EEXIST retry up to 5 times with short
17
+ // backoff, (c) on terminal failure clean up the tmp file ourselves so
18
+ // the session directory does not accumulate `*.tmp` artifacts, (d)
19
+ // re-throw the last error so the caller still observes the failure.
20
+ // Mirrors the v1.6.7 P1.2 fix.
21
+ const ATOMIC_WRITE_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY", "EEXIST"]);
22
+ const ATOMIC_WRITE_MAX_ATTEMPTS = 5;
23
+ const TMP_NONCE_BYTES = 2;
24
+ function writeJson(file, data) {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true });
26
+ const nonce = crypto.randomBytes(TMP_NONCE_BYTES).toString("hex");
27
+ const tmp = `${file}.${process.pid}.${Date.now()}.${nonce}.tmp`;
28
+ fs.writeFileSync(tmp, redact(`${JSON.stringify(data, null, 2)}\n`), "utf8");
29
+ let lastErr = null;
30
+ for (let attempt = 0; attempt < ATOMIC_WRITE_MAX_ATTEMPTS; attempt += 1) {
31
+ try {
32
+ fs.renameSync(tmp, file);
33
+ return;
34
+ }
35
+ catch (err) {
36
+ lastErr = err;
37
+ const code = err.code;
38
+ if (!code || !ATOMIC_WRITE_RETRY_CODES.has(code))
39
+ break;
40
+ const wait = 10 * 2 ** attempt; // 10, 20, 40, 80, 160 ms
41
+ const start = Date.now();
42
+ while (Date.now() - start < wait) {
43
+ /* spin — sync write path, brief by design */
44
+ }
45
+ }
46
+ }
47
+ // Terminal failure path: best-effort tmp cleanup so callers don't see
48
+ // the orphan accumulate even when the write itself failed.
49
+ try {
50
+ fs.unlinkSync(tmp);
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ throw lastErr;
56
+ }
57
+ // v2.4.0 / audit closure (P1.3 companion): boot sweep of orphan .tmp files.
58
+ // Crashes inside writeJson (between writeFileSync and renameSync) leave
59
+ // files matching `<basename>.<pid>.<ts>.<nonce>.tmp` in the session
60
+ // directory. They are never read but should not accumulate. Walk every
61
+ // session dir at boot, drop files matching the .tmp pattern whose holder
62
+ // pid is dead OR whose timestamp is older than 1h. Idempotent +
63
+ // best-effort.
64
+ const TMP_FILE_PATTERN = /\.(\d+)\.(\d+)\.[0-9a-f]+\.tmp$/;
65
+ const TMP_STALE_AFTER_MS = 60 * 60 * 1000; // 1h
66
+ function readJson(file) {
67
+ // v2.4.0 / audit closure: contextualize JSON.parse failures so callers see
68
+ // which file is malformed rather than a bare SyntaxError. Read errors
69
+ // still propagate naturally (ENOENT, EACCES) so caller can branch.
70
+ const raw = fs.readFileSync(file, "utf8");
71
+ try {
72
+ return JSON.parse(raw);
73
+ }
74
+ catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ throw new Error(`failed to parse JSON at ${file}: ${message}`, { cause: err });
77
+ }
78
+ }
79
+ function safeFilePart(value) {
80
+ const normalized = value
81
+ .normalize("NFKD")
82
+ .replace(/[^\w.-]+/g, "-")
83
+ .replace(/^-+|-+$/g, "")
84
+ .slice(0, 80);
85
+ return normalized || "evidence";
86
+ }
87
+ function timestampFilePart() {
88
+ return now().replace(/[:.]/g, "-");
89
+ }
90
+ export class SessionStore {
91
+ config;
92
+ // v2.4.0 / audit closure (P3.13): in-memory monotonic seq counter per
93
+ // session. Pre-v2.4.0 appendEvent recomputed seq by reading the events
94
+ // file, splitting on newlines and counting non-empty lines — that race
95
+ // remained even inside withSessionLock because two emit calls within
96
+ // the same process could compute identical seqs if the OS write returned
97
+ // before the next read. The cache below is initialized on first use
98
+ // (lazy) by reading the existing file ONCE and is incremented strictly
99
+ // monotonically thereafter. Restart re-initializes from disk, so seq
100
+ // remains correct across process boundaries.
101
+ seqCache = new Map();
102
+ constructor(config) {
103
+ this.config = config;
104
+ fs.mkdirSync(this.sessionsDir(), { recursive: true });
105
+ }
106
+ sessionsDir() {
107
+ return path.join(this.config.data_dir, "sessions");
108
+ }
109
+ sessionDir(sessionId) {
110
+ this.assertSessionId(sessionId);
111
+ const sessionsRoot = fs.realpathSync(this.sessionsDir());
112
+ const candidate = path.resolve(sessionsRoot, sessionId);
113
+ const containedCandidate = fs.existsSync(candidate) ? fs.realpathSync(candidate) : candidate;
114
+ if (!this.isPathContained(sessionsRoot, containedCandidate)) {
115
+ throw new Error(`session path escapes data directory: ${sessionId}`);
116
+ }
117
+ return containedCandidate;
118
+ }
119
+ metaPath(sessionId) {
120
+ return path.join(this.sessionDir(sessionId), "meta.json");
121
+ }
122
+ eventsPath(sessionId) {
123
+ return path.join(this.sessionDir(sessionId), "events.ndjson");
124
+ }
125
+ assertSessionId(sessionId) {
126
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(sessionId)) {
127
+ throw new Error(`invalid session_id: ${sessionId}`);
128
+ }
129
+ }
130
+ isPathContained(parent, target) {
131
+ const relative = path.relative(parent, target);
132
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
133
+ }
134
+ processAlive(pid) {
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ sleepSync(ms) {
144
+ const buffer = new SharedArrayBuffer(4);
145
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
146
+ }
147
+ totalsFor(meta) {
148
+ const peerResults = meta.rounds.flatMap((round) => round.peers);
149
+ const generations = meta.generation_files ?? [];
150
+ return {
151
+ usage: mergeUsage([
152
+ ...peerResults.map((peer) => peer.usage),
153
+ ...generations.map((generation) => generation.usage),
154
+ ]),
155
+ cost: mergeCost([
156
+ ...peerResults.map((peer) => peer.cost),
157
+ ...generations.map((generation) => generation.cost),
158
+ ]),
159
+ };
160
+ }
161
+ withSessionLock(sessionId, fn) {
162
+ const dir = this.sessionDir(sessionId);
163
+ const lockPath = path.join(dir, ".lock");
164
+ const timeoutAt = Date.now() + 30_000;
165
+ while (true) {
166
+ try {
167
+ const fd = fs.openSync(lockPath, "wx");
168
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquired_at: now() }));
169
+ fs.closeSync(fd);
170
+ break;
171
+ }
172
+ catch (error) {
173
+ if (error.code !== "EEXIST")
174
+ throw error;
175
+ try {
176
+ const lock = readJson(lockPath);
177
+ const age = lock.acquired_at ? Date.now() - Date.parse(lock.acquired_at) : Infinity;
178
+ if (!lock.pid || age > 120_000 || !this.processAlive(lock.pid)) {
179
+ fs.rmSync(lockPath, { force: true });
180
+ continue;
181
+ }
182
+ }
183
+ catch {
184
+ fs.rmSync(lockPath, { force: true });
185
+ continue;
186
+ }
187
+ if (Date.now() >= timeoutAt) {
188
+ throw new Error(`timed out waiting for session lock: ${sessionId}`, { cause: error });
189
+ }
190
+ this.sleepSync(100);
191
+ }
192
+ }
193
+ try {
194
+ return fn();
195
+ }
196
+ finally {
197
+ fs.rmSync(lockPath, { force: true });
198
+ }
199
+ }
200
+ init(task, caller, snapshot, reviewFocus) {
201
+ const session_id = crypto.randomUUID();
202
+ // v2.22.0 (B.P3): snapshot the cost ceiling at session_init time so
203
+ // budget pressure analysis is decoupled from later env-var mutation.
204
+ // null when the operator runs without a session-level cost cap.
205
+ const ceiling = this.config.budget.max_session_cost_usd;
206
+ const meta = {
207
+ session_id,
208
+ version: this.config.version,
209
+ created_at: now(),
210
+ updated_at: now(),
211
+ task,
212
+ ...(reviewFocus ? { review_focus: reviewFocus } : {}),
213
+ caller,
214
+ capability_snapshot: snapshot,
215
+ convergence_health: {
216
+ state: "idle",
217
+ last_event_at: now(),
218
+ detail: "Session initialized.",
219
+ },
220
+ rounds: [],
221
+ totals: {
222
+ usage: {},
223
+ cost: { currency: "USD", estimated: false, source: "unknown-rate" },
224
+ },
225
+ cost_ceiling_usd: typeof ceiling === "number" && ceiling > 0 ? ceiling : null,
226
+ costs_per_round: [],
227
+ budget_warning_emitted: false,
228
+ };
229
+ fs.mkdirSync(path.join(this.sessionDir(session_id), "agent-runs"), { recursive: true });
230
+ writeJson(this.metaPath(session_id), meta);
231
+ fs.writeFileSync(path.join(this.sessionDir(session_id), "task.md"), task, "utf8");
232
+ if (reviewFocus) {
233
+ fs.writeFileSync(path.join(this.sessionDir(session_id), "review-focus.md"), reviewFocus, "utf8");
234
+ }
235
+ return meta;
236
+ }
237
+ // v2.4.0 / cross-review R5 (codex blocker): refuse to overwrite an
238
+ // existing in_flight when starting a new round. Pre-R5 markInFlight
239
+ // unconditionally clobbered `meta.in_flight`, so a second concurrent
240
+ // ask_peers on the same session would silently steamroll the first
241
+ // round's state — and the format-recovery quota counter would race
242
+ // because both calls could read the same `recoveriesAlready` baseline.
243
+ // R5 throws when in_flight is already populated; the boot-time
244
+ // `clearStaleInFlight` sweep clears any orphan in_flight from a
245
+ // crashed prior host so legitimate operators are not blocked.
246
+ markInFlight(sessionId, params) {
247
+ return this.withSessionLock(sessionId, () => {
248
+ const meta = this.read(sessionId);
249
+ if (meta.in_flight) {
250
+ throw new Error(`session ${sessionId} already has an in-flight round (round=${meta.in_flight.round}, started_at=${meta.in_flight.started_at}); refusing to start a concurrent round. Wait for the round to complete, cancel it via session_cancel_job, or recover it via session_recover_interrupted.`);
251
+ }
252
+ meta.in_flight = {
253
+ round: params.round,
254
+ peers: params.peers,
255
+ started_at: params.started_at,
256
+ status: "running",
257
+ };
258
+ meta.convergence_scope = params.scope;
259
+ meta.convergence_health = {
260
+ state: "running",
261
+ last_event_at: now(),
262
+ detail: `Round ${params.round} is running.`,
263
+ };
264
+ meta.updated_at = now();
265
+ writeJson(this.metaPath(sessionId), meta);
266
+ return meta;
267
+ });
268
+ }
269
+ read(sessionId) {
270
+ return readJson(this.metaPath(sessionId));
271
+ }
272
+ // v2.4.0 / audit closure (P3.13) — refined after cross-review R2 (codex
273
+ // caught a durability gap in the initial implementation).
274
+ //
275
+ // Pre-R2: the cache was incremented BEFORE appendFileSync. If the
276
+ // append failed (ENOSPC, EACCES, write-error mid-call) the cache held
277
+ // an already-handed-out seq number that nothing on disk consumed —
278
+ // and a subsequent successful append would reuse the same disk byte
279
+ // for a different event, while the cache produced seq+1. After
280
+ // process restart the cache rebuild re-counted lines and produced a
281
+ // duplicate seq.
282
+ //
283
+ // R2 (codex): the cache is updated ONLY after the appendFileSync
284
+ // returns. If append throws, the cache is unchanged so the next call
285
+ // reuses the same intended seq (no gap, no duplicate). On restart
286
+ // the cache rebuild reflects on-disk reality. The lazy load uses
287
+ // line count of the existing file as a reasonable approximation of
288
+ // the durable max-seq.
289
+ peekNextSeq(sessionId, file) {
290
+ let cached = this.seqCache.get(sessionId);
291
+ if (cached === undefined) {
292
+ try {
293
+ cached = fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).length;
294
+ }
295
+ catch (error) {
296
+ if (error.code !== "ENOENT")
297
+ throw error;
298
+ cached = 0;
299
+ }
300
+ this.seqCache.set(sessionId, cached);
301
+ }
302
+ return cached + 1;
303
+ }
304
+ commitSeq(sessionId, committed) {
305
+ this.seqCache.set(sessionId, committed);
306
+ }
307
+ appendEvent(event) {
308
+ const sessionId = event.session_id;
309
+ if (!sessionId)
310
+ return;
311
+ try {
312
+ this.withSessionLock(sessionId, () => {
313
+ const file = this.eventsPath(sessionId);
314
+ const seq = this.peekNextSeq(sessionId, file);
315
+ fs.appendFileSync(file, `${JSON.stringify({ ...event, seq, ts: event.ts ?? now() })}\n`, "utf8");
316
+ // Only commit the cache AFTER the durable append succeeded.
317
+ // If appendFileSync threw above, the cache still reflects the
318
+ // last persisted seq and the next call reuses this seq number.
319
+ this.commitSeq(sessionId, seq);
320
+ });
321
+ }
322
+ catch {
323
+ // Event persistence must never break provider calls or MCP responses.
324
+ }
325
+ }
326
+ readEvents(sessionId, sinceSeq = 0) {
327
+ const file = this.eventsPath(sessionId);
328
+ if (!fs.existsSync(file))
329
+ return [];
330
+ return fs
331
+ .readFileSync(file, "utf8")
332
+ .split(/\r?\n/)
333
+ .filter(Boolean)
334
+ .map((line, index) => ({ seq: index + 1, ...JSON.parse(line) }))
335
+ .filter((event) => event.seq > sinceSeq);
336
+ }
337
+ // v2.27.0: corrupted meta.json files are silently skipped + quarantined to
338
+ // `<session_dir>/meta.json.bad` so subsequent startup sweeps do not re-throw.
339
+ // Empirically demonstrated by 3 sessions corrupted by the v2.25.1 redact
340
+ // escape-boundary bug (77c47284, be47a5b0, 7edf63e3) that caused parse
341
+ // errors on every Claude Code reload until manually deleted 2026-05-12.
342
+ list() {
343
+ if (!fs.existsSync(this.sessionsDir()))
344
+ return [];
345
+ const entries = fs.readdirSync(this.sessionsDir(), { withFileTypes: true });
346
+ const metas = [];
347
+ for (const entry of entries) {
348
+ if (!entry.isDirectory())
349
+ continue;
350
+ const sessionDir = path.join(this.sessionsDir(), entry.name);
351
+ const file = path.join(sessionDir, "meta.json");
352
+ if (!fs.existsSync(file))
353
+ continue;
354
+ try {
355
+ metas.push(readJson(file));
356
+ }
357
+ catch (err) {
358
+ const message = err instanceof Error ? err.message : String(err);
359
+ const quarantine = path.join(sessionDir, "meta.json.bad");
360
+ try {
361
+ if (!fs.existsSync(quarantine)) {
362
+ fs.renameSync(file, quarantine);
363
+ console.error(`[cross-review] quarantined corrupted meta.json at ${file} -> ${quarantine} (${message})`);
364
+ }
365
+ }
366
+ catch {
367
+ /* best-effort */
368
+ }
369
+ }
370
+ }
371
+ return metas.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
372
+ }
373
+ // v2.27.0: prune finalized sessions older than `maxAgeDays` days. Default
374
+ // 60 days (configurable via CROSS_REVIEW_PRUNE_AFTER_DAYS env var or
375
+ // explicit arg). Only removes sessions whose outcome is terminal (converged
376
+ // | aborted | max-rounds) AND whose updated_at is older than the cutoff.
377
+ // In-flight or untyped-outcome sessions are never pruned. Idempotent +
378
+ // best-effort. Empirically motivated by 534 sessions accumulated on disk
379
+ // by 2026-05-12 inflating cold-start sweep cost.
380
+ pruneOldSessions(maxAgeDays) {
381
+ const envDays = Number.parseFloat(process.env.CROSS_REVIEW_PRUNE_AFTER_DAYS ?? "");
382
+ const days = maxAgeDays != null && maxAgeDays > 0
383
+ ? maxAgeDays
384
+ : Number.isFinite(envDays) && envDays > 0
385
+ ? envDays
386
+ : 60;
387
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
388
+ let scanned = 0;
389
+ let pruned = 0;
390
+ for (const session of this.list()) {
391
+ scanned += 1;
392
+ if (!session.outcome)
393
+ continue;
394
+ const lastTouched = Date.parse(session.updated_at);
395
+ if (!Number.isFinite(lastTouched) || lastTouched >= cutoffMs)
396
+ continue;
397
+ const dir = this.sessionDir(session.session_id);
398
+ try {
399
+ fs.rmSync(dir, { recursive: true, force: true });
400
+ pruned += 1;
401
+ }
402
+ catch {
403
+ /* best-effort */
404
+ }
405
+ }
406
+ return { scanned, pruned };
407
+ }
408
+ savePrompt(sessionId, round, prompt) {
409
+ const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-prompt.md`);
410
+ fs.writeFileSync(file, redact(prompt), "utf8");
411
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
412
+ }
413
+ saveDraft(sessionId, round, draft) {
414
+ const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-draft.md`);
415
+ fs.writeFileSync(file, redact(draft), "utf8");
416
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
417
+ }
418
+ saveGeneration(sessionId, round, result, label = "generation") {
419
+ const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${result.peer}-${label}.json`);
420
+ writeJson(file, { ...result, text: redact(result.text) });
421
+ const relativePath = path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
422
+ this.withSessionLock(sessionId, () => {
423
+ const meta = this.read(sessionId);
424
+ const artifact = {
425
+ ts: now(),
426
+ round,
427
+ label,
428
+ peer: result.peer,
429
+ path: relativePath,
430
+ usage: result.usage,
431
+ cost: result.cost,
432
+ latency_ms: result.latency_ms,
433
+ };
434
+ meta.generation_files = [...(meta.generation_files ?? []), artifact];
435
+ meta.totals = this.totalsFor(meta);
436
+ meta.updated_at = now();
437
+ writeJson(this.metaPath(sessionId), meta);
438
+ });
439
+ return relativePath;
440
+ }
441
+ saveFinal(sessionId, text) {
442
+ const file = path.join(this.sessionDir(sessionId), "final.md");
443
+ fs.writeFileSync(file, redact(text), "utf8");
444
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
445
+ }
446
+ saveReport(sessionId, text) {
447
+ const file = path.join(this.sessionDir(sessionId), "session-report.md");
448
+ fs.writeFileSync(file, redact(text), "utf8");
449
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
450
+ }
451
+ savePeerResult(sessionId, round, result, label = "response") {
452
+ const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${result.peer}-${label}.json`);
453
+ writeJson(file, { ...result, text: redact(result.text) });
454
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
455
+ }
456
+ savePeerFailure(sessionId, round, failure) {
457
+ const file = path.join(this.sessionDir(sessionId), "agent-runs", `round-${round}-${failure.peer}-failure.json`);
458
+ writeJson(file, { ...failure, message: redact(failure.message) });
459
+ return path.relative(this.sessionDir(sessionId), file).replace(/\\/g, "/");
460
+ }
461
+ appendRound(sessionId, params) {
462
+ return this.withSessionLock(sessionId, () => {
463
+ const meta = this.read(sessionId);
464
+ // v3.2.0 (Codex bug report 2026-05-12): refuse to append a round
465
+ // to a finalized session. Otherwise the per-round
466
+ // `convergence_health` write below would clobber the converged
467
+ // health set by `finalize()`, producing the contradictory
468
+ // `outcome=converged / health=blocked` state observed in session
469
+ // 41244a1c (R6 ran after a `session_finalize` call corrupted the
470
+ // meta — but the orchestrator path can also produce this if any
471
+ // post-finalize round mutator slips through).
472
+ if (meta.outcome) {
473
+ const err = new Error(`session_already_finalized: cannot append round to session ${sessionId} (outcome="${meta.outcome}")`);
474
+ err.code = "session_already_finalized";
475
+ throw err;
476
+ }
477
+ const round = {
478
+ round: meta.rounds.length + 1,
479
+ started_at: params.started_at,
480
+ completed_at: now(),
481
+ caller_status: params.caller_status,
482
+ draft_file: params.draft_file,
483
+ prompt_file: params.prompt_file,
484
+ peers: params.peers,
485
+ rejected: params.rejected,
486
+ convergence: params.convergence,
487
+ };
488
+ meta.rounds.push(round);
489
+ meta.failed_attempts = [
490
+ ...(meta.failed_attempts ?? []),
491
+ ...params.rejected.map((failure) => ({ ...failure, round: round.round })),
492
+ ];
493
+ delete meta.in_flight;
494
+ meta.convergence_scope = params.convergence_scope;
495
+ meta.convergence_health = {
496
+ state: params.convergence.converged ? "converged" : "blocked",
497
+ last_event_at: now(),
498
+ detail: params.convergence.reason,
499
+ };
500
+ meta.updated_at = now();
501
+ meta.totals = this.totalsFor(meta);
502
+ // v2.22.0 (B.P3): append per-round cost. Sum of peer.cost.total_cost
503
+ // across this round's peers. Coerced to 0 when adapters didn't
504
+ // surface a cost (stub paths, error rounds). Read AFTER totalsFor
505
+ // so the new round's peer costs are already counted by the merger,
506
+ // but we recompute the round-local sum independently to avoid
507
+ // diff-based drift if a peer's cost changed in a retry loop.
508
+ const roundCost = params.peers.reduce((sum, peer) => sum + (peer.cost?.total_cost ?? 0), 0);
509
+ meta.costs_per_round = [...(meta.costs_per_round ?? []), roundCost];
510
+ writeJson(this.metaPath(sessionId), meta);
511
+ return round;
512
+ });
513
+ }
514
+ // v2.22.0 (B.P3): one-shot guard for `session.budget_warning` emit
515
+ // idempotency. Persisted in meta.json so the warning fires at most
516
+ // once per session even across host restarts.
517
+ markBudgetWarningEmitted(sessionId) {
518
+ return this.withSessionLock(sessionId, () => {
519
+ const meta = this.read(sessionId);
520
+ meta.budget_warning_emitted = true;
521
+ meta.updated_at = now();
522
+ writeJson(this.metaPath(sessionId), meta);
523
+ return meta;
524
+ });
525
+ }
526
+ // v2.25.0 (circular mode): atomically replace meta.circular_state. The
527
+ // orchestrator's circular loop calls this every round so resumed
528
+ // sessions can pick up the rotation cursor and consecutive-no-change
529
+ // count from disk without re-deriving them by walking events.
530
+ setCircularState(sessionId, state) {
531
+ return this.withSessionLock(sessionId, () => {
532
+ const meta = this.read(sessionId);
533
+ meta.circular_state = state;
534
+ meta.updated_at = now();
535
+ writeJson(this.metaPath(sessionId), meta);
536
+ return meta;
537
+ });
538
+ }
539
+ // v3.5.0 (CRV2-1 + CRV2-6, Codex operational report): persist
540
+ // requested-vs-effective budget + max_rounds traceability once at the
541
+ // start of a run. Pre-v3.5.0 the durable record only had
542
+ // `cost_ceiling_usd` (always the effective value) and nothing for
543
+ // max_rounds — so retroactive analysis could not tell whether a
544
+ // ceiling came from a per-call arg or a config default, nor what
545
+ // max_rounds the caller actually requested. This fills that gap with
546
+ // pure-additive metadata; `cost_ceiling_usd` is kept in sync with
547
+ // `effective_cost_ceiling_usd` for back-compat with v3.4.x readers.
548
+ setSessionTraceability(sessionId, traceability) {
549
+ return this.withSessionLock(sessionId, () => {
550
+ const meta = this.read(sessionId);
551
+ meta.requested_max_rounds = traceability.requested_max_rounds;
552
+ meta.effective_max_rounds = traceability.effective_max_rounds;
553
+ meta.requested_max_cost_usd = traceability.requested_max_cost_usd;
554
+ meta.effective_cost_ceiling_usd = traceability.effective_cost_ceiling_usd;
555
+ meta.cost_ceiling_source = traceability.cost_ceiling_source;
556
+ // Keep the legacy field in sync so v3.4.x dashboard/readers that
557
+ // only know `cost_ceiling_usd` still see the effective ceiling.
558
+ meta.cost_ceiling_usd = traceability.effective_cost_ceiling_usd;
559
+ meta.updated_at = now();
560
+ writeJson(this.metaPath(sessionId), meta);
561
+ return meta;
562
+ });
563
+ }
564
+ // v3.2.0 (Codex bug report 2026-05-12): public guard for orchestrator
565
+ // entry points. Throws when the session has already been finalized so
566
+ // round-starting tools fail fast instead of appending rounds onto a
567
+ // closed session (which would re-derive `convergence_health` from the
568
+ // post-final round's `convergence.converged` and leave the meta in the
569
+ // contradictory `outcome=converged / health=blocked` state observed in
570
+ // session 41244a1c). Error code is structured for upstream callers.
571
+ assertNotFinalized(sessionId) {
572
+ const meta = this.read(sessionId);
573
+ if (meta.outcome) {
574
+ const err = new Error(`session_already_finalized: session ${sessionId} is finalized with outcome="${meta.outcome}"; cannot start new rounds`);
575
+ err.code = "session_already_finalized";
576
+ throw err;
577
+ }
578
+ }
579
+ finalize(sessionId, outcome, reason) {
580
+ return this.withSessionLock(sessionId, () => {
581
+ const meta = this.read(sessionId);
582
+ // v3.2.0 (Codex bug report 2026-05-12): when the caller asserts
583
+ // outcome="converged", the latest round (if any) MUST have
584
+ // `convergence.converged === true`. Otherwise we would persist the
585
+ // contradictory `outcome=converged / health=blocked` state observed
586
+ // in session 41244a1c (R6 had perplexity:unparseable_after_recovery
587
+ // → convergence.converged=false, but session_finalize was invoked
588
+ // with outcome="converged"/"unanimous_ready" anyway). Refuse with a
589
+ // structured error so the operator/caller fixes the mismatch
590
+ // upstream instead of corrupting the meta.
591
+ if (outcome === "converged" && meta.rounds.length > 0) {
592
+ const latest = meta.rounds[meta.rounds.length - 1];
593
+ if (latest.convergence?.converged !== true) {
594
+ const err = new Error(`session_finalize_outcome_mismatch: cannot finalize as "converged" — latest round (round=${latest.round}) has convergence.converged=${latest.convergence?.converged ?? "undefined"}, reason="${latest.convergence?.reason ?? "n/a"}"`);
595
+ err.code = "session_finalize_outcome_mismatch";
596
+ throw err;
597
+ }
598
+ }
599
+ meta.outcome = outcome;
600
+ if (reason)
601
+ meta.outcome_reason = reason;
602
+ delete meta.in_flight;
603
+ meta.convergence_health = {
604
+ state: outcome === "converged" ? "converged" : outcome === "max-rounds" ? "blocked" : "stale",
605
+ last_event_at: now(),
606
+ detail: reason ?? outcome,
607
+ };
608
+ meta.updated_at = now();
609
+ writeJson(this.metaPath(sessionId), meta);
610
+ return meta;
611
+ });
612
+ }
613
+ requestCancellation(sessionId, reason = "operator_requested", jobId) {
614
+ return this.withSessionLock(sessionId, () => {
615
+ const meta = this.read(sessionId);
616
+ meta.control = {
617
+ status: "cancel_requested",
618
+ reason,
619
+ job_id: jobId,
620
+ requested_at: now(),
621
+ updated_at: now(),
622
+ };
623
+ meta.convergence_health = {
624
+ state: meta.outcome === "converged" ? "converged" : "blocked",
625
+ last_event_at: now(),
626
+ detail: `Cancellation requested: ${reason}`,
627
+ };
628
+ meta.updated_at = now();
629
+ writeJson(this.metaPath(sessionId), meta);
630
+ return meta;
631
+ });
632
+ }
633
+ markCancelled(sessionId, reason = "cancelled") {
634
+ return this.withSessionLock(sessionId, () => {
635
+ const meta = this.read(sessionId);
636
+ meta.outcome = "aborted";
637
+ meta.outcome_reason = reason;
638
+ delete meta.in_flight;
639
+ meta.control = {
640
+ status: "cancelled",
641
+ reason,
642
+ job_id: meta.control?.job_id,
643
+ requested_at: meta.control?.requested_at,
644
+ updated_at: now(),
645
+ };
646
+ meta.convergence_health = {
647
+ state: "stale",
648
+ last_event_at: now(),
649
+ detail: reason,
650
+ };
651
+ meta.updated_at = now();
652
+ writeJson(this.metaPath(sessionId), meta);
653
+ return meta;
654
+ });
655
+ }
656
+ isCancellationRequested(sessionId) {
657
+ const meta = this.read(sessionId);
658
+ return meta.control?.status === "cancel_requested";
659
+ }
660
+ appendFallbackEvent(sessionId, event) {
661
+ return this.withSessionLock(sessionId, () => {
662
+ const meta = this.read(sessionId);
663
+ meta.fallback_events = [...(meta.fallback_events ?? []), event];
664
+ meta.updated_at = now();
665
+ writeJson(this.metaPath(sessionId), meta);
666
+ return meta;
667
+ });
668
+ }
669
+ // v2.7.0 Evidence Broker: aggregate NEEDS_EVIDENCE asks from a round
670
+ // into the session-level checklist. Each (peer, ask) pair is
671
+ // deduplicated by sha256(peer + ":" + ask) so the same ask repeated
672
+ // across rounds increments `round_count` instead of producing
673
+ // duplicate entries. Returns the updated checklist (or empty array
674
+ // if nothing was added/updated).
675
+ appendEvidenceChecklistItems(sessionId, round, incoming) {
676
+ if (!incoming.length)
677
+ return [];
678
+ return this.withSessionLock(sessionId, () => {
679
+ const meta = this.read(sessionId);
680
+ const existing = meta.evidence_checklist ?? [];
681
+ const byId = new Map(existing.map((item) => [item.id, item]));
682
+ const ts = now();
683
+ for (const { peer, ask } of incoming) {
684
+ const trimmed = ask.trim();
685
+ if (!trimmed)
686
+ continue;
687
+ const id = crypto
688
+ .createHash("sha256")
689
+ .update(`${peer}:${trimmed}`)
690
+ .digest("hex")
691
+ .slice(0, 16);
692
+ const existing = byId.get(id);
693
+ if (existing) {
694
+ // Same ask resurfaced. Bump last_round/last_seen_at and
695
+ // round_count only when the round number is strictly newer
696
+ // (avoid double-counting if the same caller_request appears
697
+ // multiple times within the same round across peers — though
698
+ // we already iterate per-peer, so this is defensive).
699
+ if (round > existing.last_round) {
700
+ existing.last_round = round;
701
+ existing.last_seen_at = ts;
702
+ existing.round_count += 1;
703
+ }
704
+ }
705
+ else {
706
+ byId.set(id, {
707
+ id,
708
+ peer,
709
+ first_round: round,
710
+ last_round: round,
711
+ round_count: 1,
712
+ ask: trimmed,
713
+ first_seen_at: ts,
714
+ last_seen_at: ts,
715
+ });
716
+ }
717
+ }
718
+ const updated = Array.from(byId.values()).sort((a, b) => {
719
+ if (a.first_round !== b.first_round)
720
+ return a.first_round - b.first_round;
721
+ if (a.peer !== b.peer)
722
+ return a.peer.localeCompare(b.peer);
723
+ return a.ask.localeCompare(b.ask);
724
+ });
725
+ meta.evidence_checklist = updated;
726
+ meta.updated_at = ts;
727
+ writeJson(this.metaPath(sessionId), meta);
728
+ return updated;
729
+ });
730
+ }
731
+ // v2.8.0: terminal statuses owned by the operator. The runtime never
732
+ // auto-mutates items in these states — it only surfaces them via the
733
+ // peer_resurfaced_terminal collection so the orchestrator can emit a
734
+ // visibility event. Held as a Set because the runtime checks membership
735
+ // on every item every round; a Set lookup avoids any risk of someone
736
+ // later writing the buggy `(status === "satisfied" || "deferred" ||
737
+ // "rejected")` truthy-OR form by accident.
738
+ static TERMINAL_STATUSES = new Set(["satisfied", "deferred", "rejected"]);
739
+ // v2.8.0: resurfacing-inference for the evidence checklist. Runs AFTER
740
+ // appendEvidenceChecklistItems for a given round and applies two rules
741
+ // atomically under the session lock:
742
+ // 1. Items in `open` whose `last_round < currentRound` were not
743
+ // brought back by any peer this round → promote to `addressed`
744
+ // and stamp `addressed_at_round`.
745
+ // 2. Items in `addressed` whose `last_round === currentRound` were
746
+ // resurfaced this round (aggregation already bumped last_round
747
+ // and round_count) → revert to `open` and clear addressed_at_round.
748
+ // Terminal operator statuses (satisfied/deferred/rejected) are NEVER
749
+ // touched here. The peer_resurfaced_terminal information is surfaced
750
+ // by the orchestrator via a separate event so operators see when peers
751
+ // keep asking for items they explicitly closed; the status itself is
752
+ // operator-owned.
753
+ runEvidenceChecklistAddressDetection(sessionId, currentRound) {
754
+ return this.withSessionLock(sessionId, () => {
755
+ const meta = this.read(sessionId);
756
+ const checklist = meta.evidence_checklist ?? [];
757
+ if (!checklist.length) {
758
+ return { not_resurfaced: [], reopened: [], peer_resurfaced_terminal: [] };
759
+ }
760
+ const notResurfaced = [];
761
+ const reopened = [];
762
+ const peerResurfacedTerminal = [];
763
+ const history = meta.evidence_status_history ?? [];
764
+ const ts = now();
765
+ for (const item of checklist) {
766
+ const status = item.status ?? "open";
767
+ if (status === "open" && item.last_round < currentRound) {
768
+ // v3.5.0 (CRV2-2): an `open` item the peer did not resurface
769
+ // becomes `not_resurfaced`, NOT `addressed`. "The peer did not
770
+ // re-ask" is not proof the evidence was satisfied — only the
771
+ // judge autowire (verified-satisfied) or explicit operator
772
+ // action may move an item to a confirmed state. This keeps the
773
+ // audit trail honest. `not_resurfaced` is still not `open`, so
774
+ // it does not hard-block the `=== "open"` convergence gate;
775
+ // the inference is recorded, not enforced.
776
+ item.status = "not_resurfaced";
777
+ item.addressed_at_round = currentRound;
778
+ // v2.9.0: tag the inference path so the dashboard and audit
779
+ // trail can distinguish runtime resurfacing from runtime judge
780
+ // promotions. Operator-set terminal statuses do not populate
781
+ // this field; setEvidenceChecklistItemStatus clears it.
782
+ item.address_method = "resurfacing";
783
+ delete item.judge_rationale;
784
+ notResurfaced.push(item);
785
+ history.push({
786
+ ts,
787
+ item_id: item.id,
788
+ from: "open",
789
+ to: "not_resurfaced",
790
+ by: "runtime",
791
+ round: currentRound,
792
+ note: `auto: peer did not resurface ask in round ${currentRound} (not proof of satisfaction)`,
793
+ });
794
+ }
795
+ else if ((status === "not_resurfaced" || status === "addressed") &&
796
+ item.last_round === currentRound) {
797
+ // v3.5.0 (CRV2-2): a peer resurfacing an item reverts it to
798
+ // `open` regardless of whether the prior state was the soft
799
+ // `not_resurfaced` inference or a judge/operator `addressed` —
800
+ // the peer's renewed ask wins over either inference path.
801
+ const from = status;
802
+ item.status = "open";
803
+ delete item.addressed_at_round;
804
+ delete item.address_method;
805
+ delete item.judge_rationale;
806
+ reopened.push(item);
807
+ history.push({
808
+ ts,
809
+ item_id: item.id,
810
+ from,
811
+ to: "open",
812
+ by: "runtime",
813
+ round: currentRound,
814
+ note: `auto: peer resurfaced ask in round ${currentRound}`,
815
+ });
816
+ }
817
+ else if (SessionStore.TERMINAL_STATUSES.has(status) && item.last_round === currentRound) {
818
+ // Operator closed it but the peer brought it back this round.
819
+ // Status stays terminal (operator-owned); we surface it for
820
+ // the orchestrator to emit a visibility event.
821
+ peerResurfacedTerminal.push(item);
822
+ }
823
+ }
824
+ if (notResurfaced.length || reopened.length) {
825
+ meta.evidence_status_history = history;
826
+ meta.updated_at = ts;
827
+ writeJson(this.metaPath(sessionId), meta);
828
+ }
829
+ return {
830
+ not_resurfaced: notResurfaced,
831
+ reopened,
832
+ peer_resurfaced_terminal: peerResurfacedTerminal,
833
+ };
834
+ });
835
+ }
836
+ // v2.8.0: operator workflow mutator for the evidence checklist. Used by
837
+ // the session_evidence_checklist_update MCP tool. Allowed transitions
838
+ // (operator): open → satisfied | deferred | rejected | open;
839
+ // addressed | not_resurfaced → satisfied | deferred | rejected | open.
840
+ // Terminal-state items can also be moved BACK to "open" by the operator
841
+ // (retract a deferral/rejection); that re-arms the runtime
842
+ // auto-promotion logic. Operator CANNOT move items to "addressed" or
843
+ // "not_resurfaced" — both are runtime-managed (judge promotion and
844
+ // resurfacing inference respectively). Returns the mutated item and the
845
+ // appended history entry.
846
+ setEvidenceChecklistItemStatus(sessionId, itemId, status, options = {}) {
847
+ return this.withSessionLock(sessionId, () => {
848
+ const meta = this.read(sessionId);
849
+ const checklist = meta.evidence_checklist ?? [];
850
+ const item = checklist.find((entry) => entry.id === itemId);
851
+ if (!item) {
852
+ throw new Error(`evidence_checklist_item_not_found: ${itemId}`);
853
+ }
854
+ const from = item.status ?? "open";
855
+ if (from === status) {
856
+ // No-op: already at the requested status. We still record a
857
+ // history entry so the audit trail captures the operator's
858
+ // explicit intent.
859
+ }
860
+ const ts = now();
861
+ const entry = {
862
+ ts,
863
+ item_id: itemId,
864
+ from,
865
+ to: status,
866
+ by: options.by ?? "operator",
867
+ note: options.note,
868
+ };
869
+ item.status = status;
870
+ // The signature excludes "addressed" so any operator-driven status
871
+ // change clears the runtime-managed stamps (v2.8.0 addressed_at_round
872
+ // + v2.9.0 address_method + judge_rationale).
873
+ delete item.addressed_at_round;
874
+ delete item.address_method;
875
+ delete item.judge_rationale;
876
+ const history = meta.evidence_status_history ?? [];
877
+ history.push(entry);
878
+ meta.evidence_status_history = history;
879
+ meta.evidence_checklist = checklist;
880
+ meta.updated_at = ts;
881
+ writeJson(this.metaPath(sessionId), meta);
882
+ return { item, history_entry: entry };
883
+ });
884
+ }
885
+ // v2.9.0: runtime-judge promotion path. Promotes an `open` item to
886
+ // `addressed` ONLY — never touches terminal operator statuses, never
887
+ // moves anything other than open. Atomic under the session lock.
888
+ // Returns null when the item is not currently `open` (already
889
+ // addressed, terminal, or missing) so the caller can skip emit.
890
+ markEvidenceItemAddressedByJudge(sessionId, itemId, params) {
891
+ return this.withSessionLock(sessionId, () => {
892
+ const meta = this.read(sessionId);
893
+ const checklist = meta.evidence_checklist ?? [];
894
+ const item = checklist.find((entry) => entry.id === itemId);
895
+ if (!item)
896
+ return null;
897
+ const status = item.status ?? "open";
898
+ // Single allowed transition: open → addressed (judge). Terminal
899
+ // statuses (satisfied/deferred/rejected) and already-addressed
900
+ // items are NOT auto-mutated here.
901
+ if (status !== "open")
902
+ return null;
903
+ const ts = now();
904
+ const rationale = params.rationale.trim().slice(0, 800);
905
+ item.status = "addressed";
906
+ item.addressed_at_round = params.round;
907
+ item.address_method = "judge";
908
+ item.judge_rationale = rationale;
909
+ const entry = {
910
+ ts,
911
+ item_id: itemId,
912
+ from: "open",
913
+ to: "addressed",
914
+ by: "runtime",
915
+ round: params.round,
916
+ note: `judge[${params.judge_peer}]: ${rationale}`,
917
+ };
918
+ const history = meta.evidence_status_history ?? [];
919
+ history.push(entry);
920
+ meta.evidence_status_history = history;
921
+ meta.evidence_checklist = checklist;
922
+ meta.updated_at = ts;
923
+ writeJson(this.metaPath(sessionId), meta);
924
+ return { item, history_entry: entry };
925
+ });
926
+ }
927
+ recoverInterruptedSessions(activeSessionIds = new Set()) {
928
+ const recovered = [];
929
+ for (const session of this.list()) {
930
+ if (session.outcome || activeSessionIds.has(session.session_id) || !session.in_flight)
931
+ continue;
932
+ const updated = this.withSessionLock(session.session_id, () => {
933
+ const current = this.read(session.session_id);
934
+ if (current.outcome || activeSessionIds.has(current.session_id) || !current.in_flight) {
935
+ return current;
936
+ }
937
+ const round = current.in_flight.round;
938
+ delete current.in_flight;
939
+ current.control = {
940
+ status: "recovered_after_restart",
941
+ reason: `Round ${round} was interrupted before completion and can be resumed manually.`,
942
+ updated_at: now(),
943
+ };
944
+ current.convergence_health = {
945
+ state: "stale",
946
+ last_event_at: now(),
947
+ detail: `Recovered interrupted round ${round} after MCP restart. Start a new round to continue from saved session context.`,
948
+ };
949
+ current.updated_at = now();
950
+ writeJson(this.metaPath(current.session_id), current);
951
+ return current;
952
+ });
953
+ recovered.push(updated);
954
+ }
955
+ return recovered;
956
+ }
957
+ // v2.12.0: walk session events.ndjson and aggregate
958
+ // `session.evidence_judge_pass.shadow_decision` events into a peer-keyed
959
+ // rollup. Operator observability: how many shadow decisions exist, what
960
+ // the would_promote rate looks like per judge_peer, what confidence
961
+ // distribution the judge returns. Walks the event log per session
962
+ // (O(events) per call); acceptable for v2.12 because the corpus is
963
+ // bounded (≤ a few hundred sessions historically) and the dashboard
964
+ // refreshes on demand.
965
+ aggregateShadowJudgments(sessionId) {
966
+ const sessions = sessionId ? [this.read(sessionId)] : this.list();
967
+ const byPeer = {};
968
+ let decisionsTotal = 0;
969
+ let wouldPromoteTotal = 0;
970
+ const peerKnown = PEERS;
971
+ for (const session of sessions) {
972
+ const events = this.readEvents(session.session_id);
973
+ for (const event of events) {
974
+ if (event.type !== "session.evidence_judge_pass.shadow_decision")
975
+ continue;
976
+ const data = (event.data ?? {});
977
+ const judgePeer = data.judge_peer;
978
+ if (!judgePeer || !peerKnown.includes(judgePeer))
979
+ continue;
980
+ let entry = byPeer[judgePeer];
981
+ if (!entry) {
982
+ entry = {
983
+ judge_peer: judgePeer,
984
+ decisions_total: 0,
985
+ would_promote: 0,
986
+ would_skip_satisfied_unverified: 0,
987
+ would_skip_not_satisfied: 0,
988
+ by_confidence: {},
989
+ first_seen_at: null,
990
+ last_seen_at: null,
991
+ };
992
+ byPeer[judgePeer] = entry;
993
+ }
994
+ entry.decisions_total += 1;
995
+ decisionsTotal += 1;
996
+ if (data.would_promote === true) {
997
+ entry.would_promote += 1;
998
+ wouldPromoteTotal += 1;
999
+ }
1000
+ else if (data.satisfied === true) {
1001
+ entry.would_skip_satisfied_unverified += 1;
1002
+ }
1003
+ else {
1004
+ entry.would_skip_not_satisfied += 1;
1005
+ }
1006
+ if (data.confidence === "verified" ||
1007
+ data.confidence === "inferred" ||
1008
+ data.confidence === "unknown") {
1009
+ entry.by_confidence[data.confidence] = (entry.by_confidence[data.confidence] ?? 0) + 1;
1010
+ }
1011
+ const ts = event.ts ?? null;
1012
+ if (ts) {
1013
+ if (!entry.first_seen_at || ts < entry.first_seen_at)
1014
+ entry.first_seen_at = ts;
1015
+ if (!entry.last_seen_at || ts > entry.last_seen_at)
1016
+ entry.last_seen_at = ts;
1017
+ }
1018
+ }
1019
+ }
1020
+ return {
1021
+ decisions_total: decisionsTotal,
1022
+ would_promote_total: wouldPromoteTotal,
1023
+ by_judge_peer: byPeer,
1024
+ };
1025
+ }
1026
+ metrics(sessionId) {
1027
+ const sessions = sessionId ? [this.read(sessionId)] : this.list();
1028
+ const peerResults = {};
1029
+ const peerFailures = {};
1030
+ const decisionQuality = {};
1031
+ const peerLatencies = [];
1032
+ const generationLatencies = [];
1033
+ let moderationRecoveries = 0;
1034
+ let fallbackEvents = 0;
1035
+ const perPeer = {};
1036
+ const accumulator = (peer) => {
1037
+ let entry = perPeer[peer];
1038
+ if (!entry) {
1039
+ entry = {
1040
+ results_total: 0,
1041
+ ready_count: 0,
1042
+ not_ready_count: 0,
1043
+ needs_evidence_count: 0,
1044
+ unresolved_count: 0,
1045
+ cost_sum: 0,
1046
+ cost_count: 0,
1047
+ parser_warnings_total: 0,
1048
+ rejected_total: 0,
1049
+ failures_by_class: {},
1050
+ };
1051
+ perPeer[peer] = entry;
1052
+ }
1053
+ return entry;
1054
+ };
1055
+ for (const session of sessions) {
1056
+ fallbackEvents += session.fallback_events?.length ?? 0;
1057
+ for (const round of session.rounds) {
1058
+ for (const peer of round.peers) {
1059
+ peerResults[peer.peer] = (peerResults[peer.peer] ?? 0) + 1;
1060
+ const quality = peer.decision_quality ?? "failed";
1061
+ decisionQuality[quality] = (decisionQuality[quality] ?? 0) + 1;
1062
+ if (Number.isFinite(peer.latency_ms))
1063
+ peerLatencies.push(peer.latency_ms);
1064
+ if (peer.parser_warnings.some((warning) => warning.includes("moderation_safe_retry"))) {
1065
+ moderationRecoveries += 1;
1066
+ }
1067
+ const acc = accumulator(peer.peer);
1068
+ acc.results_total += 1;
1069
+ if (peer.status === "READY")
1070
+ acc.ready_count += 1;
1071
+ else if (peer.status === "NOT_READY")
1072
+ acc.not_ready_count += 1;
1073
+ else if (peer.status === "NEEDS_EVIDENCE")
1074
+ acc.needs_evidence_count += 1;
1075
+ else
1076
+ acc.unresolved_count += 1;
1077
+ if (peer.cost?.total_cost != null &&
1078
+ Number.isFinite(peer.cost.total_cost) &&
1079
+ peer.cost.source !== "stub") {
1080
+ acc.cost_sum += peer.cost.total_cost;
1081
+ acc.cost_count += 1;
1082
+ }
1083
+ acc.parser_warnings_total += peer.parser_warnings.length;
1084
+ }
1085
+ for (const failure of round.rejected) {
1086
+ peerFailures[failure.failure_class] = (peerFailures[failure.failure_class] ?? 0) + 1;
1087
+ const acc = accumulator(failure.peer);
1088
+ acc.rejected_total += 1;
1089
+ acc.failures_by_class[failure.failure_class] =
1090
+ (acc.failures_by_class[failure.failure_class] ?? 0) + 1;
1091
+ }
1092
+ }
1093
+ for (const generation of session.generation_files ?? []) {
1094
+ if (generation.latency_ms != null && Number.isFinite(generation.latency_ms)) {
1095
+ generationLatencies.push(generation.latency_ms);
1096
+ }
1097
+ }
1098
+ }
1099
+ const average = (values) => values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
1100
+ const perPeerHealth = {};
1101
+ for (const [peer, acc] of Object.entries(perPeer)) {
1102
+ const total = acc.results_total;
1103
+ perPeerHealth[peer] = {
1104
+ peer,
1105
+ results_total: total,
1106
+ ready_count: acc.ready_count,
1107
+ not_ready_count: acc.not_ready_count,
1108
+ needs_evidence_count: acc.needs_evidence_count,
1109
+ unresolved_count: acc.unresolved_count,
1110
+ ready_rate: total > 0 ? acc.ready_count / total : 0,
1111
+ needs_evidence_rate: total > 0 ? acc.needs_evidence_count / total : 0,
1112
+ avg_cost_usd: acc.cost_count > 0 ? acc.cost_sum / acc.cost_count : null,
1113
+ total_cost_usd: acc.cost_count > 0 ? acc.cost_sum : null,
1114
+ parser_warnings_total: acc.parser_warnings_total,
1115
+ rejected_total: acc.rejected_total,
1116
+ failures_by_class: acc.failures_by_class,
1117
+ };
1118
+ }
1119
+ return {
1120
+ generated_at: now(),
1121
+ scope: sessionId ? "session" : "all",
1122
+ session_id: sessionId,
1123
+ sessions: {
1124
+ total: sessions.length,
1125
+ converged: sessions.filter((session) => session.outcome === "converged").length,
1126
+ aborted: sessions.filter((session) => session.outcome === "aborted").length,
1127
+ max_rounds: sessions.filter((session) => session.outcome === "max-rounds").length,
1128
+ unfinished: sessions.filter((session) => !session.outcome).length,
1129
+ },
1130
+ rounds: sessions.reduce((sum, session) => sum + session.rounds.length, 0),
1131
+ peer_results: peerResults,
1132
+ peer_failures: peerFailures,
1133
+ decision_quality: decisionQuality,
1134
+ moderation_recoveries: moderationRecoveries,
1135
+ fallback_events: fallbackEvents,
1136
+ total_usage: mergeUsage(sessions.map((session) => session.totals.usage)),
1137
+ total_cost: mergeCost(sessions.map((session) => session.totals.cost)),
1138
+ latency_ms: {
1139
+ peer_average: average(peerLatencies),
1140
+ generation_average: average(generationLatencies),
1141
+ },
1142
+ per_peer_health: perPeerHealth,
1143
+ // v2.12.0: shadow_decision rollup. See aggregateShadowJudgments().
1144
+ shadow_judgment: this.aggregateShadowJudgments(sessionId),
1145
+ };
1146
+ }
1147
+ // v2.16.0: read-only operational doctor. This is intentionally a
1148
+ // reporting surface, not a cleanup tool: it never finalizes, rewrites
1149
+ // or deletes sessions. Operators use it after audits to see which
1150
+ // sessions need human action and which records are legacy metadata
1151
+ // artifacts (for example caller==lead_peer before the petitioner/
1152
+ // relator split).
1153
+ //
1154
+ // v2.22.0 (A.P2): `includeLegacy` toggles per-session enumeration of
1155
+ // `findings.self_lead_metadata`. Default false because pre-v2.16.0
1156
+ // sessions carry the legacy self-lead artifact at a 38% hit rate
1157
+ // (178/467 in the May 2026 audit corpus); enumerating them every call
1158
+ // floods the response. `totals.self_lead_metadata` count remains
1159
+ // visible regardless. Pass `includeLegacy=true` to enumerate.
1160
+ //
1161
+ // v2.22.0 (B.P2): `findings.open_evidence_sessions[i]` entries gain
1162
+ // `item_types` (open items grouped by surfacing peer) and
1163
+ // `chronic_blockers` (item ids with `round_count >= 3`) so operators
1164
+ // can see which evidence asks are systemic vs cauda ruidosa.
1165
+ sessionDoctor(limit = 20, includeLegacy = false, repair = false) {
1166
+ const cappedLimit = Math.max(1, Math.min(100, Math.trunc(limit) || 20));
1167
+ // v3.6.0 (C): opt-in repair pass BEFORE the read-only audit. Fixes
1168
+ // the contradictory `outcome="converged" + health.state="blocked"`
1169
+ // state left on disk by pre-v3.2.0 sessions (v3.2.0 fixed the cause
1170
+ // via the finalize/appendRound invariants; old corrupt metas
1171
+ // persist). Only that specific contradiction is touched, only when
1172
+ // the operator explicitly passes `repair: true`. Recomputes
1173
+ // `convergence_health` from the latest round's `convergence.converged`.
1174
+ const repaired = [];
1175
+ if (repair) {
1176
+ for (const session of this.list()) {
1177
+ if (session.outcome === "converged" && session.convergence_health?.state === "blocked") {
1178
+ const latest = session.rounds.at(-1);
1179
+ const latestConverged = latest?.convergence?.converged === true;
1180
+ // Only repair when the latest round actually converged — i.e.
1181
+ // the `outcome="converged"` finalize was legitimate and only
1182
+ // the health field is the stale lie. If the latest round did
1183
+ // NOT converge, the contradiction is deeper and we leave it
1184
+ // for manual operator inspection rather than guessing.
1185
+ if (latestConverged) {
1186
+ const fromState = session.convergence_health?.state;
1187
+ const fixed = this.withSessionLock(session.session_id, () => {
1188
+ const meta = this.read(session.session_id);
1189
+ if (meta.outcome === "converged" &&
1190
+ meta.convergence_health?.state === "blocked" &&
1191
+ meta.rounds.at(-1)?.convergence?.converged === true) {
1192
+ meta.convergence_health = {
1193
+ state: "converged",
1194
+ last_event_at: now(),
1195
+ detail: `v3.6.0 doctor repair: recomputed health from latest round (was "blocked" with outcome="converged" — pre-v3.2.0 corruption artifact)`,
1196
+ };
1197
+ meta.updated_at = now();
1198
+ writeJson(this.metaPath(session.session_id), meta);
1199
+ return true;
1200
+ }
1201
+ return false;
1202
+ });
1203
+ if (fixed) {
1204
+ repaired.push({
1205
+ session_id: session.session_id,
1206
+ from_health_state: fromState,
1207
+ to_health_state: "converged",
1208
+ reason: "outcome=converged but health=blocked; latest round has convergence.converged=true — recomputed health",
1209
+ });
1210
+ }
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+ const sessions = this.list();
1216
+ const openSessions = [];
1217
+ const staleSessions = [];
1218
+ const blockedSessions = [];
1219
+ const maxRoundsSessions = [];
1220
+ const selfLeadMetadata = [];
1221
+ const openEvidenceSessions = [];
1222
+ const grokProviderErrorSessions = [];
1223
+ const eventReadErrorSessions = [];
1224
+ let eventsTotal = 0;
1225
+ let tokenDeltaEvents = 0;
1226
+ let tokenCompletedEvents = 0;
1227
+ const pushLimited = (target, entry) => {
1228
+ if (target.length < cappedLimit)
1229
+ target.push(entry);
1230
+ };
1231
+ for (const session of sessions) {
1232
+ const scope = session.convergence_scope;
1233
+ const petitioner = scope?.petitioner ?? scope?.caller ?? session.caller;
1234
+ const leadPeer = scope?.lead_peer;
1235
+ const evidenceList = session.evidence_checklist ?? [];
1236
+ const openEvidenceItemsList = evidenceList.filter((item) => (item.status ?? "open") === "open");
1237
+ const openEvidenceItems = openEvidenceItemsList.length;
1238
+ const grokProviderErrors = (session.failed_attempts ?? []).filter((failure) => failure.peer === "grok" && failure.failure_class === "provider_error").length;
1239
+ const entry = {
1240
+ session_id: session.session_id,
1241
+ version: session.version,
1242
+ caller: session.caller,
1243
+ petitioner,
1244
+ lead_peer: leadPeer,
1245
+ outcome: session.outcome,
1246
+ outcome_reason: session.outcome_reason,
1247
+ health_state: session.convergence_health?.state,
1248
+ health_detail: session.convergence_health?.detail,
1249
+ rounds: session.rounds.length,
1250
+ updated_at: session.updated_at,
1251
+ ...(openEvidenceItems > 0 ? { open_evidence_items: openEvidenceItems } : {}),
1252
+ ...(grokProviderErrors > 0 ? { grok_provider_errors: grokProviderErrors } : {}),
1253
+ };
1254
+ // v2.22.0 (B.P2): drill-down for open-evidence entries. Aggregate
1255
+ // open items by peer + flag chronic blockers (round_count >= 3).
1256
+ if (openEvidenceItems > 0) {
1257
+ const itemTypes = {};
1258
+ const chronicBlockers = [];
1259
+ for (const item of openEvidenceItemsList) {
1260
+ itemTypes[item.peer] = (itemTypes[item.peer] ?? 0) + 1;
1261
+ if (item.round_count >= 3) {
1262
+ chronicBlockers.push(item.id);
1263
+ }
1264
+ }
1265
+ entry.item_types = itemTypes;
1266
+ entry.chronic_blockers = chronicBlockers;
1267
+ }
1268
+ // v3.7.5 (A1, logs+sessions study 2026-05-15): terminal outcomes
1269
+ // are NEVER stale or blocked — they are DONE. Pre-v3.7.5 the
1270
+ // doctor classified solely on `convergence_health.state` which
1271
+ // markCancelled writes as "stale" on `outcome="aborted"`. Result:
1272
+ // 22 cancelled sessions of 244 (9%) were flagged as needing
1273
+ // attention when they were terminal. Likewise the v3.6.0 repair
1274
+ // path was the symmetric symptom for `outcome="converged" +
1275
+ // state="blocked"`. The classification fix keeps backward compat
1276
+ // with the 244 existing sessions on disk (no migration) and only
1277
+ // recognizes the truth at the consumer layer: if the session has
1278
+ // a terminal outcome, do not flag it as stale or blocked.
1279
+ const isTerminal = session.outcome != null;
1280
+ if (!session.outcome)
1281
+ pushLimited(openSessions, entry);
1282
+ if (!isTerminal && session.convergence_health?.state === "stale")
1283
+ pushLimited(staleSessions, entry);
1284
+ if (!isTerminal && session.convergence_health?.state === "blocked")
1285
+ pushLimited(blockedSessions, entry);
1286
+ if (session.outcome === "max-rounds")
1287
+ pushLimited(maxRoundsSessions, entry);
1288
+ if (petitioner && leadPeer && petitioner === leadPeer)
1289
+ pushLimited(selfLeadMetadata, entry);
1290
+ if (openEvidenceItems > 0)
1291
+ pushLimited(openEvidenceSessions, entry);
1292
+ if (grokProviderErrors > 0)
1293
+ pushLimited(grokProviderErrorSessions, entry);
1294
+ let sessionEvents = [];
1295
+ try {
1296
+ sessionEvents = this.readEvents(session.session_id);
1297
+ }
1298
+ catch (error) {
1299
+ entry.event_read_error = redact(error instanceof Error ? error.message : String(error));
1300
+ pushLimited(eventReadErrorSessions, entry);
1301
+ }
1302
+ for (const event of sessionEvents) {
1303
+ eventsTotal += 1;
1304
+ if (event.type === "peer.token.delta")
1305
+ tokenDeltaEvents += 1;
1306
+ if (event.type === "peer.token.completed")
1307
+ tokenCompletedEvents += 1;
1308
+ }
1309
+ }
1310
+ // v2.22.0 (A.P2): compute the headline self_lead_metadata count
1311
+ // BEFORE deciding whether to suppress the per-session array, so
1312
+ // `totals.self_lead_metadata` always reflects reality even when the
1313
+ // findings array is empty.
1314
+ const selfLeadCount = sessions.filter((session) => {
1315
+ const scope = session.convergence_scope;
1316
+ const petitioner = scope?.petitioner ?? scope?.caller ?? session.caller;
1317
+ return Boolean(petitioner && scope?.lead_peer && petitioner === scope.lead_peer);
1318
+ }).length;
1319
+ const recommendations = [];
1320
+ if (openSessions.length > 0) {
1321
+ recommendations.push("Review open_sessions first; finalize, contest, cancel or explicitly continue each live case.");
1322
+ }
1323
+ if (selfLeadCount > 0) {
1324
+ // Recommendation fires off the headline count, not the in-array
1325
+ // count, so operators are still nudged when the array is hidden.
1326
+ const baseAdvice = "Treat self_lead_metadata as legacy/protocol-drift evidence; do not rewrite historical records automatically.";
1327
+ if (!includeLegacy) {
1328
+ recommendations.push(`${baseAdvice} ${selfLeadCount} legacy sessions hidden by default — pass include_legacy=true to enumerate.`);
1329
+ }
1330
+ else {
1331
+ recommendations.push(baseAdvice);
1332
+ }
1333
+ }
1334
+ if (openEvidenceSessions.length > 0) {
1335
+ recommendations.push("Address or explicitly terminal-mark open evidence checklist items before expecting convergence.");
1336
+ }
1337
+ if (grokProviderErrorSessions.length > 0) {
1338
+ recommendations.push("Run a Grok-specific smoke/probe for sessions with grok provider errors before relying on Grok in release gates.");
1339
+ }
1340
+ if (eventReadErrorSessions.length > 0) {
1341
+ recommendations.push("Inspect event_read_error_sessions manually; malformed events.ndjson records were skipped for doctor aggregation but not modified.");
1342
+ }
1343
+ if (eventsTotal > 0 && tokenDeltaEvents / eventsTotal > 0.5) {
1344
+ recommendations.push("Token delta events dominate this corpus; increase CROSS_REVIEW_TOKEN_DELTA_CHARS_THRESHOLD or disable token streaming for low-noise audits.");
1345
+ }
1346
+ return {
1347
+ generated_at: now(),
1348
+ scope: "all",
1349
+ limit: cappedLimit,
1350
+ totals: {
1351
+ sessions: sessions.length,
1352
+ open: sessions.filter((session) => !session.outcome).length,
1353
+ stale: sessions.filter((session) => session.convergence_health?.state === "stale").length,
1354
+ blocked: sessions.filter((session) => session.convergence_health?.state === "blocked")
1355
+ .length,
1356
+ max_rounds: sessions.filter((session) => session.outcome === "max-rounds").length,
1357
+ self_lead_metadata: selfLeadCount,
1358
+ open_evidence_sessions: sessions.filter((session) => (session.evidence_checklist ?? []).some((item) => (item.status ?? "open") === "open")).length,
1359
+ grok_provider_error_sessions: sessions.filter((session) => (session.failed_attempts ?? []).some((failure) => failure.peer === "grok" && failure.failure_class === "provider_error")).length,
1360
+ event_read_error_sessions: eventReadErrorSessions.length,
1361
+ },
1362
+ findings: {
1363
+ open_sessions: openSessions,
1364
+ stale_sessions: staleSessions,
1365
+ blocked_sessions: blockedSessions,
1366
+ max_rounds_sessions: maxRoundsSessions,
1367
+ // v2.22.0 (A.P2): suppress per-session enumeration unless
1368
+ // operator passes include_legacy=true. Headline count remains
1369
+ // in `totals.self_lead_metadata`.
1370
+ self_lead_metadata: includeLegacy ? selfLeadMetadata : [],
1371
+ open_evidence_sessions: openEvidenceSessions,
1372
+ grok_provider_error_sessions: grokProviderErrorSessions,
1373
+ event_read_error_sessions: eventReadErrorSessions,
1374
+ },
1375
+ event_noise: {
1376
+ events_total: eventsTotal,
1377
+ token_delta_events: tokenDeltaEvents,
1378
+ token_completed_events: tokenCompletedEvents,
1379
+ token_delta_ratio: eventsTotal > 0 ? tokenDeltaEvents / eventsTotal : null,
1380
+ },
1381
+ recommendations,
1382
+ // v3.6.0 (C): only present when repair was requested; lists the
1383
+ // converged+blocked contradictions that were recomputed.
1384
+ ...(repair ? { repaired } : {}),
1385
+ };
1386
+ }
1387
+ // v2.14.0 (item 1): compute precision/recall/F1 for the shadow judge
1388
+ // against empirical ground truth (whether peers raised the same ask
1389
+ // in a subsequent round). Walks events.ndjson per session, finds each
1390
+ // `session.evidence_judge_pass.shadow_decision` event, looks up the
1391
+ // matching item in `meta.evidence_checklist` by id, and classifies
1392
+ // based on (would_promote x ask_resurfaced). Returns per-peer rollup.
1393
+ computeJudgmentPrecisionReport(opts) {
1394
+ const sessions = opts?.session_id ? [this.read(opts.session_id)] : this.list();
1395
+ const peerKnown = PEERS;
1396
+ const byPeer = {};
1397
+ let totalDecisions = 0;
1398
+ let totalWithGroundTruth = 0;
1399
+ let totalSkippedNoGT = 0;
1400
+ const acc = (peer) => {
1401
+ let entry = byPeer[peer];
1402
+ if (!entry) {
1403
+ entry = {
1404
+ judge_peer: peer,
1405
+ decisions_total: 0,
1406
+ decisions_with_ground_truth: 0,
1407
+ decisions_skipped_no_ground_truth: 0,
1408
+ true_positive: 0,
1409
+ false_positive: 0,
1410
+ true_negative: 0,
1411
+ false_negative: 0,
1412
+ precision: null,
1413
+ recall: null,
1414
+ f1: null,
1415
+ by_confidence: {},
1416
+ };
1417
+ byPeer[peer] = entry;
1418
+ }
1419
+ return entry;
1420
+ };
1421
+ for (const session of sessions) {
1422
+ const events = this.readEvents(session.session_id);
1423
+ const checklist = session.evidence_checklist ?? [];
1424
+ const itemById = new Map();
1425
+ for (const item of checklist)
1426
+ itemById.set(item.id, item);
1427
+ const maxRound = session.rounds.length;
1428
+ for (const event of events) {
1429
+ if (event.type !== "session.evidence_judge_pass.shadow_decision")
1430
+ continue;
1431
+ const data = (event.data ?? {});
1432
+ const judgePeer = data.judge_peer;
1433
+ if (!judgePeer || !peerKnown.includes(judgePeer))
1434
+ continue;
1435
+ if (opts?.peer && judgePeer !== opts.peer)
1436
+ continue;
1437
+ if (opts?.since && event.ts && event.ts < opts.since)
1438
+ continue;
1439
+ const itemId = data.item_id;
1440
+ if (!itemId)
1441
+ continue;
1442
+ const item = itemById.get(itemId);
1443
+ if (!item)
1444
+ continue;
1445
+ const judgeRound = event.round ?? item.last_round;
1446
+ const peerStats = acc(judgePeer);
1447
+ peerStats.decisions_total += 1;
1448
+ totalDecisions += 1;
1449
+ // Ground truth: did the ask resurface AFTER the judge ran?
1450
+ // last_round > judgeRound → resurfaced. last_round === judgeRound
1451
+ // AND maxRound > judgeRound → not resurfaced (we have evidence
1452
+ // peers had a chance to ask again and didn't). last_round ===
1453
+ // judgeRound AND maxRound === judgeRound → no ground truth.
1454
+ const resurfaced = item.last_round > judgeRound;
1455
+ const peersHadChance = maxRound > judgeRound;
1456
+ if (!resurfaced && !peersHadChance) {
1457
+ peerStats.decisions_skipped_no_ground_truth += 1;
1458
+ totalSkippedNoGT += 1;
1459
+ continue;
1460
+ }
1461
+ peerStats.decisions_with_ground_truth += 1;
1462
+ totalWithGroundTruth += 1;
1463
+ const wouldPromote = data.would_promote === true;
1464
+ let bucket;
1465
+ if (wouldPromote && !resurfaced)
1466
+ bucket = "tp";
1467
+ else if (wouldPromote && resurfaced)
1468
+ bucket = "fp";
1469
+ else if (!wouldPromote && resurfaced)
1470
+ bucket = "tn";
1471
+ else
1472
+ bucket = "fn";
1473
+ if (bucket === "tp")
1474
+ peerStats.true_positive += 1;
1475
+ else if (bucket === "fp")
1476
+ peerStats.false_positive += 1;
1477
+ else if (bucket === "tn")
1478
+ peerStats.true_negative += 1;
1479
+ else
1480
+ peerStats.false_negative += 1;
1481
+ if (data.confidence) {
1482
+ let bc = peerStats.by_confidence[data.confidence];
1483
+ if (!bc) {
1484
+ bc = { tp: 0, fp: 0, tn: 0, fn: 0 };
1485
+ peerStats.by_confidence[data.confidence] = bc;
1486
+ }
1487
+ bc[bucket] += 1;
1488
+ }
1489
+ }
1490
+ }
1491
+ // Compute precision/recall/f1 per peer.
1492
+ for (const peer of Object.keys(byPeer)) {
1493
+ const stats = byPeer[peer];
1494
+ if (!stats)
1495
+ continue;
1496
+ const tp = stats.true_positive;
1497
+ const fp = stats.false_positive;
1498
+ const fn = stats.false_negative;
1499
+ stats.precision = tp + fp > 0 ? tp / (tp + fp) : null;
1500
+ stats.recall = tp + fn > 0 ? tp / (tp + fn) : null;
1501
+ stats.f1 =
1502
+ stats.precision != null && stats.recall != null && stats.precision + stats.recall > 0
1503
+ ? (2 * stats.precision * stats.recall) / (stats.precision + stats.recall)
1504
+ : null;
1505
+ }
1506
+ return {
1507
+ generated_at: now(),
1508
+ peer_filter: opts?.peer,
1509
+ since_filter: opts?.since,
1510
+ session_filter: opts?.session_id,
1511
+ decisions_total: totalDecisions,
1512
+ decisions_with_ground_truth: totalWithGroundTruth,
1513
+ decisions_skipped_no_ground_truth: totalSkippedNoGT,
1514
+ by_judge_peer: byPeer,
1515
+ };
1516
+ }
1517
+ // v2.14.0 (path-A structural fix): resolve `meta.evidence_files[]`
1518
+ // entries into in-memory contents for inlining into peer prompts.
1519
+ // Reads each attachment from disk, applies a per-file cap (60% of the
1520
+ // total cap to leave room for at least 1 other attachment + headers),
1521
+ // accumulates into a total-cap, and returns whatever fits. Order
1522
+ // preserved (oldest attachment first). Files that cannot be read
1523
+ // (deleted, permission denied) are skipped silently — the caller
1524
+ // sees only the metadata that survived. This closes the recurring
1525
+ // "meta-channel limit" pattern (v2.5.0, v2.13.0) where codex demanded
1526
+ // evidence the MCP `caller → server` 200KB channel could not carry:
1527
+ // the file content already lives in `data_dir/sessions/<id>/evidence/`
1528
+ // by the time we inline, so the only constraint is the peer model's
1529
+ // context window — much larger than the MCP boundary.
1530
+ readEvidenceAttachments(sessionId, totalCapChars) {
1531
+ if (!Number.isFinite(totalCapChars) || totalCapChars <= 0)
1532
+ return [];
1533
+ const meta = this.read(sessionId);
1534
+ const files = meta.evidence_files ?? [];
1535
+ if (!files.length)
1536
+ return [];
1537
+ const perFileCap = Math.max(2_000, Math.floor(totalCapChars * 0.6));
1538
+ const sessionDir = this.sessionDir(sessionId);
1539
+ const result = [];
1540
+ let used = 0;
1541
+ for (const file of files) {
1542
+ const absolutePath = path.resolve(sessionDir, file.path);
1543
+ if (!this.isPathContained(sessionDir, absolutePath))
1544
+ continue;
1545
+ let raw;
1546
+ try {
1547
+ raw = fs.readFileSync(absolutePath, "utf8");
1548
+ }
1549
+ catch {
1550
+ continue;
1551
+ }
1552
+ const remaining = totalCapChars - used;
1553
+ if (remaining <= 0)
1554
+ break;
1555
+ const cap = Math.min(perFileCap, remaining);
1556
+ const truncated = raw.length > cap;
1557
+ const slice = truncated ? raw.slice(0, cap) : raw;
1558
+ result.push({
1559
+ label: file.label,
1560
+ relative_path: file.path,
1561
+ content: slice,
1562
+ bytes: raw.length,
1563
+ truncated,
1564
+ content_type: file.content_type,
1565
+ });
1566
+ used += slice.length;
1567
+ }
1568
+ return result;
1569
+ }
1570
+ // v2.14.0 (item 4): contest a final verdict. Stamps the contested
1571
+ // session's meta with the contestation record AND initializes a new
1572
+ // session that references back. Validates the original session is
1573
+ // in a final state (converged | aborted | max-rounds). Per the
1574
+ // tribunal-colegiado memory, this is the canonical "caller NOT_READY
1575
+ // → novo ciclo deliberativo dentro dos mesmos autos" surface — the
1576
+ // original session is preserved (append-only); a new session opens
1577
+ // for re-deliberation with a fresh task + initial_draft and a
1578
+ // structural reference back to the contested session.
1579
+ contestVerdict(params) {
1580
+ const original = this.read(params.session_id);
1581
+ if (!original.outcome) {
1582
+ throw new Error(`cannot_contest_in_flight_session: session ${params.session_id} has no outcome yet (still in flight). Wait for it to converge or finalize before contesting.`);
1583
+ }
1584
+ if (original.contestation) {
1585
+ throw new Error(`session_already_contested: session ${params.session_id} was already contested at ${original.contestation.contested_at} (new_session_id=${original.contestation.new_session_id}).`);
1586
+ }
1587
+ const newCaller = params.new_caller ?? "operator";
1588
+ const newSession = this.init(params.new_task, newCaller, [], undefined);
1589
+ // Cross-link new session → original.
1590
+ this.withSessionLock(newSession.session_id, () => {
1591
+ const m = this.read(newSession.session_id);
1592
+ m.contests_session_id = params.session_id;
1593
+ m.updated_at = now();
1594
+ writeJson(this.metaPath(newSession.session_id), m);
1595
+ return m;
1596
+ });
1597
+ // Stamp original with contestation record.
1598
+ const contestedMeta = this.withSessionLock(params.session_id, () => {
1599
+ const m = this.read(params.session_id);
1600
+ m.contestation = {
1601
+ contested_at: now(),
1602
+ reason: params.reason,
1603
+ original_outcome: m.outcome ?? null,
1604
+ new_session_id: newSession.session_id,
1605
+ };
1606
+ m.updated_at = now();
1607
+ writeJson(this.metaPath(params.session_id), m);
1608
+ return m;
1609
+ });
1610
+ return { contested_meta: contestedMeta, new_session_id: newSession.session_id };
1611
+ }
1612
+ attachEvidence(sessionId, params) {
1613
+ const extension = safeFilePart(params.extension ?? "txt").replace(/\./g, "") || "txt";
1614
+ const label = safeFilePart(params.label);
1615
+ const relativePath = `evidence/${timestampFilePart()}-${label}.${extension}`;
1616
+ const file = path.join(this.sessionDir(sessionId), relativePath);
1617
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1618
+ fs.writeFileSync(file, redact(params.content), "utf8");
1619
+ const meta = this.withSessionLock(sessionId, () => {
1620
+ const current = this.read(sessionId);
1621
+ current.evidence_files = [
1622
+ ...(current.evidence_files ?? []),
1623
+ {
1624
+ ts: now(),
1625
+ label: params.label,
1626
+ path: relativePath.replace(/\\/g, "/"),
1627
+ content_type: params.content_type,
1628
+ },
1629
+ ];
1630
+ current.updated_at = now();
1631
+ writeJson(this.metaPath(sessionId), current);
1632
+ return current;
1633
+ });
1634
+ return { path: relativePath.replace(/\\/g, "/"), meta };
1635
+ }
1636
+ escalateToOperator(sessionId, params) {
1637
+ return this.withSessionLock(sessionId, () => {
1638
+ const meta = this.read(sessionId);
1639
+ meta.operator_escalations = [
1640
+ ...(meta.operator_escalations ?? []),
1641
+ { ts: now(), reason: params.reason, severity: params.severity },
1642
+ ];
1643
+ meta.convergence_health = {
1644
+ state: meta.outcome === "converged" ? "converged" : "blocked",
1645
+ last_event_at: now(),
1646
+ detail: `Operator escalation requested: ${params.reason}`,
1647
+ };
1648
+ meta.updated_at = now();
1649
+ writeJson(this.metaPath(sessionId), meta);
1650
+ return meta;
1651
+ });
1652
+ }
1653
+ sweepIdle(idleMs, outcome = "aborted", reason = "stale") {
1654
+ const effectiveIdleMs = Math.max(idleMs, SWEEP_MIN_IDLE_MS);
1655
+ const nowMs = Date.now();
1656
+ const swept = [];
1657
+ for (const session of this.list()) {
1658
+ if (session.outcome)
1659
+ continue;
1660
+ const updatedAt = Date.parse(session.updated_at);
1661
+ const idleFor = Number.isFinite(updatedAt) ? nowMs - updatedAt : Infinity;
1662
+ if (idleFor < effectiveIdleMs)
1663
+ continue;
1664
+ const finalized = this.withSessionLock(session.session_id, () => {
1665
+ const current = this.read(session.session_id);
1666
+ current.outcome = outcome;
1667
+ current.outcome_reason = reason;
1668
+ delete current.in_flight;
1669
+ current.convergence_health = {
1670
+ state: "stale",
1671
+ last_event_at: now(),
1672
+ detail: reason,
1673
+ idle_ms: idleFor,
1674
+ };
1675
+ current.updated_at = now();
1676
+ writeJson(this.metaPath(session.session_id), current);
1677
+ return current;
1678
+ });
1679
+ swept.push(finalized);
1680
+ }
1681
+ return swept;
1682
+ }
1683
+ // v2.4.0 / audit closure (P1.3 companion): boot sweep of orphan .tmp
1684
+ // files. Crashes inside writeJson (between writeFileSync and renameSync)
1685
+ // leave files matching `<basename>.<pid>.<ts>.<nonce>.tmp` in the session
1686
+ // directory. Walk every session dir at boot, drop files matching the
1687
+ // .tmp pattern whose holder pid is dead OR whose timestamp is older than
1688
+ // 1h. Idempotent + best-effort. Returns counts for telemetry.
1689
+ // v3.7.5 (B1, logs+sessions study 2026-05-15): prune the
1690
+ // `<data_dir>/corrupt_sessions/` quarantine directory. Created
1691
+ // historically when meta.json corruption was severe enough to move
1692
+ // the whole session dir (one such case from the 2026-05-08 v2.25.1
1693
+ // redact escape-boundary bug remains on disk). Pre-v3.7.5 there was
1694
+ // no automated cleanup — the entries accumulated forever even after
1695
+ // root-cause fixes shipped. This method scans the directory and
1696
+ // removes subdirectories whose mtime is older than `minAgeMs`,
1697
+ // leaving fresher cases for forensic inspection. Read-only when the
1698
+ // dir does not exist. Errors per-entry are swallowed and surface as
1699
+ // `kept` so a single permission failure doesn't abort the sweep.
1700
+ pruneCorruptSessions(minAgeMs) {
1701
+ const corruptDir = path.join(this.config.data_dir, "corrupt_sessions");
1702
+ if (!fs.existsSync(corruptDir))
1703
+ return { scanned: 0, removed: 0, kept: 0 };
1704
+ let entries;
1705
+ try {
1706
+ entries = fs.readdirSync(corruptDir, { withFileTypes: true });
1707
+ }
1708
+ catch {
1709
+ return { scanned: 0, removed: 0, kept: 0 };
1710
+ }
1711
+ const cutoff = Date.now() - Math.max(0, minAgeMs);
1712
+ let scanned = 0;
1713
+ let removed = 0;
1714
+ let kept = 0;
1715
+ for (const ent of entries) {
1716
+ if (!ent.isDirectory())
1717
+ continue;
1718
+ scanned += 1;
1719
+ const entryPath = path.join(corruptDir, ent.name);
1720
+ let mtimeMs;
1721
+ try {
1722
+ mtimeMs = fs.statSync(entryPath).mtimeMs;
1723
+ }
1724
+ catch {
1725
+ kept += 1;
1726
+ continue;
1727
+ }
1728
+ if (mtimeMs > cutoff) {
1729
+ kept += 1;
1730
+ continue;
1731
+ }
1732
+ try {
1733
+ fs.rmSync(entryPath, { recursive: true, force: true });
1734
+ removed += 1;
1735
+ }
1736
+ catch {
1737
+ kept += 1;
1738
+ }
1739
+ }
1740
+ return { scanned, removed, kept };
1741
+ }
1742
+ sweepOrphanTmpFiles() {
1743
+ let scanned = 0;
1744
+ let removed = 0;
1745
+ const root = this.sessionsDir();
1746
+ if (!fs.existsSync(root))
1747
+ return { scanned, removed };
1748
+ let entries;
1749
+ try {
1750
+ entries = fs.readdirSync(root, { withFileTypes: true });
1751
+ }
1752
+ catch {
1753
+ return { scanned, removed };
1754
+ }
1755
+ for (const ent of entries) {
1756
+ if (!ent.isDirectory())
1757
+ continue;
1758
+ const sessionPath = path.join(root, ent.name);
1759
+ let files;
1760
+ try {
1761
+ files = fs.readdirSync(sessionPath);
1762
+ }
1763
+ catch {
1764
+ continue;
1765
+ }
1766
+ for (const f of files) {
1767
+ const m = TMP_FILE_PATTERN.exec(f);
1768
+ if (!m)
1769
+ continue;
1770
+ scanned += 1;
1771
+ const tmpPid = Number.parseInt(m[1] ?? "", 10);
1772
+ const tmpTs = Number.parseInt(m[2] ?? "", 10);
1773
+ const tmpAge = Date.now() - tmpTs;
1774
+ const holderAlive = Number.isInteger(tmpPid) ? this.processAlive(tmpPid) : false;
1775
+ if (!holderAlive || tmpAge > TMP_STALE_AFTER_MS) {
1776
+ try {
1777
+ fs.unlinkSync(path.join(sessionPath, f));
1778
+ removed += 1;
1779
+ }
1780
+ catch {
1781
+ /* ignore */
1782
+ }
1783
+ }
1784
+ }
1785
+ }
1786
+ return { scanned, removed };
1787
+ }
1788
+ // v2.4.0 / audit closure (P3.11): clear stale meta.in_flight at boot.
1789
+ // `markInFlight` sets meta.in_flight before each round and clearInFlight
1790
+ // is supposed to clear it on resolve/reject. If the host crashes
1791
+ // mid-spawn, in_flight stays set forever — confusing audit consumers
1792
+ // and `recoverInterruptedSessions` consumers that read it as "round in
1793
+ // progress". sweepIdle clears in_flight only after 24h idle (footgun
1794
+ // floor). This companion sweep covers the common host-crash case where
1795
+ // we want to reconcile in_flight as soon as the new boot starts, not
1796
+ // after a day. Conditions to clear:
1797
+ // - holder pid (lock holder, if any) is dead, OR
1798
+ // - in_flight.started_at is older than HEARTBEAT_STALE_AFTER_MS.
1799
+ // Sessions still actively running on a live PID are skipped. Idempotent
1800
+ // + best-effort. Returns counts for telemetry.
1801
+ clearStaleInFlight() {
1802
+ const HEARTBEAT_STALE_AFTER_MS = 30 * 60 * 1000; // 30 minutes
1803
+ let scanned = 0;
1804
+ let cleared = 0;
1805
+ for (const session of this.list()) {
1806
+ if (!session.in_flight)
1807
+ continue;
1808
+ scanned += 1;
1809
+ const startedIso = session.in_flight.started_at;
1810
+ const startedAge = startedIso ? Date.now() - Date.parse(startedIso) : Infinity;
1811
+ // Best-effort liveness probe via the active lock holder pid (if any).
1812
+ let holderAlive = true;
1813
+ const lockPath = path.join(this.sessionDir(session.session_id), ".lock");
1814
+ if (fs.existsSync(lockPath)) {
1815
+ try {
1816
+ const lock = readJson(lockPath);
1817
+ if (Number.isInteger(lock.pid)) {
1818
+ holderAlive = this.processAlive(lock.pid);
1819
+ }
1820
+ }
1821
+ catch {
1822
+ // malformed lock — assume dead so the lock sweep cleans it up.
1823
+ holderAlive = false;
1824
+ }
1825
+ }
1826
+ else {
1827
+ // No active lock — heartbeat staleness is the only signal.
1828
+ holderAlive = !Number.isFinite(startedAge) ? false : startedAge <= HEARTBEAT_STALE_AFTER_MS;
1829
+ }
1830
+ if (!holderAlive || startedAge > HEARTBEAT_STALE_AFTER_MS) {
1831
+ try {
1832
+ this.withSessionLock(session.session_id, () => {
1833
+ const current = this.read(session.session_id);
1834
+ if (!current.in_flight)
1835
+ return;
1836
+ delete current.in_flight;
1837
+ current.updated_at = now();
1838
+ writeJson(this.metaPath(session.session_id), current);
1839
+ cleared += 1;
1840
+ });
1841
+ }
1842
+ catch {
1843
+ /* best-effort */
1844
+ }
1845
+ }
1846
+ }
1847
+ return { scanned, cleared };
1848
+ }
1849
+ // v2.5.0: abort sessions that were never finalized.
1850
+ //
1851
+ // Empirical analysis of 253 historical sessions surfaced 22 in-progress
1852
+ // orphans where every peer had reached READY but the caller never
1853
+ // invoked `session_finalize`. Those sessions stayed at `outcome:
1854
+ // undefined` indefinitely, polluting `session_list` and stealing rows
1855
+ // from `session_recover_interrupted` consumers that interpret a missing
1856
+ // outcome as "still running".
1857
+ //
1858
+ // The session-start contract (orchestrator.ts > sessionContractDirectives
1859
+ // rule 4) now codifies the caller's finalize obligation; this boot
1860
+ // sweep cleans up the cases where the caller exited without honoring
1861
+ // that contract. It is a companion to `clearStaleInFlight`, with a
1862
+ // longer threshold because the failure mode is "host died after a
1863
+ // session ran", not "host died mid-round".
1864
+ //
1865
+ // Conditions to abort:
1866
+ // - meta.outcome is undefined (not finalized);
1867
+ // - meta.in_flight is absent (i.e. the in-flight sweep already ran or
1868
+ // the session was never marked in-flight); a still-in-flight session
1869
+ // is the inFlight sweep's job, not ours;
1870
+ // - no active lock holder, OR the session is past the staleness
1871
+ // threshold (default 24h via CROSS_REVIEW_STALE_HOURS).
1872
+ //
1873
+ // Idempotent + best-effort. Returns counts for telemetry.
1874
+ abortStaleSessions(staleHours) {
1875
+ const envHours = Number.parseFloat(process.env.CROSS_REVIEW_STALE_HOURS ?? "");
1876
+ const hours = staleHours != null && staleHours > 0
1877
+ ? staleHours
1878
+ : Number.isFinite(envHours) && envHours > 0
1879
+ ? envHours
1880
+ : 24;
1881
+ const staleThresholdMs = hours * 60 * 60 * 1000;
1882
+ let scanned = 0;
1883
+ let aborted = 0;
1884
+ for (const session of this.list()) {
1885
+ // Already finalized? Skip.
1886
+ if (session.outcome)
1887
+ continue;
1888
+ // Currently in-flight? Don't race the in-flight sweep — let it
1889
+ // either clear in_flight (next pass aborts) or leave it in place
1890
+ // (legitimate running session, must not be touched).
1891
+ if (session.in_flight)
1892
+ continue;
1893
+ scanned += 1;
1894
+ // Live lock holder => assume still running, skip.
1895
+ const lockPath = path.join(this.sessionDir(session.session_id), ".lock");
1896
+ if (fs.existsSync(lockPath)) {
1897
+ try {
1898
+ const lock = readJson(lockPath);
1899
+ if (Number.isInteger(lock.pid) && this.processAlive(lock.pid)) {
1900
+ continue;
1901
+ }
1902
+ }
1903
+ catch {
1904
+ /* malformed lock — fall through to staleness check */
1905
+ }
1906
+ }
1907
+ const lastTouched = Date.parse(session.updated_at);
1908
+ if (!Number.isFinite(lastTouched))
1909
+ continue;
1910
+ if (Date.now() - lastTouched < staleThresholdMs)
1911
+ continue;
1912
+ try {
1913
+ this.finalize(session.session_id, "aborted", `stale_no_finalize_${hours}h`);
1914
+ aborted += 1;
1915
+ }
1916
+ catch {
1917
+ /* best-effort */
1918
+ }
1919
+ }
1920
+ return { scanned, aborted };
1921
+ }
1922
+ }
1923
+ //# sourceMappingURL=session-store.js.map