opencode-longrun-harness 1.2.22

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/docs/V1.2.20_EVIDENCE.md +114 -0
  4. package/docs/V1.2.21_EVIDENCE.md +68 -0
  5. package/docs/V1.2.22_EVIDENCE.md +52 -0
  6. package/harness/commissioning/README.md +16 -0
  7. package/harness/commissioning/inspect-copied-run.mjs +25 -0
  8. package/harness/commissioning/verify-copied-case.mjs +35 -0
  9. package/harness/plugin/longrun.js +677 -0
  10. package/harness/src/cli.mjs +40 -0
  11. package/harness/src/controller.js +1413 -0
  12. package/harness/src/evidence.mjs +135 -0
  13. package/harness/src/execution.mjs +217 -0
  14. package/harness/src/executor.mjs +21 -0
  15. package/harness/src/install.mjs +435 -0
  16. package/harness/src/maintenance.mjs +257 -0
  17. package/harness/src/memory.mjs +472 -0
  18. package/harness/test/candidates.test.mjs +73 -0
  19. package/harness/test/checkpoint.test.mjs +65 -0
  20. package/harness/test/controller.test.mjs +230 -0
  21. package/harness/test/evidence.test.mjs +57 -0
  22. package/harness/test/fixtures/durable-host.mjs +27 -0
  23. package/harness/test/fixtures/example-app-run.json +1375 -0
  24. package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
  25. package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
  26. package/harness/test/fixtures/notes-recovery-run.json +622 -0
  27. package/harness/test/fixtures/presets-readout-run.json +825 -0
  28. package/harness/test/fixtures/routing-worker.mjs +35 -0
  29. package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
  30. package/harness/test/helper.mjs +41 -0
  31. package/harness/test/install.test.mjs +117 -0
  32. package/harness/test/lifecycle.test.mjs +102 -0
  33. package/harness/test/maintenance.test.mjs +204 -0
  34. package/harness/test/memory.test.mjs +145 -0
  35. package/harness/test/negative-control.test.mjs +91 -0
  36. package/harness/test/plugin.test.mjs +169 -0
  37. package/harness/test/recovery-runner.test.mjs +435 -0
  38. package/harness/test/recovery.test.mjs +68 -0
  39. package/harness/test/repair-mechanics.test.mjs +122 -0
  40. package/harness/test/toolbehavior.test.mjs +75 -0
  41. package/harness/test/v121-commissioning.test.mjs +177 -0
  42. package/harness/test/v1210-deadline.test.mjs +134 -0
  43. package/harness/test/v1211-pause.test.mjs +81 -0
  44. package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
  45. package/harness/test/v1213-readout.test.mjs +82 -0
  46. package/harness/test/v1214-durable.test.mjs +121 -0
  47. package/harness/test/v1215-guidance.test.mjs +57 -0
  48. package/harness/test/v1216-test-summary.test.mjs +39 -0
  49. package/harness/test/v1217-discovery.test.mjs +73 -0
  50. package/harness/test/v1218-completion-review.test.mjs +203 -0
  51. package/harness/test/v1219-budget-pause.test.mjs +134 -0
  52. package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
  53. package/harness/test/v1220-budget-amendment.test.mjs +343 -0
  54. package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
  55. package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
  56. package/harness/test/v123-plugin-e2e.test.mjs +120 -0
  57. package/harness/test/v123-receipt-model.test.mjs +185 -0
  58. package/harness/test/v124-canonical.test.mjs +147 -0
  59. package/harness/test/v124-installed.test.mjs +48 -0
  60. package/harness/test/v125-stability.test.mjs +183 -0
  61. package/harness/test/v126-execution.test.mjs +183 -0
  62. package/harness/test/v127-reconciliation.test.mjs +139 -0
  63. package/harness/test/v128-compaction.test.mjs +156 -0
  64. package/harness/test/v129-routing.test.mjs +165 -0
  65. package/harness/tools/audit-receipts.mjs +121 -0
  66. package/harness/tools/recovery-runner.mjs +499 -0
  67. package/package.json +49 -0
@@ -0,0 +1,1413 @@
1
+ // Long-run Harness — deterministic controller (shared by plugin, tools, CLI).
2
+ // Dependency-free: node builtins only (works without bun; importable by Bun or Node ESM).
3
+ // No network, no external packages. This file is copied verbatim into the built install.
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import * as EV from "./evidence.mjs";
9
+ import * as MEM from "./memory.mjs";
10
+ import * as EXEC from "./execution.mjs";
11
+
12
+ // ---- Run-lifecycle schema (authoritative, shared by plugin + tools + CLI + tests) ----------
13
+ // The native longrun tool exposes EXACTLY these actions; no ellipsis, no hidden guessing.
14
+ export const LIFECYCLE_SCHEMA_VERSION = "1.2.22";
15
+ export const RUN_ACTIONS = [
16
+ "help", "start", "status", "receipts", "next", "checkpoint", "verify", "pause",
17
+ "resume", "complete", "cancel", "reconcile", "memory_init", "memory_refresh", "memory_status",
18
+ ];
19
+ export const ACTION_PARAMS = {
20
+ help: { required: [], optional: ["session"] },
21
+ start: { required: ["request", "criteria"], optional: ["hardGates", "candidateBudget", "timeBudgetHours", "deadlineHours", "toolActionCap", "sameFailureThreshold", "noProgressThreshold", "autoContinue", "checkCatalogue"] },
22
+ status: { required: [], optional: ["run"] },
23
+ receipts: { required: [], optional: ["run", "checkId", "receiptId", "offset", "limit"] },
24
+ next: { required: [], optional: ["run"] },
25
+ checkpoint: { required: [], optional: ["run", "progress"] },
26
+ verify: { required: ["checkId"], optional: ["evidenceClass", "runId"] },
27
+ pause: { required: [], optional: ["run"] },
28
+ resume: { required: [], optional: ["run"] },
29
+ reconcile: { required: [], optional: ["runId"] },
30
+ complete: { required: [], optional: ["run"] },
31
+ cancel: { required: [], optional: ["run", "reason"] },
32
+ memory_init: { required: [], optional: ["maxDepth", "dryRun", "regenerate"] },
33
+ memory_refresh: { required: [], optional: ["maxDepth"] },
34
+ memory_status: { required: [], optional: [] },
35
+ };
36
+
37
+ export const STATES = [
38
+ "READY", "IMPLEMENTING", "VERIFYING", "REPAIRING", "NEEDS_REPLAN",
39
+ "COMPACTING", "RECOVERY_REQUIRED", "PAUSED", "BLOCKED", "COMPLETE",
40
+ "CANCELLED",
41
+ ];
42
+
43
+ // ---- Canonical run resolution (§1, v1.2.2): ONE definition of "active run", shared by the
44
+ // native `longrun` tool AND native `longrun_verify`. State -> code is EXPLICIT; never collapse
45
+ // everything into a single generic "no_active_run". A non-terminal run's state decides whether a
46
+ // verification may run; terminal (COMPLETE/CANCELLED) and absent runs return distinct structured
47
+ // codes. The tool supplies the candidate run records (resolved from session binding + canonical
48
+ // project/worktree identity + an explicit runId); the controller owns the rules.
49
+
50
+ // States in which a declared check MAY be executed + recorded.
51
+ export const RUN_VERIFY_STATES = ["IMPLEMENTING", "VERIFYING", "REPAIRING", "NEEDS_REPLAN"];
52
+ // Terminal states: never a verification target, and never block a fresh `start`.
53
+ export const RUN_TERMINAL_STATES = ["COMPLETE", "CANCELLED"];
54
+ // Resolvable-but-not-eligible states (a verification must first be resumed/reconciled).
55
+ export const RUN_STALL_STATES = ["READY", "COMPACTING", "RECOVERY_REQUIRED", "BLOCKED", "PAUSED"];
56
+
57
+ export function isVerifyEligible(status) { return RUN_VERIFY_STATES.includes(status); }
58
+
59
+ // Caller holds the run writer lock. Shared by native control and installed
60
+ // maintenance so an emergency pause cannot resurrect a terminal run or miss
61
+ // the generation change observed by an already-running declared check.
62
+ export function pauseRun(run) {
63
+ if (RUN_TERMINAL_STATES.includes(run.status)) return { error: `RUN_${run.status}`, state: run.status };
64
+ run.status = "PAUSED";
65
+ run.autoEnabled = false;
66
+ run.controlGeneration = (run.controlGeneration || 0) + 1;
67
+ return { ok: true, state: "PAUSED", cancelledContinuations: true };
68
+ }
69
+
70
+ // Classify a single run record for verification. Returns {eligible, code, state}.
71
+ export function runVerifyCategory(run) {
72
+ if (!run) return { eligible: false, code: "NO_RUN", state: null };
73
+ const s = run.status;
74
+ if (RUN_VERIFY_STATES.includes(s)) return { eligible: true, code: "ELIGIBLE", state: s };
75
+ if (s === "PAUSED") return { eligible: false, code: "RUN_PAUSED", state: s };
76
+ if (s === "COMPLETE") return { eligible: false, code: "RUN_COMPLETE", state: s };
77
+ if (s === "CANCELLED") return { eligible: false, code: "RUN_CANCELLED", state: s };
78
+ return { eligible: false, code: "RUN_STALLED", state: s };
79
+ }
80
+
81
+ // STATUS/display selection: the single authoritative current run for a project. Prefer the most
82
+ // recent NON-terminal run; otherwise surface the most recent terminal one (so status can read
83
+ // COMPLETE/CANCELLED). `entries` = chronological [{run}]. Returns the entry or null.
84
+ export function pickCurrentRun(entries) {
85
+ const list = (entries || []).filter((e) => e && e.run);
86
+ const nonTerm = list.filter((e) => !RUN_TERMINAL_STATES.includes(e.run.status));
87
+ if (nonTerm.length) return nonTerm[nonTerm.length - 1];
88
+ if (list.length) return list[list.length - 1];
89
+ return null;
90
+ }
91
+
92
+ // VERIFY resolution against a single authoritative run. Never silently verifies an arbitrary run
93
+ // and never guesses when resolution is ambiguous. `entries` are already project-isolated by the
94
+ // caller. explicitRunId restricts to that id; if it is absent for this project it is NO_RUN (not a
95
+ // cross-project fallback).
96
+ export function resolveVerification({ entries = [], explicitRunId = null } = {}) {
97
+ const withRun = (entries || []).filter((e) => e && e.run);
98
+ if (explicitRunId) {
99
+ const found = withRun.find((e) => e.run.runId === explicitRunId);
100
+ if (!found) return { ok: false, code: "NO_RUN", detail: `run ${explicitRunId} not resolvable in this project/worktree` };
101
+ const cat = runVerifyCategory(found.run);
102
+ if (cat.eligible) return { ok: true, entry: found, run: found.run, runId: found.run.runId };
103
+ return { ok: false, code: cat.code, runId: found.run.runId, state: cat.state, detail: `run ${found.run.runId} is ${cat.state}; not eligible for verification` };
104
+ }
105
+ const active = withRun.filter((e) => !RUN_TERMINAL_STATES.includes(e.run.status));
106
+ if (active.length === 0) return { ok: false, code: "NO_RUN", detail: "no active run in this project (terminal-only or none); a prompt alone is not a run" };
107
+ if (active.length > 1) return { ok: false, code: "AMBIGUOUS_RUN", ids: active.map((e) => e.run.runId), detail: "multiple non-terminal runs; pass an explicit runId" };
108
+ const cat = runVerifyCategory(active[0].run);
109
+ if (cat.eligible) return { ok: true, entry: active[0], run: active[0].run, runId: active[0].run.runId };
110
+ return { ok: false, code: cat.code, runId: active[0].run.runId, state: cat.state, detail: `run ${active[0].run.runId} is ${cat.state}; not eligible for verification` };
111
+ }
112
+
113
+ // Lifecycle verification (§6 + §8): records a receipt + recomputes criterion status/loss WITHOUT
114
+ // counting a source candidate. `applyVerification` (candidate accounting) is UNCHANGED for real
115
+ // candidate evaluation. A lifecycle verify with unchanged source must leave the candidate count
116
+ // at 0, so it is routed here with diagnosticOnly + canChangeAcceptance:false.
117
+ export function applyLifecycleVerification(run, opts = {}) {
118
+ return applyVerification(run, { ...opts, diagnosticOnly: true, canChangeAcceptance: false });
119
+ }
120
+
121
+ export const CHECK_STATUSES = [
122
+ "PASS", "FAIL", "ERROR", "TIMEOUT", "SKIPPED", "BLOCKED", "NOT_RUN", "STALE",
123
+ ];
124
+ // Only a valid PASS satisfies a required check.
125
+ export function statusSatisfies(status) { return status === "PASS"; }
126
+
127
+ // Excluded: caches, VCS, and the controller's OWN outputs (so recording evidence never
128
+ // self-invalidates). Matched by path segment (dirs) or by basename / suffix (files).
129
+ const LEGACY_EXCLUDES = [
130
+ ".git", "node_modules", ".opencode", "longrun-harness", ".longrun",
131
+ "**/RESUME.md", "**/*.longrun-receipt.json",
132
+ ];
133
+
134
+ // Schema 2 excludes known generated outputs. Tests, source, manifests and configuration remain
135
+ // inputs. Legacy hashes are computed separately, never rewritten into historical receipts.
136
+ export const SOURCE_FINGERPRINT_SCHEMA = 2;
137
+ const DEFAULT_EXCLUDES = [...LEGACY_EXCLUDES,
138
+ "dist", "coverage", "test-results", "playwright-report", ".cache", ".DS_Store",
139
+ "**/CHECKPOINT.md", "**/*.tsbuildinfo",
140
+ ];
141
+ function generatedEvidence(p) {
142
+ return /^artifacts\/.*\.(png|jpe?g|webp|mp4|webm|zip|log|json)$/i.test(p);
143
+ }
144
+
145
+ function sha256(s) { return crypto.createHash("sha256").update(s).digest("hex"); }
146
+ function exists(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
147
+
148
+ function rel(p, root) { return path.relative(root, p).split(path.sep).join("/"); }
149
+ function isExcluded(relPath, excludes) {
150
+ const parts = relPath.split("/");
151
+ const last = parts[parts.length - 1];
152
+ for (const e of excludes) {
153
+ // a bare name (no slash) excludes the path if ANY segment equals it (dir) or basename equals it
154
+ if (!e.startsWith("**/")) {
155
+ if (parts.includes(e) || last === e) return true;
156
+ continue;
157
+ }
158
+ const tail = e.slice(3); // strip "**/"
159
+ if (tail.startsWith("*.")) { if (last.endsWith(tail.slice(1))) return true; }
160
+ else if (last === tail) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function git(root, args) {
166
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
167
+ return { ok: r.status === 0, out: (r.stdout || "").toString(), code: r.status };
168
+ }
169
+
170
+ // ---- Identity (§4): key by canonical repo/worktree identity AND run id.
171
+ // Git: worktree root + HEAD. Non-git: keyed by ABSOLUTE path (never group all non-git together).
172
+ export function projectIdentity(root) {
173
+ const abs = path.resolve(root);
174
+ const g = git(abs, ["rev-parse", "--show-toplevel"]);
175
+ if (g.ok && g.out.trim()) {
176
+ const top = path.resolve(g.out.trim());
177
+ const head = git(top, ["rev-parse", "HEAD"]);
178
+ const inside = git(top, ["rev-parse", "--is-inside-work-tree"]);
179
+ if (inside.ok && inside.out.trim() === "true") {
180
+ return { kind: "git", id: `git:${top}`, root: top, head: head.ok ? head.out.trim() : null };
181
+ }
182
+ }
183
+ // Non-git / git-dir-less: stable id from absolute path so two dirs never share state.
184
+ return { kind: "dir", id: `dir:${abs}`, root: abs, head: null };
185
+ }
186
+ export function stateKey(identity, runId) {
187
+ return sha256(`${identity.id}\u0000${runId}`).slice(0, 32);
188
+ }
189
+
190
+ function routingFailure(detail, code = "ROUTING_STORE_ERROR") {
191
+ return Object.assign(new Error(detail), { code });
192
+ }
193
+ export async function withStoreRoutingLock(dir, callback) {
194
+ if (!dir) throw routingFailure("state directory unavailable");
195
+ fs.mkdirSync(dir, { recursive: true });
196
+ const file = path.join(dir, "ROUTING.lock"), token = crypto.randomUUID();
197
+ let acquired = false;
198
+ for (let attempt = 0; attempt < 200; attempt++) {
199
+ try {
200
+ const fd = fs.openSync(file, "wx");
201
+ try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() })); acquired = true; }
202
+ finally { fs.closeSync(fd); }
203
+ break;
204
+ } catch (error) {
205
+ if (error.code !== "EEXIST") throw routingFailure(`routing lock unavailable (${error.code || error.name})`);
206
+ await new Promise(resolve => setTimeout(resolve, 20));
207
+ }
208
+ }
209
+ if (!acquired) throw routingFailure("another host owns the routing transaction or its lock needs recovery; no lifecycle mutation was started. Retry after that host finishes; preserve an abandoned lock for inspected recovery.", "ROUTING_BUSY");
210
+ try {
211
+ return await callback();
212
+ } finally {
213
+ try { if (JSON.parse(fs.readFileSync(file, "utf8")).token === token) fs.unlinkSync(file); } catch {}
214
+ }
215
+ }
216
+
217
+ // ---- Append-only store with an append-only event log + locks (§4).
218
+ export class Store {
219
+ constructor(dir) { this.dir = dir; }
220
+ keyPath(key) {
221
+ const p = path.join(this.dir, "state", key);
222
+ fs.mkdirSync(p, { recursive: true });
223
+ return p;
224
+ }
225
+ _file(key, name) { return path.join(this.keyPath(key), name); }
226
+ readJSON(key, name) {
227
+ try { return JSON.parse(fs.readFileSync(this._file(key, name), "utf8")); } catch { return null; }
228
+ }
229
+ // Canonical lifecycle reads distinguish absent records from damaged storage.
230
+ // Diagnostic readJSON's tolerant fallback is inappropriate for mutations.
231
+ readRun(key) {
232
+ let text;
233
+ try { text = fs.readFileSync(path.join(this.dir, "state", key, "run.json"), "utf8"); }
234
+ catch (error) { return { error: error.code === "ENOENT" ? "NO_RUN" : "STATE_READ_ERROR", detail: error.code || error.message }; }
235
+ try {
236
+ const run = JSON.parse(text);
237
+ if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error("expected a canonical run object");
238
+ return { run };
239
+ } catch (error) { return { error: "STATE_CORRUPT", detail: error.message }; }
240
+ }
241
+ // Atomic write: tmp + rename. Never a destructive reset.
242
+ writeJSON(key, name, value) {
243
+ const final = this._file(key, name);
244
+ const tmp = `${final}.${process.pid}.tmp`;
245
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
246
+ fs.renameSync(tmp, final);
247
+ return final;
248
+ }
249
+ // Append-only event log with dedup. Returns true if newly appended, false if duplicate.
250
+ appendEvent(key, event) {
251
+ const log = this._file(key, "events.log");
252
+ let seen = [];
253
+ try { seen = fs.readFileSync(log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l).dedupeKey; } catch { return null; } }); } catch {}
254
+ if (event.dedupeKey && seen.includes(event.dedupeKey)) return false;
255
+ fs.appendFileSync(log, JSON.stringify(event) + "\n");
256
+ return true;
257
+ }
258
+ // Non-blocking writer lock (acquire/release), never held across model/test/subprocess I/O.
259
+ tryLock(key, token) {
260
+ const lock = path.join(this.keyPath(key), "WRITER.lock");
261
+ try {
262
+ if (fs.existsSync(lock)) {
263
+ const cur = fs.readFileSync(lock, "utf8");
264
+ let info = {}; try { info = JSON.parse(cur); } catch {}
265
+ // stale if pid gone
266
+ if (info.pid && !procAlive(info.pid)) { try { fs.rmSync(lock); } catch {} }
267
+ }
268
+ const fd = fs.openSync(lock, "wx");
269
+ fs.writeSync(fd, JSON.stringify({ token, pid: process.pid, at: Date.now() }));
270
+ fs.closeSync(fd);
271
+ return true;
272
+ } catch (e) { if (e && e.code === "EEXIST") return false; throw e; }
273
+ }
274
+ releaseLock(key) { try { fs.rmSync(path.join(this.keyPath(key), "WRITER.lock")); } catch {} }
275
+ whoHoldsLock(key) { try { return JSON.parse(fs.readFileSync(path.join(this.keyPath(key), "WRITER.lock"), "utf8")); } catch { return null; } }
276
+ // Re-read under the short writer lock, so a checkpoint/pause cannot be overwritten
277
+ // by a verifier's pre-subprocess snapshot. Callbacks must be synchronous.
278
+ mutate(key, callback) {
279
+ const token = crypto.randomUUID();
280
+ if (!this.tryLock(key, token)) return { error: "STATE_BUSY", detail: "another writer is committing; retry the same operation" };
281
+ try {
282
+ const found = this.readRun(key);
283
+ if (found.error) return found;
284
+ const { run } = found;
285
+ const result = callback(run);
286
+ if (!result?.error) this.writeJSON(key, "run.json", run);
287
+ return result;
288
+ } finally { this.releaseLock(key); }
289
+ }
290
+ }
291
+ function procAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
292
+
293
+ // ---- Source fingerprint (§7): tracked + untracked + deletions/renames.
294
+ // Returns {hash, manifest}. Deletions are recorded as `!DELETED:<rel>`.
295
+ export function sourceFingerprint(root, opts = {}) {
296
+ const legacy = opts.schemaVersion === 1;
297
+ const excludes = [...(legacy ? LEGACY_EXCLUDES : DEFAULT_EXCLUDES), ...(opts.extraExcludes || [])];
298
+ const excluded = (p) => isExcluded(p, excludes) || (!legacy && generatedEvidence(p));
299
+ const rootAbs = path.resolve(root);
300
+ const entries = [];
301
+ const id = projectIdentity(rootAbs);
302
+ if (id.kind === "git" && id.head) {
303
+ const tracked = git(id.root, ["ls-files"]);
304
+ const untracked = git(id.root, ["ls-files", "--others", "--exclude-standard"]);
305
+ const trackedFiles = tracked.ok ? tracked.out.split("\n").filter(Boolean).map((f) => path.join(id.root, f)) : [];
306
+ const untrackedFiles = untracked.ok ? untracked.out.split("\n").filter(Boolean).map((f) => path.join(id.root, f)) : [];
307
+ for (const f of [...trackedFiles, ...untrackedFiles]) {
308
+ const r = rel(f, id.root);
309
+ if (excluded(r)) continue;
310
+ if (exists(f)) entries.push(`${r}:${sha256(fs.readFileSync(f))}`);
311
+ else entries.push(`!DELETED:${r}`);
312
+ }
313
+ } else {
314
+ // Non-git: bounded deterministic walk, exclude listed patterns + hidden caches.
315
+ const stack = [rootAbs]; const visited = new Set();
316
+ let budget = 20000;
317
+ while (stack.length && budget-- > 0) {
318
+ const d = stack.pop();
319
+ let items; try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
320
+ for (const it of items) {
321
+ const full = path.join(d, it.name);
322
+ const r = rel(full, rootAbs);
323
+ if (excluded(r)) continue;
324
+ if (it.isDirectory()) { if (!visited.has(full)) { visited.add(full); stack.push(full); } }
325
+ else if (it.isFile()) { if (exists(full)) entries.push(`${r}:${sha256(fs.readFileSync(full))}`); }
326
+ }
327
+ }
328
+ }
329
+ entries.sort();
330
+ return { hash: sha256(entries.join("\n")), schemaVersion: legacy ? 1 : SOURCE_FINGERPRINT_SCHEMA, count: entries.length, kind: id.kind, head: id.head, ...(!legacy ? { legacyHash: sourceFingerprint(root, { ...opts, schemaVersion: 1 }).hash } : {}) };
331
+ }
332
+
333
+ // ---- Contract + loss (§5)
334
+ // contract = { criteria:[{id, required, checks:[checkId], weight, status, evidenceFingerprint}],
335
+ // gates:[{id, required, status}], evaluator?:{...}, revision }
336
+ export function defaultLoss(contract) {
337
+ const req = (contract.criteria || []).filter((c) => c.required);
338
+ if (req.length === 0) return { error: "empty_required_set" };
339
+ const denom = req.reduce((s, c) => s + (c.weight ?? 1), 0);
340
+ if (!(denom > 0)) return { error: "zero_denominator" };
341
+ // Missing/stale/unverified evidence counts as unsatisfied (numerator).
342
+ const num = req.reduce((s, c) => s + (statusSatisfies(c.status) ? 0 : (c.weight ?? 1)), 0);
343
+ const loss = num / denom;
344
+ if (!Number.isFinite(loss)) return { error: "non_finite" };
345
+ return { loss, target: 0, num, denom };
346
+ }
347
+ export function evaluateEvaluatorResult(result) {
348
+ // Invalid / missing / non-finite / out-of-domain => evaluator error, never a good score.
349
+ if (!result || typeof result !== "object") return { error: "invalid_evaluator_output" };
350
+ const v = result.value;
351
+ if (typeof v !== "number" || !Number.isFinite(v) || Number.isNaN(v)) return { error: "non_finite_or_missing_value" };
352
+ if (typeof result.direction === "number") { /* domain bound supplied by evaluator */ }
353
+ return { ok: true, value: v, schemaValid: !!result.schemaValid };
354
+ }
355
+
356
+ // Contract-only callers (without a receipt store or check catalogue) can evaluate explicit
357
+ // declarations. Tracked runs NEVER use cached criterion/gate statuses as evidence.
358
+ function declarationOnly(run) { return !Array.isArray(run.receipts) && !run.checkCatalogue; }
359
+ function currentHash(fp) { return typeof fp === "object" && fp ? fp.hash : fp; }
360
+
361
+ // Operator review is independent of check success. It is an audited workflow
362
+ // boundary, not an OS sandbox against actors with arbitrary state-file access.
363
+ export function completionReviewBasis(run, currentFingerprint = run.sourceFingerprint) {
364
+ // A recorded budget amendment changes the effective limits, so it is part of the basis. Runs with
365
+ // no amendments hash EXACTLY as before (the extra key is omitted), so historical reviews are not
366
+ // invalidated merely by upgrading the harness.
367
+ const amendments = Array.isArray(run.budgetAmendments) && run.budgetAmendments.length ? { budgetAmendments: run.budgetAmendments } : {};
368
+ return sha256(JSON.stringify({ runId: run.runId, directory: run.directory,
369
+ originalRequest: run.originalRequest, createdAt: run.createdAt, budget: run.budget,
370
+ ...amendments,
371
+ contract: run.contract, contractHash: run.contractHash, evaluatorHash: run.evaluatorHash,
372
+ checkCatalogue: run.checkCatalogue, sourceFingerprint: currentHash(currentFingerprint),
373
+ receipts: run.receipts, evidence: run.evidence, candidates: run.state?.candidates,
374
+ verificationMs: run.execution?.verificationMs, commandAttempts: run.execution?.commandAttempts }));
375
+ }
376
+ export function completionReviewStatus(run, currentFingerprint = run.sourceFingerprint) {
377
+ if (declarationOnly(run)) return { required: false, status: "NOT_APPLICABLE", basis: null };
378
+ const basis = completionReviewBasis(run, currentFingerprint);
379
+ const latest = Array.isArray(run.completionReviews) ? run.completionReviews.at(-1) : null;
380
+ const valid = latest?.schemaVersion === 1 && latest?.source === "operator_cli" &&
381
+ typeof latest.id === "string" && typeof latest.reason === "string" &&
382
+ ["accept", "reject"].includes(latest.verdict) && Number.isFinite(latest.at);
383
+ const status = !valid ? "REQUIRED" : latest.basis !== basis ? "STALE" : latest.verdict === "accept" ? "ACCEPTED" : "REJECTED";
384
+ return { required: true, status, basis, reviewId: valid ? latest.id : null,
385
+ reason: valid ? latest.reason : null,
386
+ guidance: "Green checks alone do not authorize completion. Pause for an independent operator review. The installed maintenance review command records it; model checkpoints and complete arguments cannot grant approval." };
387
+ }
388
+
389
+ // Caller holds the writer lock. All refusals precede mutation/archive writes.
390
+ // Archive is required before rejecting a recorded COMPLETE; normal pause/resume
391
+ // remain unable to reopen terminal records.
392
+ export function recordCompletionReview(run, { verdict, reason, expectedBasis, reviewId,
393
+ currentFingerprint, archive, now = Date.now() } = {}) {
394
+ if (!["accept", "reject"].includes(verdict) || typeof reason !== "string" || !reason.trim() || reason.length > 4000 ||
395
+ typeof reviewId !== "string" || !/^[a-zA-Z0-9_-]{8,80}$/.test(reviewId) ||
396
+ typeof expectedBasis !== "string" || !/^[a-f0-9]{64}$/.test(expectedBasis) || !currentHash(currentFingerprint))
397
+ return { error: "INVALID_COMPLETION_REVIEW" };
398
+ if (run.completionReviews !== undefined && !Array.isArray(run.completionReviews)) return { error: "INVALID_REVIEW_HISTORY" };
399
+ const history = run.completionReviews || [];
400
+ if (history.some(r => !r || typeof r !== "object" || r.schemaVersion !== 1 || typeof r.id !== "string")) return { error: "INVALID_REVIEW_HISTORY" };
401
+ if (run.status === "CANCELLED") return { error: "RUN_CANCELLED", state: run.status };
402
+ if (run.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT" };
403
+ const prior = history.find(r => r.id === reviewId);
404
+ if (prior) {
405
+ if (prior.basis !== expectedBasis || prior.verdict !== verdict || prior.reason !== reason.trim()) return { error: "REVIEW_ID_CONFLICT" };
406
+ if (completionReviewBasis(run, currentFingerprint) !== expectedBasis) return { error: "REVIEW_BASIS_CHANGED" };
407
+ return { ok: true, alreadyRecorded: true, reviewId, state: run.status, completionReview: completionReviewStatus(run, currentFingerprint) };
408
+ }
409
+ if (run.status !== "PAUSED" && !(verdict === "reject" && run.status === "COMPLETE"))
410
+ return { error: "REVIEW_REQUIRES_PAUSE", state: run.status };
411
+ const basis = completionReviewBasis(run, currentFingerprint);
412
+ if (basis !== expectedBasis) return { error: "REVIEW_BASIS_CHANGED", currentBasis: basis };
413
+ if (verdict === "accept" && !deriveRunView(run, { currentFingerprint }).declaredChecksReady)
414
+ return { error: "REVIEW_CHECKS_NOT_READY" };
415
+ const previousState = run.status;
416
+ const before = JSON.stringify(run, null, 2);
417
+ let archivedSnapshot = null;
418
+ if (previousState === "COMPLETE") {
419
+ if (typeof archive !== "function") return { error: "REVIEW_ARCHIVE_REQUIRED" };
420
+ try { archivedSnapshot = archive(JSON.parse(before), reviewId); }
421
+ catch (e) { return { error: "REVIEW_ARCHIVE_FAILED", detail: e.code || e.message }; }
422
+ if (typeof archivedSnapshot !== "string" || !archivedSnapshot) return { error: "REVIEW_ARCHIVE_FAILED" };
423
+ }
424
+ const record = { schemaVersion: 1, id: reviewId, at: now, source: "operator_cli", verdict,
425
+ reason: reason.trim(), basis, sourceFingerprint: currentHash(currentFingerprint),
426
+ previousState, priorRunHash: sha256(before), archivedSnapshot };
427
+ run.completionReviews = [...history, record];
428
+ run.status = "PAUSED"; run.autoEnabled = false;
429
+ run.controlGeneration = (run.controlGeneration || 0) + 1;
430
+ return { ok: true, state: run.status, reviewId, verdict, archivedSnapshot,
431
+ completionReview: completionReviewStatus(run, currentFingerprint) };
432
+ }
433
+
434
+ export async function operatorCompletionReview(store, key, args) {
435
+ return withStoreRoutingLock(store.dir, () => store.mutate(key, run => {
436
+ if (!args.directory || !args.runId || run.runId !== args.runId || !run.directory ||
437
+ projectIdentity(args.directory).root !== projectIdentity(run.directory).root)
438
+ return { error: "REVIEW_RUN_MISMATCH" };
439
+ // Rejecting COMPLETE reopens a project slot. Serialize against native start
440
+ // and refuse if a later, different task already occupies that slot.
441
+ if (run.status === "COMPLETE" && args.verdict === "reject") {
442
+ for (const name of fs.readdirSync(path.join(store.dir, "state"))) {
443
+ if (name === key || !/^[a-f0-9]{32}$/.test(name)) continue;
444
+ const other = store.readRun(name);
445
+ if (other.error) return other;
446
+ if (other.run.directory && projectIdentity(other.run.directory).root === projectIdentity(args.directory).root &&
447
+ !RUN_TERMINAL_STATES.includes(other.run.status))
448
+ return { error: "EXISTING_RUN", runId: other.run.runId, detail: "Another task already occupies this project; no terminal record was reopened." };
449
+ }
450
+ }
451
+ return recordCompletionReview(run, { ...args, currentFingerprint: sourceFingerprint(args.directory),
452
+ archive: (snapshot, id) => {
453
+ const file = path.join(store.keyPath(key), `completion-review-${id}.json`), bytes = JSON.stringify(snapshot, null, 2);
454
+ try { fs.writeFileSync(file, bytes, { flag: "wx" }); }
455
+ catch (e) { if (e.code !== "EEXIST" || fs.readFileSync(file, "utf8") !== bytes) throw e; }
456
+ return file;
457
+ } });
458
+ }));
459
+ }
460
+
461
+ // ---- v1.2.20 audited operator-only budget amendment -----------------------------------------
462
+ // A finite additional candidate allowance and/or a new ABSOLUTE deadline granted after the
463
+ // original limits were exhausted/expired. The original budget, deadline, usage, receipts, contract
464
+ // and failure history are preserved byte-for-byte; only an append-only amendment record plus a
465
+ // control-generation increment are added. It NEVER resumes a run and NEVER implies acceptance.
466
+ // Reachable ONLY through the installed maintenance CLI (not RUN_ACTIONS / ACTION_PARAMS), so the
467
+ // native model tool surface has no amendment action. Like the operator completion review this is an
468
+ // auditable workflow boundary, not an OS sandbox against arbitrary state-file access.
469
+ export const AMENDMENT_SCHEMA_VERSION = 1;
470
+ const AMENDMENT_MAX_ADDITIONAL = 1000;
471
+ const AMENDMENT_ID_RE = /^[a-zA-Z0-9_-]{8,80}$/;
472
+ const HEX64_RE = /^[a-f0-9]{64}$/;
473
+
474
+ // Original vs effective limits, for status/readouts and for the operator preparing a grant.
475
+ export function budgetAmendmentStatus(run, now = Date.now()) {
476
+ const original = run.budget || defaultBudget();
477
+ const baseIterations = Number.isFinite(original.iterations) ? original.iterations : defaultBudget().iterations;
478
+ const t = EXEC.timing(run, now);
479
+ const originalDeadlineAt = EXEC.originalTiming(run, now).deadlineAt;
480
+ const list = Array.isArray(run.budgetAmendments) ? run.budgetAmendments : [];
481
+ return {
482
+ schemaVersion: AMENDMENT_SCHEMA_VERSION,
483
+ count: list.length,
484
+ original: {
485
+ iterations: baseIterations, deadlineAt: originalDeadlineAt,
486
+ deadlineSeconds: Number.isFinite(original.deadlineSeconds) ? original.deadlineSeconds : null,
487
+ activeSeconds: Number.isFinite(original.activeSeconds) ? original.activeSeconds : null,
488
+ toolActionCap: Number.isFinite(original.toolActionCap) ? original.toolActionCap : null,
489
+ },
490
+ additionalCandidates: EXEC.effectiveIterations(run) - baseIterations,
491
+ effective: { iterations: EXEC.effectiveIterations(run), deadlineAt: t.deadlineAt },
492
+ amendments: list,
493
+ };
494
+ }
495
+
496
+ // Caller holds the run writer lock. Every refusal precedes any mutation.
497
+ export function applyBudgetAmendment(run, { amendmentId, additionalCandidates, newDeadlineAt, authorization, reason,
498
+ expectedRevision, expectedBasis, now = Date.now() } = {}) {
499
+ if (typeof amendmentId !== "string" || !AMENDMENT_ID_RE.test(amendmentId))
500
+ return { error: "INVALID_AMENDMENT", detail: "amendmentId must be 8..80 characters of [A-Za-z0-9_-]" };
501
+ if (!Number.isInteger(additionalCandidates) || additionalCandidates < 1 || additionalCandidates > AMENDMENT_MAX_ADDITIONAL)
502
+ return { error: "INVALID_AMENDMENT", detail: `additionalCandidates must be an integer 1..${AMENDMENT_MAX_ADDITIONAL}` };
503
+ if (!Number.isFinite(newDeadlineAt) || Math.abs(newDeadlineAt) > 8640000000000000)
504
+ return { error: "INVALID_AMENDMENT", detail: "newDeadlineAt must be a valid absolute epoch-ms timestamp" };
505
+ if (typeof authorization !== "string" || !authorization.trim() || authorization.length > 4000)
506
+ return { error: "INVALID_AMENDMENT", detail: "explicit authorization text is required (<=4000 chars)" };
507
+ if (typeof reason !== "string" || !reason.trim() || reason.length > 4000)
508
+ return { error: "INVALID_AMENDMENT", detail: "a reason is required (<=4000 chars)" };
509
+ if (!Number.isInteger(expectedRevision))
510
+ return { error: "INVALID_AMENDMENT", detail: "expectedRevision (current controlGeneration) is required" };
511
+ if (typeof expectedBasis !== "string" || !HEX64_RE.test(expectedBasis))
512
+ return { error: "INVALID_AMENDMENT", detail: "expectedBasis (current canonical basis) is required" };
513
+ if (run.budgetAmendments !== undefined && !Array.isArray(run.budgetAmendments)) return { error: "INVALID_AMENDMENT_HISTORY" };
514
+ const history = run.budgetAmendments || [];
515
+ if (history.some(a => !a || typeof a !== "object" || a.schemaVersion !== AMENDMENT_SCHEMA_VERSION || typeof a.id !== "string"))
516
+ return { error: "INVALID_AMENDMENT_HISTORY" };
517
+ if (RUN_TERMINAL_STATES.includes(run.status)) return { error: `RUN_${run.status}`, state: run.status };
518
+ if (run.status !== "PAUSED") return { error: "AMENDMENT_REQUIRES_PAUSE", state: run.status };
519
+ if (run.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT" };
520
+ const auth = authorization.trim(), why = reason.trim();
521
+ const prior = history.find(a => a.id === amendmentId);
522
+ if (prior) {
523
+ // An exact repeat is an idempotent no-op; the same id with different content is a conflict.
524
+ if (prior.additionalCandidates !== additionalCandidates || prior.newDeadlineAt !== newDeadlineAt ||
525
+ prior.authorization !== auth || prior.reason !== why)
526
+ return { error: "AMENDMENT_ID_CONFLICT", detail: "this amendmentId is already recorded with different content" };
527
+ return { ok: true, alreadyApplied: true, amendmentId, state: run.status, budgetLimit: budgetAmendmentStatus(run, now) };
528
+ }
529
+ const basis = completionReviewBasis(run, run.sourceFingerprint);
530
+ if (basis !== expectedBasis) return { error: "AMENDMENT_BASIS_CHANGED", currentBasis: basis };
531
+ const revision = run.controlGeneration || 0;
532
+ if (revision !== expectedRevision) return { error: "AMENDMENT_REVISION_CHANGED", currentRevision: revision };
533
+ const t = EXEC.timing(run, now);
534
+ const currentEffective = t.deadlineAt;
535
+ const originalDeadlineAt = EXEC.originalTiming(run, now).deadlineAt;
536
+ if (!(newDeadlineAt > now)) return { error: "AMENDMENT_DEADLINE_NOT_FUTURE", detail: new Date(newDeadlineAt).toISOString() };
537
+ if (currentEffective !== null && !(newDeadlineAt > currentEffective))
538
+ return { error: "AMENDMENT_DEADLINE_NOT_EXTENDING", currentDeadlineAt: currentEffective };
539
+ const record = {
540
+ schemaVersion: AMENDMENT_SCHEMA_VERSION, id: amendmentId, at: now, source: "operator_cli",
541
+ additionalCandidates, authorization: auth, reason: why,
542
+ originalBudget: {
543
+ iterations: Number.isFinite(run.budget?.iterations) ? run.budget.iterations : defaultBudget().iterations,
544
+ deadlineSeconds: run.budget?.deadlineSeconds ?? null, activeSeconds: run.budget?.activeSeconds ?? null,
545
+ toolActionCap: run.budget?.toolActionCap ?? null,
546
+ },
547
+ originalDeadlineAt, previousEffectiveCandidates: EXEC.effectiveIterations(run),
548
+ previousEffectiveDeadlineAt: currentEffective, newDeadlineAt,
549
+ effectiveCandidatesAfter: EXEC.effectiveIterations(run) + additionalCandidates,
550
+ expectedRevision: revision, priorRunHash: sha256(JSON.stringify(run, null, 2)),
551
+ };
552
+ run.budgetAmendments = [...history, record];
553
+ run.controlGeneration = revision + 1;
554
+ return { ok: true, state: run.status, amendmentId, amendment: record, budgetLimit: budgetAmendmentStatus(run, now) };
555
+ }
556
+
557
+ export async function operatorBudgetAmendment(store, key, args = {}) {
558
+ return withStoreRoutingLock(store.dir, () => store.mutate(key, run => {
559
+ if (!args.directory || !args.runId || run.runId !== args.runId || !run.directory ||
560
+ projectIdentity(args.directory).root !== projectIdentity(run.directory).root)
561
+ return { error: "AMENDMENT_RUN_MISMATCH" };
562
+ return applyBudgetAmendment(run, args);
563
+ }));
564
+ }
565
+
566
+ export function effectiveLoss(run, currentFingerprint = run.sourceFingerprint) {
567
+ const criteria = (run.contract?.criteria || []).map(c => ({ ...c,
568
+ status: criterionSatisfied(run, c, currentFingerprint).satisfied ? "PASS" : "FAIL" }));
569
+ return defaultLoss({ ...run.contract, criteria });
570
+ }
571
+ export function effectiveRemaining(run, currentFingerprint = run.sourceFingerprint) {
572
+ return (run.contract?.criteria || []).filter(c => c.required && !criterionSatisfied(run, c, currentFingerprint).satisfied).map(c => c.id);
573
+ }
574
+
575
+ // The sole lifecycle projection. Pure: no receipt, contract, budget or legacy summary migration.
576
+ export function deriveRunView(run, { currentFingerprint = run.sourceFingerprint, projectMemoryStatus = null, now = Date.now() } = {}) {
577
+ const criteria = (run.contract?.criteria || []).map(c => ({ id: c.id, required: !!c.required,
578
+ checks: c.checks || [], needs: c.evidenceClass || null, ...criterionSatisfied(run, c, currentFingerprint) }));
579
+ const checks = [...new Set([...Object.keys(run.checkCatalogue || {}),
580
+ ...(run.contract?.criteria || []).flatMap(c => c.checks || []),
581
+ ...(run.contract?.gates || []).flatMap(g => g.checks?.length ? g.checks : [g.id])])]
582
+ .map(id => checkDiagnostics(run, id, currentFingerprint));
583
+ const gates = gateStatuses(run, currentFingerprint);
584
+ const hardGateBlockers = gates.filter(g => !g.satisfied);
585
+ const remaining = criteria.filter(c => c.required && !c.satisfied).map(c => c.id);
586
+ const ev = evidenceStatus(run, currentFingerprint), loss = effectiveLoss(run, currentFingerprint);
587
+ // Replay receipt prefixes using their recorded identities, under the CURRENT contract and
588
+ // evaluator. Cached lossBefore/lossAfter/best/current are not qualifying evidence.
589
+ const history = [];
590
+ const prefix = [];
591
+ for (const r of run.receipts || []) {
592
+ prefix.push(r);
593
+ if (!isProjectReceipt(r) || !r.sourceFingerprint) continue;
594
+ const historicalFp = { hash: r.sourceFingerprint, legacyHash: r.sourceFingerprint, schemaVersion: r.fingerprintSchemaVersion || 1 };
595
+ const l = effectiveLoss({ ...run, receipts: prefix }, historicalFp);
596
+ if (!l.error) history.push({ loss: l.loss, receiptId: receiptId(run, r) });
597
+ }
598
+ const currentLoss = loss.loss ?? null;
599
+ const values = [...history.map(x => x.loss), currentLoss].filter(Number.isFinite);
600
+ const bestLoss = values.length ? Math.min(...values) : null;
601
+ const targetLoss = run.contract?.lossTarget ?? 0;
602
+ let blockReason = null;
603
+ if (hardGateBlockers.length) blockReason = hardGateBlockers.every(g => g.kind === "STALE_EVIDENCE") ? "stale_evidence" : "hard_gates_failed";
604
+ else if (remaining.length) blockReason = "required_unverified";
605
+ else if (loss.error) blockReason = "loss_error";
606
+ else if (!(currentLoss <= targetLoss + 1e-9)) blockReason = "loss_above_target";
607
+ else if (run.faults?.length) blockReason = "controller_fault";
608
+ const declaredChecksReady = blockReason === null;
609
+ const completionReview = completionReviewStatus(run, currentFingerprint);
610
+ if (!blockReason && completionReview.required && completionReview.status !== "ACCEPTED")
611
+ blockReason = `completion_review_${completionReview.status.toLowerCase()}`;
612
+ const memory = projectMemoryStatus || { status: "UNKNOWN", reasons: ["project memory was not supplied"], perNode: {} };
613
+ return { state: run.status, runId: run.runId || null, currentFingerprint: currentHash(currentFingerprint) || null,
614
+ currentLoss, loss: currentLoss, bestLoss, targetLoss, lossError: loss.error || null,
615
+ criterionStates: criteria, checks, gates, hardGateStates: gates, hardGateBlockers,
616
+ remaining, outstanding: criteria.filter(c => c.required && !c.satisfied),
617
+ evidenceGaps: ev.gaps, failures: ev.failures, staleEvidence: checks.filter(c => c.staleReason),
618
+ completionBlocked: blockReason !== null, blockReason, declaredChecksReady, completionReview,
619
+ candidateCount: EV.candidateCount(run), candidates: `${EV.candidateCount(run)}/${EXEC.effectiveIterations(run)}`,
620
+ budgets: run.budget || defaultBudget(), budgetLimit: budgetAmendmentStatus(run, now),
621
+ controlRevision: run.controlGeneration || 0,
622
+ // Binds the CANONICAL RECORD as stored (its own last-verified source fingerprint), not the live
623
+ // working tree, so an operator can read the basis here and pass it to `amend` unchanged. A
624
+ // working-tree edit is orthogonal to a budget/deadline grant and stays unverified regardless.
625
+ amendmentBasis: declarationOnly(run) ? null : completionReviewBasis(run, run.sourceFingerprint),
626
+ timing: EXEC.timing(run, now), executionUsage: EXEC.usage(run), counters: { iterations: EV.candidateCount(run), activeMs: null,
627
+ verificationMs: EXEC.usage(run).verificationMs, commandAttempts: EXEC.usage(run).commandAttempts,
628
+ activeTimeNote: "total agent active time is not fully observed; verificationMs covers declared checks only",
629
+ noProgressStreak: run.state?.noProgressStreak || 0, sameFailureStreak: run.state?.sameFailureStreak || 0 },
630
+ memoryStatus: memory.status, memory, continuation: false, lifecycleGuidance: LIFECYCLE_GUIDANCE, agentProgress: run.agentProgress || null,
631
+ effectiveReceiptRefs: checks.filter(c => c.effectiveStatus === "PASS").map(c => c.effectiveReceiptId),
632
+ historicalReceiptCount: (run.receipts || []).length,
633
+ legacyCachedLoss: { loss: run.loss ?? null, current: run.state?.current?.loss ?? run.state?.current?.lossAfter ?? null,
634
+ best: run.state?.best?.loss ?? run.state?.best?.lossAfter ?? null },
635
+ bestLossBasis: "current projection and receipt-prefix replay under current contract/evaluator; cached summaries excluded" };
636
+ }
637
+ export function canComplete(run, opts = {}) {
638
+ const view = opts.view || deriveRunView(run, opts);
639
+ return { ...view, complete: !view.completionBlocked, reason: view.blockReason,
640
+ hardFails: view.hardGateBlockers.map(g => g.id), blocking: view.hardGateBlockers,
641
+ reqFails: view.remaining };
642
+ }
643
+
644
+ // Routine tool readouts must not repeat command logs throughout the check table.
645
+ // Keep the canonical view/calculation intact; detailed receipts are read separately.
646
+ export function summarizeRunView(view) {
647
+ const compact = ({ historicalReceipts, effectiveReceipt, ...check }) => check;
648
+ return { ...view, checks: view.checks.map(compact), staleEvidence: view.staleEvidence.map(compact),
649
+ receiptDetails: { action: "receipts", runId: view.runId,
650
+ params: "Optional checkId, offset and limit (1..20) list receipt metadata; receiptId retrieves one receipt with its actual output tail.",
651
+ omittedFromStatus: ["historicalReceipts", "effectiveReceipt"], canonicalHistoryPreserved: true } };
652
+ }
653
+
654
+ // (v1.2.2) A second, duplicate copy of the canonical run resolver + lifecycle-verification block
655
+ // once lived here and shadowed the single canonical definition near STATES above. Removed: there is
656
+ // now exactly ONE definition of "active run", shared by the native `longrun` tool and `longrun_verify`.
657
+
658
+ // ---- Contract completeness at START (§5, v1.2.1): a required criterion must be SATISFIABLE. ----
659
+ // A required criterion can only ever reach PASS via a receipt from a DECLARED check. A criterion
660
+ // that declares no checks, or checks absent from checkCatalogue, is doomed to a permanent
661
+ // EVIDENCE_GAP (the v1.2.0 commissioning bug: LC-001 had evidenceClass STATIC but no mapped
662
+ // check, so loss could never reach zero). We refuse to create such a run: no fabricated PASS, no
663
+ // criterion that can never be verified. Returns {ok, problems:[{criterionId, missingChecks, reason}]}.
664
+ export function validateStartContract({ criteria = [], checkCatalogue = {} } = {}) {
665
+ const cat = checkCatalogue || {};
666
+ const problems = [];
667
+ for (const c of criteria) {
668
+ if (c.required === false) continue;
669
+ const id = c.id || "(unnamed)";
670
+ const checks = Array.isArray(c.checks) ? c.checks : [];
671
+ if (checks.length === 0) { problems.push({ criterionId: id, missingChecks: ["<none-declared>"], reason: "required criterion has no mapped check" }); continue; }
672
+ const missing = checks.filter((k) => !Array.isArray(cat[k]?.command));
673
+ if (missing.length) problems.push({ criterionId: id, missingChecks: missing, reason: "mapped check(s) absent from checkCatalogue" });
674
+ }
675
+ return { ok: problems.length === 0, problems };
676
+ }
677
+
678
+
679
+ // ---- Receipts (§7)
680
+ // Only a valid PASS satisfies; ZERO discovered tests cannot satisfy a test-coverage requirement;
681
+ // console optimism is ignored — exit code + discovered counts decide.
682
+ // `fpScope` records WHICH root the source fingerprint was measured against: "project" (the real
683
+ // tracked worktree) or a foreign scope ("fixture"/"copy"/"subprocess" — an isolated temp copy, a
684
+ // negative-control fixture, or a subprocess working dir). Foreign-scope receipts are NEVER allowed
685
+ // to satisfy OR invalidate a project check (they only prove a verifier is live / is sabotaged).
686
+ export function makeReceipt({ checkId, command, exitCode, output = "", testCount, startedAt, finishedAt, versions = {}, contractHash, evaluatorHash, sourceFingerprint, requirementKind, evidenceClass, proxyOnly, fpScope, fingerprintSchemaVersion }) {
687
+ let status = "PASS";
688
+ if (exitCode === "TIMEOUT") status = "TIMEOUT";
689
+ else if (exitCode === "BLOCKED") status = "BLOCKED";
690
+ else if (exitCode === "ERROR") status = "ERROR";
691
+ else if (exitCode !== 0) status = "FAIL";
692
+ // Zero discovered tests cannot satisfy a TEST-COVERAGE requirement. This gate applies ONLY to a
693
+ // declared test requirement; a NON-TEST hard gate (build/typecheck, kind "cmd") is satisfied by a
694
+ // clean exit and must never be forced to produce a test receipt.
695
+ else if (requirementKind === "test" && (typeof testCount !== "number" || testCount === 0)) status = "NOT_RUN";
696
+ const cls = evidenceClass || (proxyOnly ? "STATIC" : undefined);
697
+ return {
698
+ checkId, command, status, exitCode,
699
+ testCount: typeof testCount === "number" ? testCount : null,
700
+ startedAt, finishedAt,
701
+ versions, contractHash, evaluatorHash,
702
+ sourceFingerprint, fingerprintSchemaVersion, fpScope: fpScope || "project",
703
+ evidenceClass: cls, class: cls, proxyOnly: !!proxyOnly || EV.PROXY_ONLY.has(cls),
704
+ // a receipt is only valid if recorded against a still-current fingerprint
705
+ _validateAgainst: (currentFp) => currentFp === sourceFingerprint ? status : "STALE",
706
+ };
707
+ }
708
+ export function resolveReceiptStatus(receipt, currentFingerprint) {
709
+ return currentFingerprint === receipt.sourceFingerprint ? receipt.status : "STALE";
710
+ }
711
+
712
+ // ---- Authoritative effective-receipt model (§1/§2, v1.2.3) ----------------------------------
713
+ // ONE definition of "what does this check currently prove", shared by status, the verify readout,
714
+ // replay, completion detection and hard-gate calculation. Rules:
715
+ // * Only PROJECT-scope receipts decide a project check. A fixture/copy/subprocess-scope result
716
+ // never satisfies AND never invalidates the real project (a sabotage run against a copied
717
+ // workspace cannot turn the project's build red, and a healthy-fixture negative control is not
718
+ // positive evidence).
719
+ // * Of the project receipts, the AUTHORITATIVE one is the most recent (by finishedAt, then by
720
+ // insertion order). This is the anti-masking rule: a NEWER failure is not hidden behind an older
721
+ // pass, and a NEWER restore-pass is not hidden behind an older failure.
722
+ // * A PASS only satisfies on CURRENT source. A pass measured against a different (stale) project
723
+ // fingerprint is STALE, never trusted; an old PASS is never kept "just because it says PASS".
724
+ function isProjectReceipt(r) {
725
+ return r && (r.fpScope == null || r.fpScope === "project") && r.mode !== "negative" && r.kind !== "negative_control";
726
+ }
727
+ export function projectReceiptsFor(run, checkId) {
728
+ return (run?.receipts || []).filter(r => r.checkId === checkId && isProjectReceipt(r));
729
+ }
730
+ function receiptId(run, r) {
731
+ return r.receiptId || r.id || `receipt-${(run.receipts || []).indexOf(r) + 1}-${sha256(JSON.stringify(r)).slice(0, 12)}`;
732
+ }
733
+ function receiptEligibility(run, r, fp) {
734
+ if (!isProjectReceipt(r)) return "NON_PROJECT_EVIDENCE";
735
+ if (!r.sourceFingerprint) return "LEGACY_STALE_MISSING_FINGERPRINT";
736
+ if (run.contractHash && r.contractHash !== run.contractHash) return r.contractHash ? "CONTRACT_MISMATCH" : "LEGACY_STALE_MISSING_CONTRACT_HASH";
737
+ if (run.evaluatorHash && r.evaluatorHash !== run.evaluatorHash) return "EVALUATOR_MISMATCH";
738
+ if (r.fingerprintSchemaVersion && ![1, SOURCE_FINGERPRINT_SCHEMA].includes(r.fingerprintSchemaVersion)) return "FINGERPRINT_SCHEMA_INCOMPATIBLE";
739
+ const current = typeof fp === "object" && fp ? ((r.fingerprintSchemaVersion || 1) === 1 ? fp.legacyHash : fp.hash) : fp;
740
+ if (!current) return "CURRENT_FINGERPRINT_UNAVAILABLE";
741
+ if (r.sourceFingerprint !== current) return "SOURCE_FINGERPRINT_MISMATCH";
742
+ return r.status === "STALE" ? r.staleReason || "STALE_RECEIPT" : null;
743
+ }
744
+ export function effectiveReceipt(run, checkId, currentFingerprint = run.sourceFingerprint) {
745
+ const list = projectReceiptsFor(run, checkId);
746
+ if (!list.length) return null;
747
+ const best = list.reduce((a, b) => (b.finishedAt || 0) >= (a.finishedAt || 0) ? b : a);
748
+ const staleReason = receiptEligibility(run, best, currentFingerprint);
749
+ return { receipt: best, receiptId: receiptId(run, best), status: staleReason ? "STALE" : best.status, staleReason };
750
+ }
751
+ export function checkDiagnostics(run, checkId, currentFingerprint) {
752
+ const all = (run.receipts || []).filter(r => r.checkId === checkId);
753
+ const e = effectiveReceipt(run, checkId, currentFingerprint);
754
+ const describe = r => describeReceipt(run, r, currentFingerprint);
755
+ const historicalReceipts = all.slice(-20).map(describe);
756
+ return { checkId, effectiveStatus: e?.status || "NOT_RUN", effectiveReceiptId: e && !e.staleReason ? e.receiptId : null,
757
+ selectedReceiptId: e?.receiptId || null, effectiveReceipt: e && !e.staleReason ? describe(e.receipt) : null,
758
+ historicalReceiptCount: all.length, historicalReceipts, historicalReceiptsTruncated: all.length > 20,
759
+ staleReason: e?.staleReason || null, receiptFingerprint: e?.receipt.sourceFingerprint || null,
760
+ currentFingerprint: currentHash(currentFingerprint) || null,
761
+ comparisonFingerprint: typeof currentFingerprint === "object" && currentFingerprint ? ((e?.receipt.fingerprintSchemaVersion || 1) === 1 ? currentFingerprint.legacyHash : currentFingerprint.hash) : currentFingerprint || null,
762
+ blockingReason: e?.status === "PASS" ? null : e?.staleReason || e?.status || "NO_RECEIPT" };
763
+ }
764
+ function describeReceipt(run, r, currentFingerprint) {
765
+ return { receiptId: receiptId(run, r), historicalStatus: r.status,
766
+ command: r.command ?? null, argv: r.argv ?? null, exitCode: r.exitCode ?? null,
767
+ testCount: r.testCount ?? null,
768
+ testCountMeaning: "Legacy acceptance count; zero can mean a failed or unrecognized summary, not necessarily zero discovered tests.",
769
+ reportedTests: reportedTestSummary(r.outputTail),
770
+ startedAt: r.startedAt ?? null, finishedAt: r.finishedAt ?? null,
771
+ versions: r.versions ?? null, fpScope: r.fpScope ?? null,
772
+ outputTail: typeof r.outputTail === "string" ? r.outputTail.slice(-6000) : null,
773
+ terminationReason: r.terminationReason ?? null, executionError: r.executionError ?? null,
774
+ order: (run.receipts || []).indexOf(r) + 1, createdAt: r.finishedAt ?? r.startedAt ?? null,
775
+ evidenceClass: r.evidenceClass || r.class || null, receiptFingerprint: r.sourceFingerprint || null,
776
+ classification: receiptEligibility(run, r, currentFingerprint) || "ELIGIBLE",
777
+ missingField: !r.sourceFingerprint ? "sourceFingerprint" : null };
778
+ }
779
+ export function receiptReadout(run, { currentFingerprint = run.sourceFingerprint, checkId, receiptId: selected, offset = 0, limit = 10 } = {}) {
780
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 20)
781
+ return { error: "INVALID_RECEIPT_PAGE", detail: "offset must be a nonnegative integer; limit must be 1..20" };
782
+ const all = (run.receipts || []).filter(r => checkId === undefined || r.checkId === checkId);
783
+ if (selected !== undefined) {
784
+ const r = all.find(r => receiptId(run, r) === selected);
785
+ return r ? { ok: true, runId: run.runId, checkId: r.checkId, receipt: describeReceipt(run, r, currentFingerprint) }
786
+ : { error: "RECEIPT_NOT_FOUND", runId: run.runId, receiptId: selected };
787
+ }
788
+ return { ok: true, runId: run.runId, checkId: checkId ?? null, total: all.length, offset, limit,
789
+ nextOffset: offset + limit < all.length ? offset + limit : null,
790
+ receipts: all.slice(offset, offset + limit).map(r => {
791
+ const { outputTail, ...metadata } = describeReceipt(run, r, currentFingerprint);
792
+ return { checkId: r.checkId, ...metadata, outputAvailable: typeof r.outputTail === "string" };
793
+ }), detail: "Metadata only; request action=receipts with receiptId for the recorded output tail. Historical status does not imply current eligibility." };
794
+ }
795
+ export function effectiveStatus(run, checkId, currentFingerprint) {
796
+ const e = effectiveReceipt(run, checkId, currentFingerprint);
797
+ return e ? e.status : "NOT_RUN";
798
+ }
799
+
800
+ // ---- Hard-gate evaluation (v1.2.3): recompute, never trust a cached gate status --------------
801
+ // A required gate maps to check(s): its explicit `checks` list, else — if the gate's own id is a
802
+ // declared check with receipts — [gate.id]. If it maps to NOTHING we fall back to the cached
803
+ // `status` (back-compat for hand-declared gates like "test-gate"/"render-gate" that carry no check
804
+ // and are satisfied by a human/external declaration). Otherwise the gate is satisfied only when
805
+ // EVERY mapped check's AUTHORITATIVE effective status is PASS on current source. A gate therefore
806
+ // needs its OWN passing (cmd) receipt — no fabricated test receipt, and no reliance on the loss
807
+ // number — and a passing build can complete a test-only run.
808
+ export function evaluateGate(run, gate, currentFingerprint) {
809
+ const receipts = (run && run.receipts) || [];
810
+ const checks = (Array.isArray(gate.checks) && gate.checks.length)
811
+ ? gate.checks.slice()
812
+ : ((run.checkCatalogue?.[gate.id] || receipts.some((r) => r && r.checkId === gate.id)) ? [gate.id] : []);
813
+ if (checks.length === 0) {
814
+ return { id: gate.id, checks: [], status: declarationOnly(run) ? gate.status || "FAIL" : "NOT_RUN", satisfied: declarationOnly(run) && statusSatisfies(gate.status), kind: "NO_MAPPED_CHECK", detail: declarationOnly(run) ? "contract-only declaration" : "gate declares no mapped check; recorded status is not evidence" };
815
+ }
816
+ const eff = checks.map((id) => ({ id, status: effectiveStatus(run, id, currentFingerprint) }));
817
+ const hard = eff.filter((e) => ["FAIL", "ERROR", "TIMEOUT", "BLOCKED"].includes(e.status));
818
+ if (hard.length) return { id: gate.id, checks, status: "FAIL", satisfied: false, kind: "HARD_GATE_FAILED", detail: hard };
819
+ const unverified = eff.filter((e) => e.status === "NOT_RUN" || e.status === undefined);
820
+ if (unverified.length) return { id: gate.id, checks, status: "NOT_RUN", satisfied: false, kind: "HARD_GATE_UNVERIFIED", detail: unverified };
821
+ const stale = eff.filter((e) => e.status === "STALE");
822
+ if (stale.length) return { id: gate.id, checks, status: "STALE", satisfied: false, kind: "STALE_EVIDENCE", detail: stale };
823
+ const pass = eff.filter((e) => e.status === "PASS");
824
+ const ok = pass.length === checks.length;
825
+ return { id: gate.id, checks, status: ok ? "PASS" : "FAIL", satisfied: ok, kind: ok ? "SATISFIED" : "HARD_GATE_FAILED", detail: eff };
826
+ }
827
+ // The recomputed gate table for a run (used by status, the verify readout AND completion).
828
+ export function gateStatuses(run, currentFingerprint) {
829
+ const fp = currentFingerprint != null ? currentFingerprint : (run && run.sourceFingerprint) || null;
830
+ return (run && run.contract && run.contract.gates || []).filter((g) => g.required).map((g) => evaluateGate(run, g, fp));
831
+ }
832
+
833
+ // ---- Test-count parsing (v1.2.3): supply discovered counts for genuine test runners ----------
834
+ // The controller must never rely on console optimism, but for a DECLARED test requirement it needs
835
+ // the discovered count to decide PASS vs a zero-test NOT_RUN. Best-effort extraction from common
836
+ // runner output (node:test "tests N", vitest "Tests N passed", playwright "N passed"). If a
837
+ // runner reports zero discovered tests the result is 0 (=> NOT_RUN). Returns null when no count is
838
+ // discoverable (caller then treats a clean exit as a plain cmd PASS, not a coverage requirement).
839
+ export function parseTestCounts(text) {
840
+ if (!text) return null;
841
+ const t = String(text);
842
+ let m = /(?:Tests\s+)(\d+)\s+failed/i.exec(t); if (m) return +m[1] > 0 ? 0 : (/Tests\s+\d+\s+passed/i.test(t) ? (+(/Tests\s+(\d+)\s+passed/i.exec(t)[1])) : null);
843
+ m = /Tests\s+(\d+)\s+passed/i.exec(t); if (m) return +m[1];
844
+ m = /ℹ\s+tests?\s+(\d+)/i.exec(t); if (m) return +m[1];
845
+ m = /(\d+)\s+(?:tests?|specs?)\s+(?:passed|ok)/i.exec(t); if (m) return +m[1];
846
+ m = /RESULT\s+pass=(\d+)/i.exec(t); if (m) return +m[1];
847
+ m = /(\d+)\s+pass(?:ed|ing)?\b/i.exec(t); if (m) return +m[1];
848
+ return null;
849
+ }
850
+
851
+ // Read-only runner-reported diagnostics, never an acceptance input. Historical receipts are
852
+ // not rewritten. Only a complete, internally consistent Vitest summary is recognized; absent
853
+ // categories stay unknown, and multiple summaries may be separate commands, so are declined.
854
+ function reportedTestSummary(outputTail) {
855
+ if (typeof outputTail !== "string") return null;
856
+ const text = outputTail.slice(-6000).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
857
+ const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => /^Tests\s/.test(line));
858
+ if (lines.length !== 1) return null;
859
+ const match = /^Tests\s+(.+?)\s+\((\d+)\)$/.exec(lines[0]);
860
+ if (!match) return null;
861
+ const total = Number(match[2]);
862
+ if (!Number.isSafeInteger(total)) return null;
863
+ const counts = { passed: null, failed: null, skipped: null, todo: null };
864
+ for (const part of match[1].split("|")) {
865
+ const item = /^(\d+)\s+(passed|failed|skipped|todo)$/.exec(part.trim());
866
+ if (!item || counts[item[2]] !== null || !Number.isSafeInteger(Number(item[1]))) return null;
867
+ counts[item[2]] = Number(item[1]);
868
+ }
869
+ if (Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0) !== total) return null;
870
+ return { source: "recorded_output_tail", runner: "vitest", total, ...counts, summary: lines[0] };
871
+ }
872
+
873
+ // ---- Structured tool-input normalization (v1.2.3) --------------------------------------------
874
+ // MTPLX sometimes sends a structured field as a JSON STRING and numeric/boolean params as strings.
875
+ // This is the SINGLE authoritative place that recognises those params (no per-action ad-hoc patch).
876
+ // It NEVER invents data: a non-string/non-JSON value is returned untouched; a malformed JSON string
877
+ // yields a safe fallback (empty for arrays/objects), so a bad value can never masquerade as a run.
878
+ function coerceList(v) { if (Array.isArray(v)) return v; if (typeof v === "string") { const t = v.trim(); if (t.startsWith("[") || t.startsWith("{")) { try { const p = JSON.parse(t); return Array.isArray(p) ? p : (p && typeof p === "object" ? [p] : []); } catch { return []; } } } return []; }
879
+ function coerceObj(v) { if (v && typeof v === "object" && !Array.isArray(v)) return v; if (typeof v === "string") { const t = v.trim(); if (t.startsWith("{")) { try { const p = JSON.parse(t); return (p && typeof p === "object" && !Array.isArray(p)) ? p : {}; } catch { return {}; } } } return {}; }
880
+ function num(v, fallback) { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : fallback; }
881
+ function bool(v) { return v === true || v === "true"; }
882
+ export function normalizeStartArgs(args = {}) {
883
+ const out = { ...args };
884
+ if (args.criteria !== undefined) out.criteria = coerceList(args.criteria);
885
+ if (args.checkCatalogue !== undefined) out.checkCatalogue = coerceObj(args.checkCatalogue);
886
+ if (args.hardGates !== undefined) out.hardGates = coerceList(args.hardGates);
887
+ if (args.candidateBudget !== undefined) out.candidateBudget = num(args.candidateBudget, undefined);
888
+ if (args.timeBudgetHours !== undefined) out.timeBudgetHours = num(args.timeBudgetHours, undefined);
889
+ if (args.sameFailureThreshold !== undefined) out.sameFailureThreshold = num(args.sameFailureThreshold, undefined);
890
+ if (args.noProgressThreshold !== undefined) out.noProgressThreshold = num(args.noProgressThreshold, undefined);
891
+ if (args.deadlineHours !== undefined) out.deadlineHours = num(args.deadlineHours, undefined);
892
+ if (args.toolActionCap !== undefined) out.toolActionCap = num(args.toolActionCap, undefined);
893
+ if (args.autoContinue !== undefined) out.autoContinue = bool(args.autoContinue);
894
+ return out;
895
+ }
896
+
897
+ export function validateBudgetArgs(args = {}) {
898
+ for (const name of ["candidateBudget", "timeBudgetHours", "deadlineHours", "toolActionCap", "sameFailureThreshold", "noProgressThreshold"]) {
899
+ if (args[name] === undefined) continue;
900
+ const n = Number(args[name]);
901
+ const integer = !["timeBudgetHours", "deadlineHours"].includes(name);
902
+ if (!(["number", "string"].includes(typeof args[name])) || !Number.isFinite(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER || integer && !Number.isSafeInteger(n))
903
+ return { error: "INVALID_BUDGET", detail: `${name} must be a finite positive ${integer ? "integer" : "number"}; no run created` };
904
+ }
905
+ return { ok: true };
906
+ }
907
+
908
+ // ---- v1.2.22 default evidence class ---------------------------------------------------------
909
+ // A declared check that a criterion maps to already declares the evidence strength that criterion
910
+ // needs. When the caller omits the optional per-call evidenceClass, derive it from the run's OWN
911
+ // contract so a whole-suite round cannot silently record classless receipts and block every
912
+ // criterion as UNKNOWN_CLASS despite green checks (observed twice: annotations lr-00000000a1b2 and
913
+ // another run lr-00000000c3d4). An explicit argument always wins; an ambiguous mapping (two criteria
914
+ // demanding different classes for the same check) or an unmapped check yields null so the caller
915
+ // must be explicit rather than guessed at.
916
+ export function defaultEvidenceClass(run, checkId, explicit) {
917
+ if (typeof explicit === "string" && explicit) return explicit;
918
+ const classes = new Set();
919
+ for (const criterion of run?.contract?.criteria || []) {
920
+ if (!Array.isArray(criterion.checks) || !criterion.checks.includes(checkId)) continue;
921
+ if (typeof criterion.evidenceClass === "string" && criterion.evidenceClass) classes.add(criterion.evidenceClass);
922
+ }
923
+ return classes.size === 1 ? [...classes][0] : null;
924
+ }
925
+
926
+ // ---- Evidence-strength gate (§7): a criterion is satisfied only when a PASS receipt exists
927
+ // whose evidence class is strong enough. A weaker/proxy class is a gap, not a pass.
928
+ export function criterionSatisfied(run, criterion, currentFingerprint = run.sourceFingerprint) {
929
+ if (declarationOnly(run)) return { satisfied: statusSatisfies(criterion.status), status: criterion.status, kind: "DECLARATION_ONLY" };
930
+ const checks = criterion.checks || [];
931
+ const effective = checks.map(id => effectiveReceipt(run, id, currentFingerprint));
932
+ if (!checks.length || effective.some(e => !e)) return { satisfied: false, status: "NOT_RUN", kind: "NO_RECEIPT" };
933
+ const blocked = effective.find(e => e.status !== "PASS");
934
+ if (blocked) return { satisfied: false, status: blocked.status, kind: blocked.staleReason || "NO_PASS" };
935
+ const adequate = effective.find(e => EV.classSatisfies(e.receipt.proxyOnly ? "object_exists" : e.receipt.evidenceClass || e.receipt.class, criterion).satisfied);
936
+ if (adequate) return { satisfied: true, status: "PASS", kind: "SATISFIED", receiptId: adequate.receiptId };
937
+ const gap = EV.classSatisfies(effective.at(-1).receipt.evidenceClass, criterion);
938
+ return { satisfied: false, status: "BLOCKED", kind: gap.kind || "EVIDENCE_GAP", note: gap.note };
939
+ }
940
+ export function evidenceStatus(run, currentFingerprint = run.sourceFingerprint) {
941
+ const failures = [], gaps = [];
942
+ for (const c of run.contract?.criteria || []) {
943
+ if (!c.required) continue;
944
+ const result = criterionSatisfied(run, c, currentFingerprint);
945
+ if (result.satisfied) continue;
946
+ if (["FAIL", "ERROR", "TIMEOUT"].includes(result.status)) failures.push({ criterion: c.id, status: result.status });
947
+ else gaps.push({ criterion: c.id, checks: c.checks || [], missing: [...new Set([c.evidenceClass, ...(c.requiresEvidence || [])].filter(Boolean))], note: result.kind });
948
+ }
949
+ return { failures, gaps };
950
+ }
951
+
952
+ // Append receipt history; cached contract statuses and legacy summaries are preserved verbatim.
953
+ export function applyVerification(run, { receipt, canChangeAcceptance = true, diagnosticOnly = false, hypothesis, elapsedMs = 0, currentFingerprint }) {
954
+ const fp = currentFingerprint || receipt?.sourceFingerprint || run.sourceFingerprint;
955
+ const before = deriveRunView(run, { currentFingerprint: fp });
956
+ run.receipts = run.receipts || []; run.receipts.push({ ...receipt, _validateAgainst: undefined });
957
+ const after = deriveRunView(run, { currentFingerprint: fp });
958
+ const changed = after.criterionStates.filter((c, i) => c.status !== before.criterionStates[i]?.status).map(c => c.id);
959
+ const foreign = !isProjectReceipt(receipt);
960
+ const cand = EV.considerCandidate(run, { fingerprint: receipt?.sourceFingerprint || run.sourceFingerprint,
961
+ fingerprintAliases: typeof fp === "object" ? [fp.legacyHash].filter(Boolean) : [],
962
+ evaluated: true, diagnosticOnly: diagnosticOnly || foreign, statusChanged: changed.length > 0, canChangeAcceptance,
963
+ receiptIds: [receiptId(run, run.receipts.at(-1))], criteriaChanged: changed, lossBefore: before.currentLoss,
964
+ lossAfter: after.currentLoss, result: receipt?.status, hypothesis, elapsedMs });
965
+ if (!foreign) run.sourceFingerprint = receipt?.sourceFingerprint || run.sourceFingerprint;
966
+ return { candidate: cand.candidate, counted: cand.counted, changed, lossBefore: before.currentLoss, lossAfter: after.currentLoss, gaps: after.evidenceGaps };
967
+ }
968
+
969
+ // Runs only a previously reserved command, then atomically writes genuine evidence.
970
+ // Its caller is a finite separate process so host death cannot erase completed I/O.
971
+ export async function executeReservedCheck(job, { ownerLost, aborted } = {}) {
972
+ const { stateDir, runKey, token, runId, checkId, cwd, projectRoot, fixture,
973
+ fpBefore, fpScope, targetFingerprint, productionBefore, evidenceClass, deadlineAt } = job;
974
+ const store = new Store(stateDir), found = store.readRun(runKey);
975
+ if (found.error) throw new Error(found.error);
976
+ const run = found.run, lease = run.execution?.inFlight;
977
+ // Older runs kept the declared catalogue in their session binding. The native
978
+ // reservation captures that resolved declaration without replacing the contract.
979
+ const check = run.checkCatalogue?.[checkId] || lease?.declaredCheck;
980
+ if (!lease || lease.token !== token || lease.executorPid !== process.pid || run.runId !== runId || lease.checkId !== checkId || !Array.isArray(check?.command))
981
+ throw new Error('EXECUTOR_RESERVATION_MISMATCH');
982
+ const negative = lease.mode === 'negative';
983
+ let stateWriteFailed = false;
984
+ const shouldStop = () => {
985
+ if (stateWriteFailed) return 'state_write_failed';
986
+ const current = store.readRun(runKey);
987
+ if (current.error) return 'state_read_failed';
988
+ const latest = current.run;
989
+ if (latest.execution?.inFlight?.token !== token) return 'reservation_changed';
990
+ if (latest.status === 'PAUSED') return 'run_paused';
991
+ if (RUN_TERMINAL_STATES.includes(latest.status)) return 'run_terminal';
992
+ if (latest.status === 'RECOVERY_REQUIRED') return 'recovery_required';
993
+ if ((latest.controlGeneration || 0) !== lease.generation) return 'control_generation_changed';
994
+ if (ownerLost?.()) return 'owner_lost';
995
+ if (aborted?.()) return 'aborted';
996
+ if (deadlineAt !== null && Date.now() >= deadlineAt) return 'deadline_budget';
997
+ return null;
998
+ };
999
+ const timeout = { ...job.timeout };
1000
+ if (deadlineAt !== null && deadlineAt - Date.now() < timeout.timeoutMs) {
1001
+ timeout.timeoutMs = Math.max(1, deadlineAt - Date.now()); timeout.timeoutReason = 'deadline_budget';
1002
+ }
1003
+ const r = await EXEC.runCommand(check.command, { cwd: negative ? fixture : cwd, env: process.env, ...timeout, shouldStop,
1004
+ onSpawn: pid => {
1005
+ const saved = store.mutate(runKey, latest => {
1006
+ if (latest.execution?.inFlight?.token !== token) return { error: 'RESERVATION_CHANGED' };
1007
+ latest.execution.inFlight.childPid = pid || null; return { ok: true };
1008
+ });
1009
+ stateWriteFailed = !!saved.error;
1010
+ },
1011
+ });
1012
+ const fp = sourceFingerprint(cwd);
1013
+ const timedOut = ['check_timeout', 'active_time_budget', 'deadline_budget'].includes(r.terminationReason);
1014
+ const invalid = r.error || r.signal || r.terminationReason || r.status === null;
1015
+ const exitCode = timedOut ? 'TIMEOUT' : invalid ? 'ERROR' : r.status;
1016
+ const output = r.stdout + r.stderr, outputTail = output.length > 6000 ? '...' + output.slice(-6000) : output;
1017
+ let receipt, negativeControl;
1018
+ if (negative) {
1019
+ const productionAfter = sourceFingerprint(projectRoot).hash;
1020
+ const observed = timedOut ? 'TIMEOUT' : invalid ? 'ERROR' : r.status === 0 ? 'PASS' : 'FAIL';
1021
+ negativeControl = EV.makeNegativeControl({ checkId, fixture, targetFingerprint, observed, ok: observed === 'FAIL',
1022
+ mutatedProduction: productionBefore !== productionAfter, command: check.command, exitCode: r.status,
1023
+ signal: r.signal, error: r.error || (r.terminationReason && !timedOut ? r.terminationReason : null),
1024
+ startedAt: r.startedAt, finishedAt: r.finishedAt, outputTail,
1025
+ productionFingerprintBefore: productionBefore, productionFingerprintAfter: productionAfter });
1026
+ negativeControl.terminationReason = r.terminationReason;
1027
+ } else {
1028
+ receipt = makeReceipt({ checkId, command: check.command.join(' '), exitCode, output,
1029
+ testCount: check.kind === 'test' ? (parseTestCounts(output) ?? 0) : undefined,
1030
+ requirementKind: check.kind || 'cmd', startedAt: r.startedAt, finishedAt: r.finishedAt,
1031
+ versions: { node: process.version }, contractHash: run.contractHash, evaluatorHash: run.evaluatorHash,
1032
+ sourceFingerprint: fpBefore.hash, fingerprintSchemaVersion: SOURCE_FINGERPRINT_SCHEMA,
1033
+ evidenceClass: evidenceClass && evidenceClass !== 'proxy' ? evidenceClass : undefined,
1034
+ proxyOnly: evidenceClass === 'proxy' || check.proxyOnly, fpScope });
1035
+ Object.assign(receipt, { argv: check.command, outputTail, terminationReason: r.terminationReason, executionError: r.error, executionToken: token });
1036
+ if (fp.hash !== fpBefore.hash) { receipt.status = 'STALE'; receipt.staleReason = 'SOURCE_CHANGED_DURING_CHECK'; }
1037
+ }
1038
+ const journal = { token, runId, checkId, receipt, negativeControl,
1039
+ result: { ...r, stdout: undefined, stderr: undefined }, fingerprintAfter: fp };
1040
+ store.writeJSON(runKey, `execution-${token}.json`, journal);
1041
+ return journal;
1042
+ }
1043
+
1044
+ // Validate a saved executor result before recovering it. This does not manufacture
1045
+ // missing evidence, rerun a command, or confer trust on an arbitrary state file.
1046
+ export function validateExecutionRecord(run, journal) {
1047
+ const lease = run.execution?.inFlight, r = journal?.result;
1048
+ const bad = detail => ({ error: "INVALID_EXECUTION_RECORD", detail });
1049
+ if (!lease || !journal || journal.token !== lease.token || journal.checkId !== lease.checkId ||
1050
+ (journal.runId !== undefined && journal.runId !== run.runId)) return bad("record does not match the reserved run/check/token");
1051
+ const check = run.checkCatalogue?.[lease.checkId] || lease.declaredCheck, negative = lease.mode === "negative";
1052
+ const record = negative ? journal.negativeControl : journal.receipt;
1053
+ if (!check || !r || !record || (negative ? journal.receipt : journal.negativeControl)) return bad("missing or conflicting command evidence");
1054
+ if (r.cleanupComplete !== true) return { error: "EXECUTION_CLEANUP_UNCONFIRMED", detail: "saved result does not confirm owned process cleanup" };
1055
+ if (EXEC.ownedWorkAlive(r.pid) || EXEC.ownedWorkAlive(lease.childPid)) return { error: "VERIFY_IN_FLIGHT" };
1056
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1057
+ const hash = x => typeof x === "string" && /^[a-f0-9]{64}$/.test(x);
1058
+ if (!Number.isFinite(r.startedAt) || !Number.isFinite(r.finishedAt) || r.finishedAt < r.startedAt ||
1059
+ r.startedAt < lease.startedAt || r.finishedAt > Date.now() ||
1060
+ record.startedAt !== r.startedAt || record.finishedAt !== r.finishedAt ||
1061
+ record.checkId !== lease.checkId || !hash(journal.fingerprintAfter?.hash) ||
1062
+ (r.pid !== null && (!Number.isInteger(r.pid) || r.pid <= 0)) ||
1063
+ (lease.childPid != null && lease.childPid !== r.pid)) return bad("invalid timing, process or fingerprint evidence");
1064
+ if (!(r.status === null || Number.isInteger(r.status) && (r.status >= 0 || r.error)) ||
1065
+ (r.pid === null && !r.error && !r.terminationReason) ||
1066
+ ![r.signal, r.error, r.terminationReason].every(x => x === null || typeof x === "string")) return bad("invalid process result");
1067
+ const timeout = ["check_timeout", "active_time_budget", "deadline_budget"].includes(r.terminationReason);
1068
+ const invalid = r.error || r.signal || r.terminationReason || r.status === null;
1069
+ const exitCode = timeout ? "TIMEOUT" : invalid ? "ERROR" : r.status;
1070
+ if (negative) {
1071
+ const observed = timeout ? "TIMEOUT" : invalid ? "ERROR" : r.status === 0 ? "PASS" : "FAIL";
1072
+ const changed = record.productionFingerprintBefore !== record.productionFingerprintAfter;
1073
+ const expected = EV.makeNegativeControl({ ...record, observed, ok: observed === "FAIL", mutatedProduction: changed });
1074
+ if (!same(record.command, check.command) || record.exitCode !== r.status || record.observed !== observed ||
1075
+ record.signal !== r.signal || record.terminationReason !== r.terminationReason ||
1076
+ record.error !== (r.error || (r.terminationReason && !timeout ? r.terminationReason : null)) ||
1077
+ !hash(record.targetFingerprint) || !hash(record.productionFingerprintBefore) || !hash(record.productionFingerprintAfter) ||
1078
+ record.expected !== "FAIL" || record.mutatedProduction !== changed || record.ok !== expected.ok || record.valid !== expected.valid ||
1079
+ record.executionToken !== undefined && record.executionToken !== lease.token) return bad("negative-control record disagrees with execution");
1080
+ } else {
1081
+ const status = makeReceipt({ exitCode, requirementKind: check.kind || "cmd", testCount: record.testCount }).status;
1082
+ const changed = journal.fingerprintAfter.hash !== record.sourceFingerprint;
1083
+ if (!same(record.argv, check.command) || record.command !== check.command.join(" ") ||
1084
+ record.executionToken !== lease.token || record.exitCode !== exitCode || record.executionError !== r.error ||
1085
+ record.terminationReason !== r.terminationReason || !hash(record.sourceFingerprint) ||
1086
+ record.fingerprintSchemaVersion !== SOURCE_FINGERPRINT_SCHEMA ||
1087
+ record.contractHash !== run.contractHash || record.evaluatorHash !== run.evaluatorHash ||
1088
+ !["project", "copy"].includes(record.fpScope) || record.status !== (changed ? "STALE" : status) ||
1089
+ changed && record.staleReason !== "SOURCE_CHANGED_DURING_CHECK") return bad("receipt disagrees with execution or acceptance mapping");
1090
+ }
1091
+ return { ok: true };
1092
+ }
1093
+
1094
+ // Caller holds Store.mutate's writer lock. Both the original executor and an
1095
+ // explicit reconciliation use this single commit path, so attempts/time/results
1096
+ // cannot be counted twice even when the executor retries after recovery.
1097
+ export function commitExecutionResult(run, journal, { currentFingerprint = journal.fingerprintAfter } = {}) {
1098
+ const { token, receipt, negativeControl, result: r } = journal;
1099
+ const existing = [...(run.receipts || []), ...(run.evidence || [])].find(x => x.executionToken === token);
1100
+ if (existing) return { ok: true, alreadyRecorded: true, apply: { candidate: { counted: false }, gaps: [] }, candidateCount: candidateCount(run), usage: EXEC.usage(run) };
1101
+ if (run.execution?.inFlight?.token !== token) return { error: "RESERVATION_CHANGED" };
1102
+ const usage = EXEC.initialize(run);
1103
+ usage.verificationMs += Math.max(0, r.finishedAt - r.startedAt);
1104
+ usage.inFlight = r.cleanupComplete === false ? { ...usage.inFlight, recoveryRequired: "child_cleanup_failed" } : null;
1105
+ let apply;
1106
+ if (negativeControl) { run.evidence = run.evidence || []; run.evidence.push({ ...negativeControl, executionToken: token }); }
1107
+ else apply = applyVerification(run, { receipt, canChangeAcceptance: true, diagnosticOnly: !r.pid,
1108
+ hypothesis: run.checkCatalogue?.[journal.checkId]?.hypothesis, elapsedMs: r.finishedAt - r.startedAt, currentFingerprint });
1109
+ return { ok: true, apply, candidateCount: candidateCount(run), usage: EXEC.usage(run) };
1110
+ }
1111
+
1112
+ // ---- Single-flight scheduling + generation guard (§8)
1113
+ // Event dedup, per-run single-flight, current-session generation/message guard.
1114
+ export class Scheduler {
1115
+ constructor(store) { this.store = store; this.inFlight = new Set(); }
1116
+ // Returns {dispatch:boolean, reason} . Guards: idle-only is never authorisation; ignore
1117
+ // helper/child sessions; dedupe; generation guard; single-flight.
1118
+ // (Default deployment keeps AUTO disabled in the plugin; this is the safe core + fixtures.)
1119
+ requestContinuation({ runKey, eventId, sessionID, generation, isHelper, state, autoEnabled, pendingGeneration, messageAuthorised }) {
1120
+ if (!autoEnabled) return { dispatch: false, reason: "auto_disabled" };
1121
+ if (isHelper) return { dispatch: false, reason: "helper_session" };
1122
+ if (state !== "IMPLEMENTING" && state !== "VERIFYING" && state !== "REPAIRING") return { dispatch: false, reason: "state_not_continuable" };
1123
+ if (!messageAuthorised) return { dispatch: false, reason: "idle_not_authorisation" };
1124
+ if (generation != null && pendingGeneration != null && generation !== pendingGeneration) return { dispatch: false, reason: "generation_guard" };
1125
+ const dk = `cont:${eventId || ""}:${sessionID}:${generation ?? ""}`;
1126
+ if (!this.store.appendEvent(runKey, { type: "continuation", dedupeKey: dk, at: Date.now() })) return { dispatch: false, reason: "duplicate_event" };
1127
+ if (this.inFlight.has(runKey)) return { dispatch: false, reason: "single_flight" };
1128
+ this.inFlight.add(runKey);
1129
+ return { dispatch: true, reason: "scheduled" };
1130
+ }
1131
+ completeDispatch(runKey) { this.inFlight.delete(runKey); }
1132
+ cancelAll() { this.inFlight.clear(); }
1133
+ }
1134
+
1135
+ // ---- Budgets (§8) + counters that persist (store-backed by caller).
1136
+ export function defaultBudget() {
1137
+ // autoDispatchCap bounds automatic continuation dispatches. Automatic continuation is
1138
+ // DISABLED unless a run explicitly opts in (see isAutoAllowed). 12/6 are commissioning caps.
1139
+ return { iterations: 40, activeSeconds: 4 * 3600, deadlineSeconds: 8 * 3600, sameFailureLimit: 3, noProgressLimit: 5, autoDispatchCap: 40, toolActionCap: 200 };
1140
+ }
1141
+ // Commissioning defaults are tighter; used only for an explicitly authorised disposable run.
1142
+ export function commissioningBudget() {
1143
+ return { iterations: 12, activeSeconds: 4 * 3600, deadlineSeconds: 8 * 3600, sameFailureLimit: 3, noProgressLimit: 5, autoDispatchCap: 6, toolActionCap: 200 };
1144
+ }
1145
+ // Automatic continuation gate: DEFAULT OFF. Only a run that BOTH opted in AND is flagged
1146
+ // commissioning may dispatch automatically; ordinary chat / inactive project can never.
1147
+ export function isAutoAllowed(run) {
1148
+ if (!run) return false;
1149
+ if (!run.autoEnabled) return false;
1150
+ if (run.runMode !== "commissioning") return false;
1151
+ const s = run.state || {};
1152
+ const cap = (run.budget && run.budget.autoDispatchCap) ?? 40;
1153
+ if ((s.autoDispatches || 0) >= cap) return false;
1154
+ return true;
1155
+ }
1156
+ export function budgetState(budget, counters, nowMs, startedAtMs) {
1157
+ const spent = {};
1158
+ spent.iterations = counters.iterations >= budget.iterations;
1159
+ spent.activeSeconds = (counters.activeMs || 0) / 1000 >= budget.activeSeconds;
1160
+ spent.deadline = (nowMs - startedAtMs) / 1000 >= budget.deadlineSeconds;
1161
+ spent.sameFailure = (counters.sameFailureStreak || 0) >= budget.sameFailureLimit;
1162
+ spent.noProgress = (counters.noProgressStreak || 0) >= budget.noProgressLimit;
1163
+ spent.autoTurns = (counters.autoTurns || 0) >= (budget.autoTurnCap ?? 40);
1164
+ spent.autoDispatch = (counters.autoDispatches || 0) >= (budget.autoDispatchCap ?? 40);
1165
+ spent.toolActions = (counters.toolActions || 0) >= (budget.toolActionCap ?? 200);
1166
+ return { exhausted: Object.values(spent).some(Boolean), spent };
1167
+ }
1168
+
1169
+ // Same materially equivalent failure 3x => require new evidence + replan (NEEDS_REPLAN).
1170
+ // 5 evaluated candidates without meaningful progress => one bounded replan then pause.
1171
+ export function afterEvaluation(run, { progress, fingerprint, failureSignature }) {
1172
+ const s = run.status;
1173
+ const st = run.state;
1174
+ if (!progress) {
1175
+ st.noProgressStreak = (st.noProgressStreak || 0) + 1;
1176
+ } else {
1177
+ st.noProgressStreak = 0;
1178
+ }
1179
+ if (failureSignature && failureSignature === st.lastFailureSignature) st.sameFailureStreak = (st.sameFailureStreak || 0) + 1;
1180
+ else { st.sameFailureStreak = failureSignature ? 1 : 0; st.lastFailureSignature = failureSignature || null; }
1181
+ st.iterations = (st.iterations || 0) + 1;
1182
+ const budget = run.budget || defaultBudget();
1183
+ if (st.sameFailureStreak >= budget.sameFailureLimit) return { next: "NEEDS_REPLAN", reason: "same_failure_3x" };
1184
+ if (st.noProgressStreak >= budget.noProgressLimit) {
1185
+ if (!st.replanned) { st.replanned = true; return { next: "NEEDS_REPLAN", reason: "no_progress_replan" }; }
1186
+ return { next: "PAUSED", reason: "still_stalled" };
1187
+ }
1188
+ return { next: s, reason: "continue" };
1189
+ }
1190
+
1191
+ // ---- Best vs current experiment tracking (§8). Keep best verified candidate distinct.
1192
+ export function recordCandidate(run, cand) {
1193
+ const st = run.state;
1194
+ st.candidates = st.candidates || [];
1195
+ st.candidates.push(cand);
1196
+ if (cand.verified && (!st.best || cand.loss <= st.best.loss)) st.best = cand;
1197
+ st.current = cand;
1198
+ }
1199
+
1200
+ // ---- Resume authorization (§4,§8): fabricated assistant text or text in a repo file must NOT resume.
1201
+ // Only a user-authorised CLI/session entry may transition PAUSED/cancelled -> active.
1202
+ export function canResume(run, origin) {
1203
+ if (origin === "user_cli" || origin === "user_session_command") return { ok: true };
1204
+ // A repo file, assistant prose, or a stray event cannot resume a cancelled/paused run.
1205
+ if (origin === "assistant_text" || origin === "repo_file_text" || origin === "event") return { ok: false, reason: "not_user_authorised" };
1206
+ return { ok: false, reason: "unknown_origin" };
1207
+ }
1208
+
1209
+ // Advisory working context has its own whitelist and history. It cannot write status,
1210
+ // acceptance, receipts, budgets or counters. Legacy runs are unchanged until explicitly saved.
1211
+ export const LIFECYCLE_GUIDANCE = "Continuation OFF disables automatic scheduling; it does not mean the run is PAUSED. Only the canonical state reports lifecycle. checkpoint saves advisory context without pausing. When authorized work is finished or must stop, explicitly call pause(runId), then confirm state with status(runId). toolActionCap counts declared-check attempts, not help/status/checkpoint calls or all host tools.";
1212
+ const PROGRESS_SCALAR_LIMITS = { currentSlice: 240, nextAction: 1000 };
1213
+ const PROGRESS_LIST_FIELDS = ["decisions", "failedHypotheses", "memoryNodes", "artifacts"];
1214
+ export function progressSchema() {
1215
+ return {
1216
+ accepts: "object or JSON-encoded object; at least one supported field",
1217
+ fields: {
1218
+ ...Object.fromEntries(Object.entries(PROGRESS_SCALAR_LIMITS).map(([key, maxLength]) => [key, { type: "string", maxLength }])),
1219
+ ...Object.fromEntries(PROGRESS_LIST_FIELDS.map(key => [key, { type: "array", maxItems: 12, itemType: "string", itemMaxLength: 500 }])),
1220
+ },
1221
+ maxCombinedCharacters: 6000, maxSerializedCharacters: 24000,
1222
+ memoryNodes: "relative project paths without parent traversal",
1223
+ merge: "Partial fields merge; arrays replace. Advisory only; no status, receipts, budgets or acceptance fields.",
1224
+ example: { currentSlice: "Describe the actual work", nextAction: "Describe the remaining action" },
1225
+ };
1226
+ }
1227
+ export function saveAgentProgress(run, progress, { sessionID = null, fingerprint = null, at = Date.now() } = {}) {
1228
+ const scalarLimits = PROGRESS_SCALAR_LIMITS;
1229
+ const listFields = PROGRESS_LIST_FIELDS;
1230
+ // Called under the canonical writer lock by the native path. No mutation has
1231
+ // occurred on these returns; report the state actually inspected, not prose.
1232
+ const invalid = detail => ({ error: "INVALID_PROGRESS", detail, runId: run.runId || null,
1233
+ state: run.status, continuation: false, unchanged: true, progressSchema: progressSchema(),
1234
+ lifecycleGuidance: LIFECYCLE_GUIDANCE,
1235
+ correction: "Correct only progress using the supported fields. Do not rerun checks, rewrite state files or reset the run to repair this payload." });
1236
+ if (typeof progress === "string") {
1237
+ if (progress.length > 24000) return invalid("serialized progress is too large");
1238
+ try { progress = JSON.parse(progress); } catch { return invalid("progress must be a JSON object or a valid JSON-encoded object"); }
1239
+ }
1240
+ if (!progress || typeof progress !== "object" || Array.isArray(progress)) return invalid("progress must be an object");
1241
+ if (!Object.keys(progress).length) return invalid("provide at least one progress field");
1242
+ for (const [key, value] of Object.entries(progress)) {
1243
+ if (Object.hasOwn(scalarLimits, key)) {
1244
+ if (typeof value !== "string" || value.length > scalarLimits[key]) return invalid(`${key} must be a string of at most ${scalarLimits[key]} characters`);
1245
+ } else if (listFields.includes(key)) {
1246
+ if (!Array.isArray(value) || value.length > 12 || value.some(x => typeof x !== "string" || x.length > 500)) return invalid(`${key} needs at most 12 strings of at most 500 characters`);
1247
+ if (key === "memoryNodes" && value.some(x => !x || path.isAbsolute(x) || x.split(/[\\/]/).includes(".."))) return invalid("memoryNodes must be relative project paths without parent traversal");
1248
+ } else return invalid(`unsupported progress field: ${key}`);
1249
+ }
1250
+ const fields = { ...(run.agentProgress?.fields || {}), ...structuredClone(progress) };
1251
+ if (JSON.stringify(fields).length > 6000) return invalid("combined progress exceeds 6000 characters; keep it concise and link artifacts");
1252
+ const record = { schemaVersion: 1, revision: (run.agentProgress?.revision || 0) + 1, at, sessionID, sourceFingerprint: fingerprint, fields };
1253
+ run.agentProgressHistory = [...(run.agentProgressHistory || []), record];
1254
+ run.agentProgress = record;
1255
+ return { ok: true, progress: record };
1256
+ }
1257
+
1258
+ // ---- Recovery packet (§9): machine facts plus explicitly advisory working context.
1259
+ export function buildRecoveryPacket(run, limitWords = 1500, view = deriveRunView(run)) {
1260
+ const c = run.contract || {};
1261
+ const st = run.state || {};
1262
+ const remaining = view.remaining;
1263
+ const gaps = view.evidenceGaps;
1264
+ const candCount = EV.candidateCount(run);
1265
+ const candBudget = EXEC.effectiveIterations(run);
1266
+ const originalIterations = Number.isFinite(run.budget?.iterations) ? run.budget.iterations : defaultBudget().iterations;
1267
+ const allowance = candBudget - originalIterations;
1268
+ const lines = [];
1269
+ lines.push(`STATE: ${run.status}`);
1270
+ const timing = view.timing || EXEC.timing(run);
1271
+ lines.push(`OBSERVED AT: ${new Date(timing.observedAt).toISOString()} (snapshot; time continues during inference and compaction)`);
1272
+ lines.push(`DEADLINE: ${timing.deadlineAt === null ? "UNKNOWN (original timestamp or duration missing/invalid; not reconstructed)" : new Date(timing.deadlineAt).toISOString()} (absolute wall clock; never reset)`);
1273
+ lines.push(`DEADLINE REMAINING: ${timing.remainingMs === null ? "UNKNOWN" : `${timing.remainingMs} ms at observation`}`);
1274
+ if (timing.expired) lines.push("DEADLINE EXPIRED: no further implementation or checks; checkpoint the incomplete outcome and pause. Do not reset budgets, bypass via another tool/session or create a replacement run.");
1275
+ lines.push(`CONTRACT HASH: ${run.contractHash || "?"} EVALUATOR HASH: ${run.evaluatorHash || "?"}`);
1276
+ lines.push(`SOURCE FINGERPRINT: ${view.currentFingerprint || "(unknown)"}${run.sourceFingerprintStale ? " [STALE: source changed since last verified]" : ""}`);
1277
+ lines.push(`REMAINING CRITERIA: ${remaining.length ? remaining.join(", ") : "(none)"}`);
1278
+ lines.push(`EVIDENCE GAPS: ${gaps.length ? gaps.map((g) => `${g.criterion}<-${g.missing.join("+")}`).join(", ") : "(none)"}`);
1279
+ lines.push(`CANDIDATES: ${candCount}/${candBudget}${allowance > 0 ? ` (original ${originalIterations} + operator-authorized ${allowance})` : ""} (exact, persisted; never reset by a new conversation)`);
1280
+ if ((run.budgetAmendments || []).length) lines.push(`OPERATOR BUDGET AMENDMENTS: ${run.budgetAmendments.length} recorded. Original deadline ${timing.originalDeadlineAt === null ? "UNKNOWN" : new Date(timing.originalDeadlineAt).toISOString()}; effective deadline ${timing.deadlineAt === null ? "UNKNOWN" : new Date(timing.deadlineAt).toISOString()}. Original limits/usage/history preserved; any prior completion approval is invalidated; a grant never resumes the run.`);
1281
+ lines.push(`VERIFIED EVIDENCE REFS: ${view.effectiveReceiptRefs.join(", ") || "(none currently eligible)"}`);
1282
+ lines.push(`HISTORICAL RECEIPTS: ${view.historicalReceiptCount} (retained; inspect status.checks for eligibility)`);
1283
+ lines.push(`HISTORICAL EVIDENCE REFS: ${view.checks.map(c => c.selectedReceiptId).filter(Boolean).join(", ") || "(none recorded)"}`);
1284
+ lines.push(`BEST LOSS: ${view.bestLoss ?? "n/a"} CURRENT LOSS: ${view.currentLoss ?? "n/a"}`);
1285
+ lines.push(`TARGET LOSS: ${view.targetLoss} COMPLETION BLOCKED: ${view.completionBlocked} REASON: ${view.blockReason || "none"}`);
1286
+ if (view.completionReview?.required) lines.push(`COMPLETION REVIEW: ${view.completionReview.status}. Declared checks ready: ${view.declaredChecksReady}. Pause for independent operator review; never self-approve through a shell command or checkpoint. A zero declared-check loss is not full-scope acceptance.`);
1287
+ lines.push(`HARD GATE BLOCKERS: ${view.hardGateBlockers.map(g => g.id).join(", ") || "(none)"}`);
1288
+ lines.push(`MEMORY STATUS: ${view.memoryStatus}; ${view.memory.reasons.join("; ")}`);
1289
+ const progress = run.agentProgress?.fields || st;
1290
+ lines.push(`AGENT PROGRESS (advisory; not verification evidence): ${run.agentProgress ? `revision ${run.agentProgress.revision}, saved ${new Date(run.agentProgress.at).toISOString()}; source ${run.agentProgress.sourceFingerprint || "unknown"}` : "legacy fields or not yet saved; use checkpoint(progress=...)"}`);
1291
+ lines.push(`CURRENT SLICE: ${progress.currentSlice || "(none)"}`);
1292
+ lines.push(`DECISIONS/INVARIANTS: ${(progress.decisions || []).join("; ") || "(none)"}`);
1293
+ lines.push(`MEMORY NODES (reload lazily, do NOT dump all): ${[...new Set([...(view.memory.reloadableNodes || []), ...(progress.memoryNodes || run.memoryNodes || [])])].join(", ") || "(root only)"}`);
1294
+ lines.push(`UNSUCCESSFUL APPROACHES: ${(progress.failedHypotheses || []).join("; ") || "(none)"}`);
1295
+ lines.push(`NEXT ACTION: ${progress.nextAction || "(recompute)"}`);
1296
+ lines.push(`ARTIFACT REFERENCES (inspect; not proof by themselves): ${(progress.artifacts || []).join(", ") || "(none)"}`);
1297
+ lines.push(`BUDGETS (persisted): iters=${st.iterations || 0} noProgress=${st.noProgressStreak || 0} sameFailure=${st.sameFailureStreak || 0} (legacy counters; not execution authority)`);
1298
+ const execution = EXEC.usage(run);
1299
+ lines.push(`CHECK EXECUTION: candidates=${view.candidateCount}; commands=${execution.commandAttempts}; verificationMs=${execution.verificationMs}; historicalUsageUnknown=${execution.historicalUsageUnknown}; total agent time/actions unmeasured. In-flight=${execution.inFlight?.token || "none"}.`);
1300
+ // Put the authoritative reference/status and working context before a potentially very
1301
+ // long original request. Preserve the full request in the record, never relabel an excerpt
1302
+ // as verbatim or silently discard the next action behind a request-sized prefix.
1303
+ lines.push(`ORIGINAL GOAL (verbatim): ${run.originalRequest || "(none)"}`);
1304
+ const out = lines.join("\n");
1305
+ const words = out.split(/\s+/).filter(Boolean);
1306
+ if (words.length > limitWords) {
1307
+ const marked = out.replace("ORIGINAL GOAL (verbatim):", "ORIGINAL GOAL EXCERPT (full verbatim text remains in run.json):");
1308
+ const marker = "[truncated; inspect run.json for full context]";
1309
+ const available = Math.max(0, limitWords - marker.split(/\s+/).length);
1310
+ const packet = marked.split(/\s+/).filter(Boolean).slice(0, available).join(" ") + "\n" + marker;
1311
+ return { packet, words: packet.split(/\s+/).filter(Boolean).length, truncated: true, view };
1312
+ }
1313
+ return { packet: out, words: words.length, truncated: false, view };
1314
+ }
1315
+
1316
+ export const __test = { sha256, isExcluded };
1317
+
1318
+ // ---- Re-exports so plugin + CLI can use ONE import surface (controller) -------------------
1319
+ export const memory = MEM;
1320
+ export const evidence = EV;
1321
+ export const execution = EXEC;
1322
+
1323
+ // A fixture must be physically separate from the project. Resolve aliases and inspect
1324
+ // dependencies too: a copied directory with node_modules pointing back is not isolated.
1325
+ // This is a preflight integrity check, not an OS sandbox for arbitrary catalogue commands.
1326
+ export function validateNegativeFixture(fixture, projectDirectories = [], { entryBudget = 200000 } = {}) {
1327
+ const reject = detail => ({ ok: false, error: "FIXTURE_NOT_ISOLATED", detail });
1328
+ const inside = (root, target) => { const rel = path.relative(root, target); return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)); };
1329
+ let root;
1330
+ try {
1331
+ root = fs.realpathSync(fixture);
1332
+ if (!fs.statSync(root).isDirectory()) return reject("fixture must be a directory");
1333
+ for (const dir of projectDirectories.filter(Boolean)) {
1334
+ const project = fs.realpathSync(dir);
1335
+ // A filesystem root (e.g. a host that reports context.worktree="/") contains every possible
1336
+ // path, so it conveys NO isolation information. Treating it as a project directory rejected
1337
+ // every fixture, including one outside $HOME. Real nested/overlapping directories are still
1338
+ // refused below; the refusal now names the offending directory.
1339
+ if (path.parse(project).root === project) continue;
1340
+ if (inside(project, root)) return reject(`fixture and production project overlap: fixture ${root} is inside project directory ${project}`);
1341
+ if (inside(root, project)) return reject(`fixture and production project overlap: fixture ${root} contains project directory ${project}`);
1342
+ }
1343
+ const stack = [root];
1344
+ let count = 0;
1345
+ while (stack.length) {
1346
+ const directory = stack.pop();
1347
+ for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
1348
+ if (++count > entryBudget) return reject("fixture isolation scan exceeded its entry budget");
1349
+ const file = path.join(directory, item.name);
1350
+ const stat = fs.lstatSync(file);
1351
+ if (stat.isSymbolicLink()) {
1352
+ const target = fs.realpathSync(file);
1353
+ if (!inside(root, target)) return reject(`fixture symlink escapes its root: ${path.relative(root, file)}`);
1354
+ } else if (stat.isDirectory()) stack.push(file);
1355
+ else if (stat.isFile() && stat.nlink > 1) return reject(`fixture contains a shared hard link: ${path.relative(root, file)}`);
1356
+ else if (!stat.isFile()) return reject(`fixture contains a special file: ${path.relative(root, file)}`);
1357
+ }
1358
+ }
1359
+ return { ok: true, fixture: root, inspectedEntries: count };
1360
+ } catch (error) { return reject(`cannot establish fixture isolation: ${error.code || error.message}`); }
1361
+ }
1362
+ export const candidateCount = (run) => EV.candidateCount(run);
1363
+ export const classSatisfies = (a, b) => EV.classSatisfies(a, b);
1364
+
1365
+ // ---- Authoritative run construction (§5): `start` must create a REAL run ------------------
1366
+ // Builds a stable contract from declared criteria (with evidence classes + gates) and an initial
1367
+ // run record. It does NOT fabricate a PASS anywhere; initial loss reflects unverified criteria.
1368
+ export function makeContract({ criteria = [], hardGates = [], lossTarget = 0 } = {}) {
1369
+ const crit = criteria.map((c, i) => {
1370
+ const id = c.id || `c${i + 1}`;
1371
+ return { id, required: c.required !== false, weight: c.weight ?? 1, checks: c.checks || [], evidenceClass: c.evidenceClass || null, visual: !!c.visual, status: "FAIL" };
1372
+ });
1373
+ const gates = hardGates.map((g, i) => typeof g === "string" ? { id: g, required: true, status: "FAIL" } : { ...g, id: g.id || `g${i + 1}`, required: g.required !== false, status: g.status || "FAIL" });
1374
+ return { criteria: crit, gates, lossTarget };
1375
+ }
1376
+ export function startRun({ request, contract, budgets, sourceFingerprint, memoryStatus, continuation = false, runId, directory, checkCatalogue = {} }) {
1377
+ const loss = defaultLoss(contract);
1378
+ if (loss.error) return { error: loss.error, hint: "a run needs >=1 required criterion with a positive weight" };
1379
+ const run = {
1380
+ runId: runId || ("lr-" + crypto.randomBytes(6).toString("hex")),
1381
+ status: "IMPLEMENTING",
1382
+ originalRequest: request || "",
1383
+ contract, contractHash: sha256(JSON.stringify(contract)),
1384
+ evaluatorHash: null,
1385
+ sourceFingerprint: sourceFingerprint || null,
1386
+ loss: loss.loss,
1387
+ budget: budgets || defaultBudget(),
1388
+ autoEnabled: !!continuation, runMode: "tracked",
1389
+ memoryStatus: memoryStatus || "UNKNOWN",
1390
+ state: { iterations: 0, noProgressStreak: 0, sameFailureStreak: 0, candidates: [], lastEvalFingerprint: sourceFingerprint || null, memoryNodes: ["AGENTS.md"] },
1391
+ receipts: [], faults: [],
1392
+ execution: { schemaVersion: 1, since: Date.now(), historicalUsageUnknown: false, verificationMs: 0, commandAttempts: 0, inFlight: null },
1393
+ directory: directory || null, checkCatalogue,
1394
+ createdAt: Date.now(),
1395
+ };
1396
+ return { run, initialLoss: loss.loss, contractHash: run.contractHash };
1397
+ }
1398
+
1399
+ // ---- Ledger / schema health for `doctor` ---------------------------------------------------
1400
+ export function runHealth(run) {
1401
+ if (!run) return { ok: true, state: "NO_RUN", candidateLedger: "empty", evidence: "n/a" };
1402
+ const st = run.state || {};
1403
+ const ledger = (st.candidates || []);
1404
+ const badCandidate = ledger.find((c) => c.counted && (c.fingerprint == null || c.lossBefore == null && c.lossAfter != null));
1405
+ const ev = evidenceStatus(run);
1406
+ return {
1407
+ ok: !badCandidate,
1408
+ state: run.status,
1409
+ candidateLedger: `${EV.candidateCount(run)} counted / ${ledger.length} recorded`,
1410
+ evidence: `${ev.gaps.length} gaps; ${ev.failures.length} failures`,
1411
+ issues: [badCandidate ? "candidate ledger inconsistency" : null, ...ev.gaps.map((g) => `gap ${g.criterion}`)].filter(Boolean),
1412
+ };
1413
+ }