@markstanden/opencode-test-container 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/container/Containerfile +24 -0
  4. package/container/README.md +29 -0
  5. package/dist/adapters/opencode/events.d.ts +90 -0
  6. package/dist/adapters/opencode/events.js +213 -0
  7. package/dist/adapters/opencode/runtime.d.ts +87 -0
  8. package/dist/adapters/opencode/runtime.js +136 -0
  9. package/dist/adapters/podman/container.d.ts +62 -0
  10. package/dist/adapters/podman/container.js +119 -0
  11. package/dist/adapters/podman/image.d.ts +50 -0
  12. package/dist/adapters/podman/image.js +124 -0
  13. package/dist/adapters/process.d.ts +38 -0
  14. package/dist/adapters/process.js +71 -0
  15. package/dist/campaign/assess.d.ts +45 -0
  16. package/dist/campaign/assess.js +208 -0
  17. package/dist/campaign/close.d.ts +17 -0
  18. package/dist/campaign/close.js +112 -0
  19. package/dist/campaign/evidence-guard.d.ts +24 -0
  20. package/dist/campaign/evidence-guard.js +49 -0
  21. package/dist/campaign/freeze.d.ts +94 -0
  22. package/dist/campaign/freeze.js +297 -0
  23. package/dist/campaign/inventory.d.ts +62 -0
  24. package/dist/campaign/inventory.js +192 -0
  25. package/dist/campaign/judge.d.ts +209 -0
  26. package/dist/campaign/judge.js +1285 -0
  27. package/dist/campaign/ledger.d.ts +64 -0
  28. package/dist/campaign/ledger.js +149 -0
  29. package/dist/campaign/prepare.d.ts +129 -0
  30. package/dist/campaign/prepare.js +483 -0
  31. package/dist/campaign/primary.d.ts +96 -0
  32. package/dist/campaign/primary.js +189 -0
  33. package/dist/campaign/run.d.ts +151 -0
  34. package/dist/campaign/run.js +562 -0
  35. package/dist/campaign/status.d.ts +76 -0
  36. package/dist/campaign/status.js +237 -0
  37. package/dist/cli/otc.d.ts +51 -0
  38. package/dist/cli/otc.js +435 -0
  39. package/dist/contracts/canonical.d.ts +21 -0
  40. package/dist/contracts/canonical.js +44 -0
  41. package/dist/contracts/types.d.ts +261 -0
  42. package/dist/contracts/types.js +9 -0
  43. package/dist/contracts/validate.d.ts +72 -0
  44. package/dist/contracts/validate.js +1021 -0
  45. package/dist/contracts/version.d.ts +44 -0
  46. package/dist/contracts/version.js +147 -0
  47. package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
  48. package/dist/evaluators/docs/analysis/accounting.js +237 -0
  49. package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
  50. package/dist/evaluators/docs/analysis/blinding.js +80 -0
  51. package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
  52. package/dist/evaluators/docs/analysis/bounds.js +31 -0
  53. package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
  54. package/dist/evaluators/docs/analysis/budget.js +305 -0
  55. package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
  56. package/dist/evaluators/docs/analysis/closure.js +251 -0
  57. package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
  58. package/dist/evaluators/docs/analysis/decision.js +161 -0
  59. package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
  60. package/dist/evaluators/docs/analysis/freeze.js +395 -0
  61. package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
  62. package/dist/evaluators/docs/analysis/guardrails.js +194 -0
  63. package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
  64. package/dist/evaluators/docs/analysis/journal.js +475 -0
  65. package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
  66. package/dist/evaluators/docs/analysis/ledger.js +145 -0
  67. package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
  68. package/dist/evaluators/docs/analysis/measures.js +1069 -0
  69. package/dist/evaluators/docs/analysis/model.d.ts +236 -0
  70. package/dist/evaluators/docs/analysis/model.js +740 -0
  71. package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
  72. package/dist/evaluators/docs/analysis/pairing.js +95 -0
  73. package/dist/evaluators/docs/analysis/records.d.ts +229 -0
  74. package/dist/evaluators/docs/analysis/records.js +935 -0
  75. package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
  76. package/dist/evaluators/docs/analysis/recovery.js +507 -0
  77. package/dist/evaluators/docs/analysis/render.d.ts +235 -0
  78. package/dist/evaluators/docs/analysis/render.js +566 -0
  79. package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
  80. package/dist/evaluators/docs/analysis/replay.js +1272 -0
  81. package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
  82. package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
  83. package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
  84. package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
  85. package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
  86. package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
  87. package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
  88. package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
  89. package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
  90. package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
  91. package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
  92. package/dist/evaluators/docs/analysis/selector.js +123 -0
  93. package/dist/evaluators/docs/assess.d.ts +129 -0
  94. package/dist/evaluators/docs/assess.js +433 -0
  95. package/dist/evaluators/docs/campaign.d.ts +133 -0
  96. package/dist/evaluators/docs/campaign.js +222 -0
  97. package/dist/evaluators/docs/index.d.ts +25 -0
  98. package/dist/evaluators/docs/index.js +25 -0
  99. package/dist/evaluators/docs/journal.d.ts +150 -0
  100. package/dist/evaluators/docs/journal.js +475 -0
  101. package/dist/evaluators/docs/scoring.d.ts +367 -0
  102. package/dist/evaluators/docs/scoring.js +662 -0
  103. package/dist/evaluators/skills/assess.d.ts +94 -0
  104. package/dist/evaluators/skills/assess.js +207 -0
  105. package/dist/index.d.ts +46 -0
  106. package/dist/index.js +29 -0
  107. package/dist/legacy/prod014.d.ts +86 -0
  108. package/dist/legacy/prod014.js +209 -0
  109. package/dist/reporting/report.d.ts +79 -0
  110. package/dist/reporting/report.js +242 -0
  111. package/docs/architecture.md +106 -0
  112. package/docs/capability-matrix.md +84 -0
  113. package/docs/offline-rehearsal.md +75 -0
  114. package/docs/parity-record.md +81 -0
  115. package/docs/pr-description.md +103 -0
  116. package/docs/schema.md +97 -0
  117. package/examples/skills/README.md +13 -0
  118. package/examples/skills/campaign.json +73 -0
  119. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  120. package/examples/skills/suite.json +29 -0
  121. package/package.json +45 -5
@@ -0,0 +1,1285 @@
1
+ /**
2
+ * Blind docs judging for a campaign.
3
+ *
4
+ * The judge receives the established arm-neutral packet built from checked
5
+ * evidence only. It runs in a neutral container with no arm subjects, no
6
+ * campaign manifest and no evidence root. A usable judgement is stored with
7
+ * its attempt; a registered policy may recover exactly one complete
8
+ * `json-syntax` failure from the same packet bytes. Every dispatch is claimed
9
+ * against a durable budget before it starts.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
12
+ import { join, relative, sep } from "node:path";
13
+ import { sha256Bytes, sha256Of } from "../contracts/canonical.js";
14
+ import { isRecord } from "../contracts/validate.js";
15
+ import { opencodeRunArgs, providerOverlay, sessionEnv, writeJudgeConfigHome, } from "../adapters/opencode/runtime.js";
16
+ import { normaliseMounts, runContainer, } from "../adapters/podman/container.js";
17
+ import { decodeTrail } from "../adapters/opencode/events.js";
18
+ import { claimBudget, advanceClaim, claimUsage, readClaims, releaseClaim, } from "../evaluators/docs/analysis/budget.js";
19
+ import { claimRecoveryBudget, parseRecoveryPolicy, recoveryDecision, recoveryPolicySha256, selectionProblems, selectionRecordOf, } from "../evaluators/docs/analysis/recovery.js";
20
+ import { buildAcPrompt, documentationScore, judgeFailureClass, parseAcJudgement, validateAcJudgement, } from "../evaluators/docs/scoring.js";
21
+ import { docChanges, labelLeaks, } from "../evaluators/docs/campaign.js";
22
+ import { creditedOperationLines } from "../evaluators/docs/assess.js";
23
+ import { claimAttemptDir } from "./evidence-guard.js";
24
+ import { deliveryCheck, loadFrozen } from "./freeze.js";
25
+ import { inventory } from "./inventory.js";
26
+ import { appendLedgerEvent, readLedger } from "./ledger.js";
27
+ import { loadPreparedManifest, } from "./prepare.js";
28
+ import { readPrimaryCheck } from "./primary.js";
29
+ import { trialDirectory } from "./run.js";
30
+ /** The path of one slot's judgement evidence. */
31
+ export function judgeDirectory(root, slotId) {
32
+ return join(root, "evidence", "judgements", slotId);
33
+ }
34
+ /** The one canonical claims location: the campaign evidence root. */
35
+ export function claimsRoot(root) {
36
+ return join(root, "evidence");
37
+ }
38
+ /** Release reservations that provably dispatched nothing. */
39
+ function releaseClaims(claims) {
40
+ releaseClaim(claims.judge);
41
+ if (claims.recovery !== undefined)
42
+ releaseClaim(claims.recovery);
43
+ }
44
+ /** The path of one slot's checked selection. */
45
+ export function selectionPath(root, slotId) {
46
+ return join(judgeDirectory(root, slotId), "selection.json");
47
+ }
48
+ /** The path of one slot's immutable packet. */
49
+ export function packetPath(root, slotId) {
50
+ return join(judgeDirectory(root, slotId), "packet.md");
51
+ }
52
+ /** One attempt's directory. */
53
+ function attemptDirectory(root, slotId, attemptId) {
54
+ return join(judgeDirectory(root, slotId), attemptId);
55
+ }
56
+ /** Read one JSON object, or null. */
57
+ function readObject(path) {
58
+ if (!existsSync(path))
59
+ return null;
60
+ try {
61
+ const value = JSON.parse(readFileSync(path, "utf8"));
62
+ return isRecord(value) ? value : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** Readdir when a directory exists, otherwise an empty list. */
69
+ function namesOf(directory) {
70
+ if (!existsSync(directory))
71
+ return [];
72
+ return readdirSync(directory, { withFileTypes: true })
73
+ .filter((entry) => entry.isDirectory())
74
+ .map((entry) => entry.name)
75
+ .filter((name) => /^attempt-\d{3}$/.test(name))
76
+ .sort();
77
+ }
78
+ /** Read every recorded attempt, refusing a started or unreadable one. */
79
+ function recordedAttempts(root, slotId) {
80
+ const problems = [];
81
+ const attempts = [];
82
+ for (const attemptId of namesOf(judgeDirectory(root, slotId))) {
83
+ const directory = attemptDirectory(root, slotId, attemptId);
84
+ const value = readObject(join(directory, "meta.json"));
85
+ if (value === null) {
86
+ problems.push(`judge ${slotId}/${attemptId} has no readable meta`);
87
+ continue;
88
+ }
89
+ const meta = value;
90
+ if (meta.judgementStatus !== "judged" &&
91
+ meta.judgementStatus !== "invalid-verdict" &&
92
+ meta.judgementStatus !== "judge-error") {
93
+ problems.push(`judge ${slotId}/${attemptId} has an unresolved status ${JSON.stringify(meta.judgementStatus)}`);
94
+ continue;
95
+ }
96
+ attempts.push({
97
+ attemptId,
98
+ meta,
99
+ facts: {
100
+ attemptId,
101
+ judgementStatus: meta.judgementStatus,
102
+ packetSha256: meta.packetSha256,
103
+ failureClass: meta.failureClass,
104
+ complete: meta.complete,
105
+ },
106
+ });
107
+ }
108
+ return { attempts, problems };
109
+ }
110
+ /** Read every Markdown document under a lake root. */
111
+ function lakeDocuments(root) {
112
+ if (!existsSync(root))
113
+ return [];
114
+ const documents = [];
115
+ const walk = (directory) => {
116
+ for (const entry of readdirSync(directory, {
117
+ withFileTypes: true,
118
+ }).sort((left, right) => left.name.localeCompare(right.name))) {
119
+ const path = join(directory, entry.name);
120
+ if (entry.isDirectory())
121
+ walk(path);
122
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
123
+ documents.push({
124
+ path: relative(root, path).split(sep).join("/"),
125
+ contents: readFileSync(path, "utf8"),
126
+ });
127
+ }
128
+ }
129
+ };
130
+ walk(root);
131
+ return documents;
132
+ }
133
+ /** The changed, created and removed files between the pristine and live trees. */
134
+ function changedPaths(pristine, live) {
135
+ const before = inventory(pristine);
136
+ const after = inventory(live);
137
+ if (!before.ok || !after.ok)
138
+ return [];
139
+ const beforeMap = new Map(before.entries.map((entry) => [entry.path, entry.sha256]));
140
+ const afterMap = new Map(after.entries.map((entry) => [entry.path, entry.sha256]));
141
+ const changed = new Set();
142
+ for (const [path, hash] of afterMap) {
143
+ if (path === "answer.md")
144
+ continue;
145
+ if (beforeMap.get(path) !== hash)
146
+ changed.add(path);
147
+ }
148
+ for (const path of beforeMap.keys()) {
149
+ if (path === "answer.md")
150
+ continue;
151
+ if (!afterMap.has(path))
152
+ changed.add(path);
153
+ }
154
+ return [...changed].sort();
155
+ }
156
+ /** The text of one file, or an empty string. */
157
+ function textOf(path) {
158
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
159
+ }
160
+ /** The primary evidence a judge sees. */
161
+ function primaryEvidence(trialDir) {
162
+ const record = readPrimaryCheck(trialDir);
163
+ return {
164
+ status: record?.status ?? "missing",
165
+ exit: record?.exit ?? null,
166
+ stdout: (record?.stdout ?? "").slice(0, 8000),
167
+ stderr: (record?.stderr ?? "").slice(0, 4000),
168
+ };
169
+ }
170
+ /** The documentation mutation state for one slot. */
171
+ function docStateFor(root, slot, testCase) {
172
+ const lakeRoot = testCase.lakeRoot ?? "docs";
173
+ const beforeDocs = lakeDocuments(join(root, "prepare", "workspaces", testCase.caseId, lakeRoot));
174
+ const afterDocs = lakeDocuments(join(trialDirectory(root, slot.slotId), "workspace", lakeRoot));
175
+ const change = docChanges(beforeDocs, afterDocs);
176
+ return {
177
+ documentsChanged: change.documentsChanged,
178
+ createdPaths: change.createdPaths,
179
+ modifiedPaths: change.modifiedPaths,
180
+ };
181
+ }
182
+ /** The packet and mutation state for one slot. */
183
+ function buildPacket(options) {
184
+ const { root, slot, testCase } = options;
185
+ const trialDir = trialDirectory(root, slot.slotId);
186
+ const lakeRoot = testCase.lakeRoot ?? "docs";
187
+ const beforeDocs = lakeDocuments(join(root, "prepare", "workspaces", testCase.caseId, lakeRoot));
188
+ const afterDocs = lakeDocuments(join(trialDir, "workspace", lakeRoot));
189
+ const state = docStateFor(root, slot, testCase);
190
+ const operations = creditedOperationLines(trialDir);
191
+ const packet = buildAcPrompt({
192
+ taskPrompt: textOf(join(root, "prepare", "prompts", `${testCase.caseId}.txt`)),
193
+ standingPermission: textOf(join(root, "prepare", "workspaces", testCase.caseId, "AGENTS.md")),
194
+ scenarioTruth: testCase.truth ?? "",
195
+ beforeDocs,
196
+ afterDocs,
197
+ changedPaths: changedPaths(join(root, "prepare", "workspaces", testCase.caseId), join(trialDir, "workspace")),
198
+ primary: primaryEvidence(trialDir),
199
+ docsOperations: operations.lines,
200
+ answer: textOf(join(trialDir, "answer.md")),
201
+ });
202
+ return { packet, state };
203
+ }
204
+ /** The judge's immutable packet, writing it once or refusing a change. */
205
+ function packetFor(root, slotId, built) {
206
+ const path = packetPath(root, slotId);
207
+ if (existsSync(path)) {
208
+ const existing = readFileSync(path, "utf8");
209
+ if (existing !== built) {
210
+ return {
211
+ ok: false,
212
+ problem: `the judge packet for ${slotId} changed after it was frozen; use a fresh root`,
213
+ };
214
+ }
215
+ return { ok: true, packet: existing, sha256: sha256Bytes(existing) };
216
+ }
217
+ mkdirSync(join(root, "evidence", "judgements", slotId), {
218
+ recursive: true,
219
+ });
220
+ writeFileSync(path, built, "utf8");
221
+ return { ok: true, packet: built, sha256: sha256Bytes(built) };
222
+ }
223
+ /** The judge work mounts: packet read-only, config read-only, work writable. */
224
+ function judgeMounts(options) {
225
+ return normaliseMounts([
226
+ { source: options.packet, target: "/judge/packet.md", readOnly: true },
227
+ { source: options.configHome, target: "/config-home", readOnly: true },
228
+ { source: options.work, target: "/judge/work", readOnly: false },
229
+ ]);
230
+ }
231
+ /** Write the 0600 judge environment file. */
232
+ function writeJudgeEnvironment(attemptDir, campaign) {
233
+ const env = sessionEnv(process.env, {
234
+ configHome: "/config-home",
235
+ dataHome: "/tmp/agent-home/.local/share",
236
+ cacheHome: "/tmp/agent-home/.cache",
237
+ overlay: providerOverlay(campaign),
238
+ passthrough: campaign.runtime.passthroughEnv ?? [],
239
+ });
240
+ const names = [
241
+ "HOME",
242
+ "XDG_CONFIG_HOME",
243
+ "XDG_DATA_HOME",
244
+ "XDG_CACHE_HOME",
245
+ ...(campaign.runtime.provider.overlay === undefined
246
+ ? []
247
+ : ["OPENCODE_CONFIG_CONTENT"]),
248
+ ...(campaign.runtime.passthroughEnv ?? []),
249
+ ];
250
+ const envFile = join(attemptDir, "judge.env");
251
+ writeFileSync(envFile, `${names
252
+ .filter((name) => env[name] !== undefined)
253
+ .map((name) => `${name}=${env[name] ?? ""}`)
254
+ .join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
255
+ return { envFile, names };
256
+ }
257
+ /** The capture problems a judge stream shows. */
258
+ function judgeCapture(trail) {
259
+ const problems = [];
260
+ if (trail.events === 0)
261
+ problems.push("the judge emitted no events");
262
+ if (trail.parseFailures > 0) {
263
+ problems.push(`${trail.parseFailures} judge event line(s) were not readable JSON`);
264
+ }
265
+ if (trail.textParts === 0) {
266
+ problems.push("the judge emitted no answer text");
267
+ }
268
+ if (trail.lastPartKind !== "text") {
269
+ problems.push("the judge stream did not end on an answer");
270
+ }
271
+ return problems;
272
+ }
273
+ /** One attempt outcome, assembled once so each path stays small. */
274
+ function attemptOutcome(options) {
275
+ return options;
276
+ }
277
+ /** The transport or process failure outcome. */
278
+ function processOutcome(result, trail, captureProblems, complete) {
279
+ return attemptOutcome({
280
+ status: "judge-error",
281
+ failureClass: "transport-or-process",
282
+ complete,
283
+ captureProblems: [
284
+ ...captureProblems,
285
+ `judge process not ok (exit ${String(result.status)}${result.timedOut ? ", timed out" : ""}${result.error === null ? "" : `, ${result.error}`})`,
286
+ ],
287
+ judgement: null,
288
+ problems: [],
289
+ usage: trail.usage,
290
+ });
291
+ }
292
+ /** Classify a complete captured answer against its recorded mutation state. */
293
+ function verdictOutcome(trail, state, captureProblems) {
294
+ const parsed = parseAcJudgement(trail.text);
295
+ const complete = true;
296
+ if (parsed.value === undefined) {
297
+ return attemptOutcome({
298
+ status: "invalid-verdict",
299
+ failureClass: judgeFailureClass({
300
+ complete,
301
+ problems: parsed.problems,
302
+ }),
303
+ complete,
304
+ captureProblems,
305
+ judgement: null,
306
+ problems: parsed.problems,
307
+ usage: trail.usage,
308
+ });
309
+ }
310
+ const problems = validateAcJudgement(parsed.value, state);
311
+ return problems.length > 0
312
+ ? attemptOutcome({
313
+ status: "invalid-verdict",
314
+ failureClass: judgeFailureClass({
315
+ complete,
316
+ value: parsed.value,
317
+ problems,
318
+ }),
319
+ complete,
320
+ captureProblems,
321
+ judgement: parsed.value,
322
+ problems,
323
+ usage: trail.usage,
324
+ })
325
+ : attemptOutcome({
326
+ status: "judged",
327
+ failureClass: null,
328
+ complete,
329
+ captureProblems,
330
+ judgement: parsed.value,
331
+ problems: [],
332
+ usage: trail.usage,
333
+ });
334
+ }
335
+ /** Classify one completed judge process. */
336
+ function classifyAttempt(result, state) {
337
+ const trail = decodeTrail(result.stdout);
338
+ const captureProblems = judgeCapture(trail);
339
+ const complete = captureProblems.length === 0 && trail.text.trim() !== "";
340
+ const processFailed = result.timedOut ||
341
+ result.error !== null ||
342
+ result.signal !== null ||
343
+ result.status !== 0;
344
+ if (processFailed) {
345
+ return processOutcome(result, trail, captureProblems, complete);
346
+ }
347
+ return complete
348
+ ? verdictOutcome(trail, state, captureProblems)
349
+ : attemptOutcome({
350
+ status: "judge-error",
351
+ failureClass: "empty-or-truncated",
352
+ complete: false,
353
+ captureProblems,
354
+ judgement: null,
355
+ problems: [],
356
+ usage: trail.usage,
357
+ });
358
+ }
359
+ /** Build one attempt's dispatch plan without claiming or spawning. */
360
+ function planAttempt(options) {
361
+ const attemptDir = attemptDirectory(options.root, options.slot.slotId, options.attemptId);
362
+ mkdirSync(attemptDir, { recursive: true });
363
+ const configHome = writeJudgeConfigHome(attemptDir, options.campaign, options.judgeModel);
364
+ const work = join(attemptDir, "work");
365
+ mkdirSync(work, { recursive: true });
366
+ const { envFile, names } = writeJudgeEnvironment(attemptDir, options.campaign);
367
+ const plan = {
368
+ imageId: "",
369
+ mounts: judgeMounts({
370
+ packet: options.packetFile,
371
+ configHome,
372
+ work,
373
+ }),
374
+ workdir: "/judge/work",
375
+ network: options.campaign.runtime.network,
376
+ argv: [
377
+ "opencode",
378
+ ...opencodeRunArgs({
379
+ model: options.judgeModel,
380
+ title: `otc-judge-${options.slot.slotId}-${options.attemptId}`,
381
+ prompt: options.packet,
382
+ printLogs: process.env.OTC_PRINT_LOGS === "1",
383
+ }),
384
+ ],
385
+ envNames: [...new Set(names)],
386
+ envFile,
387
+ timeoutMs: options.campaign.assessment.judge?.timeoutMs ??
388
+ options.campaign.runtime.timeoutMs,
389
+ };
390
+ return { plan, attemptDir, packetSha256: options.packetSha256 };
391
+ }
392
+ /** Write one finished attempt's evidence. */
393
+ function storeAttempt(options) {
394
+ writeFileSync(join(options.attemptDir, "output.jsonl"), options.result.stdout, "utf8");
395
+ writeFileSync(join(options.attemptDir, "usage.json"), `${JSON.stringify(options.usage, null, 2)}\n`, "utf8");
396
+ const judgement = {
397
+ schemaVersion: 1,
398
+ slotId: options.meta.slotId,
399
+ attemptId: options.meta.attemptId,
400
+ status: options.outcome.status,
401
+ documentationOutcome: options.outcome.status === "judged" &&
402
+ typeof options.outcome.judgement?.documentationOutcome === "string"
403
+ ? options.outcome.judgement
404
+ .documentationOutcome
405
+ : null,
406
+ utility: options.outcome.status === "judged" &&
407
+ typeof options.outcome.judgement?.documentationOutcome === "string"
408
+ ? documentationScore(options.outcome.judgement
409
+ .documentationOutcome)
410
+ : null,
411
+ value: options.outcome.judgement,
412
+ problems: options.outcome.problems,
413
+ };
414
+ writeFileSync(join(options.attemptDir, "judgement.json"), `${JSON.stringify(judgement, null, 2)}\n`, "utf8");
415
+ writeFileSync(join(options.attemptDir, "meta.json"), `${JSON.stringify({
416
+ ...options.meta,
417
+ judgementSha256: sha256Of(judgement),
418
+ }, null, 2)}\n`, "utf8");
419
+ }
420
+ /** Write the per-slot selection after a usable judgement lands. */
421
+ function storeSelection(options) {
422
+ const index = options.manifest.campaign.schedule.slots.findIndex((entry) => entry.slotId === options.slot.slotId) + 1;
423
+ const selection = selectionRecordOf({
424
+ slot: index,
425
+ pairId: options.slot.repetitionId,
426
+ stamp: options.slot.slotId,
427
+ arm: options.slot.armId,
428
+ scenario: options.slot.caseId,
429
+ selectedAttemptId: options.selectedAttemptId,
430
+ policySha256: options.policy === null
431
+ ? null
432
+ : recoveryPolicySha256(options.policy),
433
+ attempts: options.attempts.map((entry) => entry.facts),
434
+ });
435
+ const problems = selectionProblems(selection, {
436
+ slot: index,
437
+ pairId: options.slot.repetitionId,
438
+ stamp: options.slot.slotId,
439
+ arm: options.slot.armId,
440
+ scenario: options.slot.caseId,
441
+ attemptIds: options.attempts.map((entry) => entry.attemptId),
442
+ manifestAttemptId: null,
443
+ policy: options.policy,
444
+ attemptFacts: Object.fromEntries(options.attempts.map((entry) => [entry.attemptId, entry.facts])),
445
+ });
446
+ if (problems.length > 0)
447
+ return { ok: false, problems };
448
+ const path = selectionPath(options.root, options.slot.slotId);
449
+ const existing = readObject(path);
450
+ if (existing !== null && sha256Of(existing) !== sha256Of(selection)) {
451
+ return {
452
+ ok: false,
453
+ problems: [
454
+ `selection for ${options.slot.slotId} already holds different bytes; historical selections are immutable`,
455
+ ],
456
+ };
457
+ }
458
+ if (existing === null) {
459
+ writeFileSync(path, `${JSON.stringify(selection, null, 2)}\n`, "utf8");
460
+ }
461
+ return { ok: true, selection };
462
+ }
463
+ /** The registered recovery policy, or a parse refusal. */
464
+ export function policyOf(manifest) {
465
+ const declared = manifest.campaign.assessment.recovery;
466
+ if (declared === undefined)
467
+ return { ok: true, policy: null };
468
+ const parsed = parseRecoveryPolicy(declared);
469
+ return parsed.ok
470
+ ? { ok: true, policy: parsed.policy }
471
+ : { ok: false, problems: parsed.problems };
472
+ }
473
+ /** The validated state a judge run needs, or the refusal. */
474
+ function judgeState(root, current) {
475
+ const prepared = loadPreparedManifest(root);
476
+ if (!prepared.ok)
477
+ return { ok: false, problems: [prepared.problem] };
478
+ const frozen = loadFrozen(root);
479
+ if (!frozen.ok)
480
+ return { ok: false, problems: [frozen.problem] };
481
+ const differences = current === undefined
482
+ ? deliveryCheck(root)
483
+ : deliveryCheck(root, current);
484
+ if (differences.length > 0) {
485
+ return {
486
+ ok: false,
487
+ problems: ["delivery check failed", ...differences],
488
+ };
489
+ }
490
+ const ledger = readLedger(root);
491
+ if (ledger.state === "malformed") {
492
+ return {
493
+ ok: false,
494
+ problems: [
495
+ `attempt ledger is malformed: ${ledger.problems.join("; ")}`,
496
+ ],
497
+ };
498
+ }
499
+ if (ledger.state === "closed") {
500
+ return {
501
+ ok: false,
502
+ problems: [
503
+ "campaign is closed; no further judge dispatch is permitted",
504
+ ],
505
+ };
506
+ }
507
+ const policy = policyOf(prepared.manifest);
508
+ if (!policy.ok)
509
+ return { ok: false, problems: policy.problems };
510
+ return {
511
+ ok: true,
512
+ manifest: prepared.manifest,
513
+ frozen: frozen.record,
514
+ policy: policy.policy,
515
+ };
516
+ }
517
+ /** The judge budget ceiling that also respects the campaign's declared spend. */
518
+ function judgeCeiling(manifest, policy) {
519
+ const campaign = manifest.campaign.budgets.judgeAttempts;
520
+ return policy === null
521
+ ? campaign
522
+ : Math.min(campaign, policy.totalJudgeAttempts);
523
+ }
524
+ /** Claim one initial judge dispatch. */
525
+ function claimInitial(root, ceiling, slot, attemptId, now) {
526
+ if (ceiling <= 0) {
527
+ return {
528
+ ok: false,
529
+ problem: `judge budget is zero for ${slot.slotId}`,
530
+ };
531
+ }
532
+ const claimed = claimBudget(claimsRoot(root), "judge", ceiling, {
533
+ stamp: slot.slotId,
534
+ attemptId,
535
+ key: `${slot.slotId}/${attemptId}`,
536
+ at: now(),
537
+ });
538
+ return claimed.ok
539
+ ? { ok: true, claim: claimed.claim }
540
+ : { ok: false, problem: `judge budget refused: ${claimed.reason}` };
541
+ }
542
+ /** Dispatch one attempt and record its evidence. */
543
+ async function dispatchAttempt(options) {
544
+ const attemptDir = attemptDirectory(options.root, options.slot.slotId, options.attemptId);
545
+ mkdirSync(judgeDirectory(options.root, options.slot.slotId), {
546
+ recursive: true,
547
+ });
548
+ const claimDir = claimAttemptDir(attemptDir);
549
+ if (!claimDir.ok) {
550
+ releaseClaims(options.claims);
551
+ return {
552
+ ok: false,
553
+ problem: `refusing to overwrite judge attempt ${options.attemptId}: ${claimDir.reason}`,
554
+ };
555
+ }
556
+ const planned = planAttempt({
557
+ root: options.root,
558
+ campaign: options.manifest.campaign,
559
+ slot: options.slot,
560
+ attemptId: options.attemptId,
561
+ packet: options.packet,
562
+ packetSha256: options.packetSha256,
563
+ packetFile: packetPath(options.root, options.slot.slotId),
564
+ judgeModel: options.judgeModel,
565
+ });
566
+ planned.plan.imageId = options.frozen.imageId;
567
+ const startedAt = options.now();
568
+ const startedMeta = {
569
+ schemaVersion: 1,
570
+ campaignId: options.manifest.campaignId,
571
+ slotId: options.slot.slotId,
572
+ caseId: options.slot.caseId,
573
+ armId: options.slot.armId,
574
+ attemptId: options.attemptId,
575
+ judgeModel: options.judgeModel,
576
+ packetSha256: options.packetSha256,
577
+ judgementStatus: "started",
578
+ failureClass: null,
579
+ complete: false,
580
+ startedAt,
581
+ completedAt: "",
582
+ exit: null,
583
+ timedOut: false,
584
+ captureProblems: ["judge dispatch started but has no terminal record"],
585
+ judgementSha256: null,
586
+ };
587
+ writeFileSync(join(planned.attemptDir, "meta.json"), `${JSON.stringify(startedMeta, null, 2)}\n`, "utf8");
588
+ writeFileSync(join(planned.attemptDir, "packet.md"), options.packet, "utf8");
589
+ const spawned = {
590
+ judge: advanceClaim(options.claims.judge, "spawned", {
591
+ stamp: options.slot.slotId,
592
+ attemptId: options.attemptId,
593
+ note: "judge dispatch started",
594
+ }),
595
+ ...(options.claims.recovery === undefined
596
+ ? {}
597
+ : {
598
+ recovery: advanceClaim(options.claims.recovery, "spawned", {
599
+ stamp: options.slot.slotId,
600
+ attemptId: options.attemptId,
601
+ note: "recovery dispatch started",
602
+ }),
603
+ }),
604
+ };
605
+ let result;
606
+ try {
607
+ result = await options.execute(planned.plan);
608
+ }
609
+ finally {
610
+ if (planned.plan.envFile !== null) {
611
+ rmSync(planned.plan.envFile, { force: true });
612
+ }
613
+ }
614
+ const outcome = classifyAttempt(result, options.state);
615
+ const completedAt = options.now();
616
+ const meta = {
617
+ ...startedMeta,
618
+ judgementStatus: outcome.status,
619
+ failureClass: outcome.failureClass,
620
+ complete: outcome.complete,
621
+ completedAt,
622
+ exit: result.status,
623
+ timedOut: result.timedOut,
624
+ captureProblems: outcome.captureProblems,
625
+ };
626
+ storeAttempt({
627
+ attemptDir: planned.attemptDir,
628
+ meta,
629
+ result,
630
+ outcome,
631
+ usage: outcome.usage,
632
+ });
633
+ advanceClaim(spawned.judge, "closed", {
634
+ note: outcome.status,
635
+ stamp: options.slot.slotId,
636
+ attemptId: options.attemptId,
637
+ });
638
+ if (spawned.recovery !== undefined) {
639
+ advanceClaim(spawned.recovery, "closed", {
640
+ note: outcome.status,
641
+ stamp: options.slot.slotId,
642
+ attemptId: options.attemptId,
643
+ });
644
+ }
645
+ appendLedgerEvent(options.root, {
646
+ event: "judge-attempt",
647
+ campaignId: options.manifest.campaignId,
648
+ slotId: options.slot.slotId,
649
+ attemptId: options.attemptId,
650
+ judgementStatus: outcome.status,
651
+ failureClass: outcome.failureClass,
652
+ packetSha256: options.packetSha256,
653
+ complete: outcome.complete,
654
+ exit: result.status,
655
+ }, completedAt);
656
+ return {
657
+ ok: true,
658
+ attempt: {
659
+ attemptId: options.attemptId,
660
+ meta,
661
+ facts: {
662
+ attemptId: options.attemptId,
663
+ judgementStatus: outcome.status,
664
+ packetSha256: options.packetSha256,
665
+ failureClass: outcome.failureClass,
666
+ complete: outcome.complete,
667
+ },
668
+ },
669
+ };
670
+ }
671
+ /** Resolve one slot's case, prepared payload and judge model. */
672
+ function resolveCase(manifest, slot) {
673
+ const testCase = manifest.suite.cases.find((entry) => entry.caseId === slot.caseId);
674
+ const prepared = manifest.cases.find((entry) => entry.caseId === slot.caseId);
675
+ if (testCase === undefined || prepared === undefined) {
676
+ return { problem: `slot ${slot.slotId} has no staged case` };
677
+ }
678
+ if (testCase.kind !== "docs-task") {
679
+ return {
680
+ problem: `slot ${slot.slotId} is ${testCase.kind}; only docs-task cases are judged`,
681
+ };
682
+ }
683
+ const judge = manifest.campaign.assessment.judge;
684
+ return judge === undefined
685
+ ? { problem: "campaign declares no judge model" }
686
+ : { testCase, prepared, judgeModel: judge.model };
687
+ }
688
+ /** Record a checked selection and its ledger event. */
689
+ function recordSelection(options) {
690
+ const stored = storeSelection(options);
691
+ if (!stored.ok)
692
+ return stored;
693
+ appendLedgerEvent(options.root, {
694
+ event: "judge-selection",
695
+ campaignId: options.manifest.campaignId,
696
+ slotId: options.slot.slotId,
697
+ selectedAttemptId: options.selectedAttemptId,
698
+ selectionSha256: sha256Of(stored.selection),
699
+ }, options.now());
700
+ return { ok: true };
701
+ }
702
+ /** Read a file's bytes, or null when it is absent or unreadable. */
703
+ function packetBytes(path) {
704
+ if (!existsSync(path))
705
+ return null;
706
+ try {
707
+ return readFileSync(path);
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ }
713
+ /** The registered packet identity against the actual slot and attempt bytes. */
714
+ function packetBindingProblems(root, slotId, attempt) {
715
+ const problems = [];
716
+ const slotPath = packetPath(root, slotId);
717
+ const attemptPath = join(attemptDirectory(root, slotId, attempt.attemptId), "packet.md");
718
+ const slot = packetBytes(slotPath);
719
+ const attemptBytes = packetBytes(attemptPath);
720
+ if (slot === null)
721
+ problems.push(`slot packet ${slotPath} is missing or unreadable`);
722
+ if (attemptBytes === null) {
723
+ problems.push(`attempt ${attempt.attemptId} packet ${attemptPath} is missing or unreadable`);
724
+ }
725
+ if (slot !== null && sha256Bytes(slot) !== attempt.meta.packetSha256) {
726
+ problems.push(`slot packet for ${slotId} does not match its registered identity`);
727
+ }
728
+ if (attemptBytes !== null &&
729
+ sha256Bytes(attemptBytes) !== attempt.meta.packetSha256) {
730
+ problems.push(`attempt ${attempt.attemptId} packet does not match its registered identity`);
731
+ }
732
+ if (slot !== null &&
733
+ attemptBytes !== null &&
734
+ sha256Bytes(slot) !== sha256Bytes(attemptBytes)) {
735
+ problems.push(`slot and attempt packets for ${slotId} differ`);
736
+ }
737
+ return problems;
738
+ }
739
+ /**
740
+ * Re-derive the packet evidence for one slot: every recorded attempt's packet
741
+ * must hash to its registered identity, the slot and attempt copies must agree,
742
+ * and every copy must equal the packet reconstructed from current evidence.
743
+ * @param root The campaign root.
744
+ * @param manifest The prepared manifest.
745
+ * @param slot The scheduled slot.
746
+ * @returns Packet integrity problems; empty means the packet is bound.
747
+ */
748
+ export function packetIntegrityProblems(root, manifest, slot) {
749
+ const recorded = recordedAttempts(root, slot.slotId);
750
+ if (recorded.attempts.length === 0)
751
+ return recorded.problems;
752
+ const testCase = manifest.suite.cases.find((entry) => entry.caseId === slot.caseId);
753
+ const expected = testCase?.kind === "docs-task"
754
+ ? buildPacket({ root, slot, testCase }).packet
755
+ : null;
756
+ const problems = [...recorded.problems];
757
+ for (const attempt of recorded.attempts) {
758
+ problems.push(...packetBindingProblems(root, slot.slotId, attempt));
759
+ const attemptBytes = packetBytes(join(attemptDirectory(root, slot.slotId, attempt.attemptId), "packet.md"));
760
+ if (expected !== null &&
761
+ attemptBytes !== null &&
762
+ attemptBytes.toString("utf8") !== expected) {
763
+ problems.push(`attempt ${attempt.attemptId} packet does not match current evidence`);
764
+ }
765
+ }
766
+ return problems;
767
+ }
768
+ /** The identity and attempt-set checks for one raw selection. */
769
+ function selectionRecordProblems(root, manifest, slot, policy) {
770
+ const problems = [];
771
+ const recorded = recordedAttempts(root, slot.slotId);
772
+ problems.push(...recorded.problems);
773
+ const raw = readObject(selectionPath(root, slot.slotId));
774
+ if (raw === null) {
775
+ return {
776
+ problems: [
777
+ ...problems,
778
+ `selection for ${slot.slotId} is missing or unreadable`,
779
+ ],
780
+ selection: null,
781
+ attempts: recorded.attempts,
782
+ };
783
+ }
784
+ problems.push(...selectionProblems(raw, {
785
+ stamp: slot.slotId,
786
+ arm: slot.armId,
787
+ scenario: slot.caseId,
788
+ attemptIds: recorded.attempts.map((entry) => entry.attemptId),
789
+ manifestAttemptId: null,
790
+ policy,
791
+ attemptFacts: Object.fromEntries(recorded.attempts.map((entry) => [
792
+ entry.attemptId,
793
+ entry.facts,
794
+ ])),
795
+ }));
796
+ const selection = raw;
797
+ const policyHash = policy === null ? null : recoveryPolicySha256(policy);
798
+ if (selection.policySha256 !== policyHash) {
799
+ problems.push("selection policySha256 does not match the registered policy");
800
+ }
801
+ const packets = new Set(recorded.attempts.map((entry) => entry.facts.packetSha256));
802
+ if (packets.size > 1) {
803
+ problems.push("selection attempts were judged from different packets");
804
+ }
805
+ return { problems, selection, attempts: recorded.attempts };
806
+ }
807
+ /** The selected attempt, or the reason it is absent. */
808
+ function selectedAttempt(attempts, selection) {
809
+ const selected = attempts.find((entry) => entry.attemptId === selection.selectedAttemptId);
810
+ return selected === undefined
811
+ ? {
812
+ problems: [
813
+ `selection names an absent attempt ${String(selection.selectedAttemptId)}`,
814
+ ],
815
+ selected: null,
816
+ }
817
+ : { problems: [], selected };
818
+ }
819
+ /** The stored judgement's identity, hash and outcome shape. */
820
+ function judgementShapeProblems(judgement, slot, attemptId) {
821
+ if (judgement.status !== "judged" ||
822
+ judgement.slotId !== slot.slotId ||
823
+ judgement.attemptId !== attemptId ||
824
+ !isRecord(judgement.value)) {
825
+ return [`selection ${attemptId} does not hold a usable judgement`];
826
+ }
827
+ return [];
828
+ }
829
+ /** The selected attempt and its stored judgement checks. */
830
+ function judgementRecordProblems(root, slot, testCase, attempts, selection) {
831
+ const chosen = selectedAttempt(attempts, selection);
832
+ if (chosen.selected === null) {
833
+ return {
834
+ problems: chosen.problems,
835
+ meta: null,
836
+ judgement: null,
837
+ };
838
+ }
839
+ const selected = chosen.selected;
840
+ const problems = selected.meta.judgementStatus === "judged"
841
+ ? []
842
+ : [`selection names an unusable attempt ${selected.attemptId}`];
843
+ const judgement = readObject(join(attemptDirectory(root, slot.slotId, selected.attemptId), "judgement.json"));
844
+ if (judgement === null) {
845
+ return {
846
+ problems: [
847
+ ...problems,
848
+ `selection ${selected.attemptId} has no readable judgement`,
849
+ ],
850
+ meta: selected.meta,
851
+ judgement: null,
852
+ };
853
+ }
854
+ const shape = judgementShapeProblems(judgement, slot, selected.attemptId);
855
+ if (shape.length > 0) {
856
+ return {
857
+ problems: [...problems, ...shape],
858
+ meta: selected.meta,
859
+ judgement: null,
860
+ };
861
+ }
862
+ if (typeof selected.meta.judgementSha256 !== "string" ||
863
+ selected.meta.judgementSha256 !== sha256Of(judgement)) {
864
+ problems.push(`selection ${selected.attemptId} judgement bytes changed`);
865
+ }
866
+ const value = judgement.value;
867
+ const outcome = value.documentationOutcome;
868
+ if (typeof outcome !== "string" ||
869
+ judgement.utility !==
870
+ documentationScore(outcome)) {
871
+ problems.push(`selection ${selected.attemptId} has an inconsistent outcome`);
872
+ }
873
+ problems.push(...validateAcJudgement(value, docStateFor(root, slot, testCase)));
874
+ return {
875
+ problems,
876
+ meta: selected.meta,
877
+ judgement: judgement,
878
+ };
879
+ }
880
+ /**
881
+ * Re-derive one slot's selection from evidence. A readable object is not a
882
+ * selection: attempt identity, packet identity, the selected judgement's hash,
883
+ * its outcome/utility and its agreement with the documented mutation state are
884
+ * all checked before status, closure, reporting or the judge trust it.
885
+ * @param options The campaign root, manifest, slot and registered policy.
886
+ * @returns The checked selection, or every inconsistency found.
887
+ * @param options.root The campaign root.
888
+ * @param options.manifest The prepared manifest.
889
+ * @param options.slot The scheduled slot.
890
+ * @param options.policy The parsed recovery policy, or null.
891
+ */
892
+ export function checkedSelection(options) {
893
+ const { root, manifest, slot, policy } = options;
894
+ const testCase = manifest.suite.cases.find((entry) => entry.caseId === slot.caseId);
895
+ if (testCase === undefined || testCase.kind !== "docs-task") {
896
+ return {
897
+ ok: false,
898
+ problems: [`slot ${slot.slotId} has no docs case`],
899
+ };
900
+ }
901
+ const packetProblems = packetIntegrityProblems(root, manifest, slot);
902
+ const base = selectionRecordProblems(root, manifest, slot, policy);
903
+ base.problems.unshift(...packetProblems);
904
+ if (base.selection === null) {
905
+ return { ok: false, problems: base.problems };
906
+ }
907
+ const judgement = judgementRecordProblems(root, slot, testCase, base.attempts, base.selection);
908
+ const problems = [...base.problems, ...judgement.problems];
909
+ return problems.length > 0 ||
910
+ judgement.meta === null ||
911
+ judgement.judgement === null
912
+ ? { ok: false, problems }
913
+ : {
914
+ ok: true,
915
+ value: {
916
+ selection: base.selection,
917
+ meta: judgement.meta,
918
+ judgement: judgement.judgement,
919
+ },
920
+ };
921
+ }
922
+ /**
923
+ * Load the manifest and policy, then check one slot's selection.
924
+ * @param root The campaign root.
925
+ * @param slotId The scheduled slot.
926
+ * @returns The checked selection, or every inconsistency.
927
+ */
928
+ export function checkedSelectionForSlot(root, slotId) {
929
+ const prepared = loadPreparedManifest(root);
930
+ if (!prepared.ok)
931
+ return { ok: false, problems: [prepared.problem] };
932
+ const slot = prepared.manifest.campaign.schedule.slots.find((entry) => entry.slotId === slotId);
933
+ if (slot === undefined) {
934
+ return { ok: false, problems: [`unknown slotId ${slotId}`] };
935
+ }
936
+ const policy = policyOf(prepared.manifest);
937
+ if (!policy.ok)
938
+ return { ok: false, problems: policy.problems };
939
+ return checkedSelection({
940
+ root,
941
+ manifest: prepared.manifest,
942
+ slot,
943
+ policy: policy.policy,
944
+ });
945
+ }
946
+ /**
947
+ * Build the exact judge packet for one slot without dispatching.
948
+ * @param root The campaign root.
949
+ * @param slotId The scheduled slot.
950
+ * @returns The packet and mutation state, or the refusal.
951
+ */
952
+ export function buildJudgePacketForSlot(root, slotId) {
953
+ const prepared = loadPreparedManifest(root);
954
+ if (!prepared.ok)
955
+ return { ok: false, problems: [prepared.problem] };
956
+ const slot = prepared.manifest.campaign.schedule.slots.find((entry) => entry.slotId === slotId);
957
+ const testCase = prepared.manifest.suite.cases.find((entry) => entry.caseId === slot?.caseId);
958
+ const preparedCase = prepared.manifest.cases.find((entry) => entry.caseId === slot?.caseId);
959
+ if (slot === undefined ||
960
+ testCase?.kind !== "docs-task" ||
961
+ preparedCase === undefined) {
962
+ return { ok: false, problems: [`slot ${slotId} has no docs case`] };
963
+ }
964
+ const built = buildPacket({ root, slot, testCase });
965
+ return { ok: true, ...built };
966
+ }
967
+ /** Settle a slot that already has a selection or a usable judgement. */
968
+ function settleExisting(options) {
969
+ const { root, slot, attempts, policy } = options;
970
+ if (existsSync(selectionPath(root, slot.slotId))) {
971
+ const checked = checkedSelection({
972
+ root,
973
+ manifest: options.manifest,
974
+ slot,
975
+ policy,
976
+ });
977
+ return checked.ok
978
+ ? { status: "skipped", problems: [] }
979
+ : { status: "refused", problems: checked.problems };
980
+ }
981
+ const judged = attempts.filter((entry) => entry.meta.judgementStatus === "judged");
982
+ if (judged.length === 0)
983
+ return null;
984
+ if (options.dryRun)
985
+ return { status: "judged", problems: [] };
986
+ const recorded = recordSelection({
987
+ ...options,
988
+ selectedAttemptId: judged[0].attemptId,
989
+ });
990
+ return recorded.ok
991
+ ? { status: "judged", problems: [] }
992
+ : { status: "refused", problems: recorded.problems };
993
+ }
994
+ /** Build and leak-check the judge packet for a ready slot. */
995
+ function buildReady(options) {
996
+ const trialDir = trialDirectory(options.root, options.slot.slotId);
997
+ if (!existsSync(join(trialDir, "meta.json"))) {
998
+ return { problem: `slot ${options.slot.slotId} has no recorded trial` };
999
+ }
1000
+ if (options.testCase.primaryCheck !== undefined &&
1001
+ readPrimaryCheck(trialDir) === null) {
1002
+ return {
1003
+ problem: `slot ${options.slot.slotId} has no primary-check result to judge`,
1004
+ };
1005
+ }
1006
+ const built = buildPacket(options);
1007
+ const leaks = labelLeaks(built.packet);
1008
+ return leaks.length > 0 || built.packet.includes(options.root)
1009
+ ? {
1010
+ problem: `judge packet for ${options.slot.slotId} is not arm-blind: ${[
1011
+ ...leaks,
1012
+ ...(built.packet.includes(options.root)
1013
+ ? ["absolute evidence root"]
1014
+ : []),
1015
+ ].join(", ")}`,
1016
+ }
1017
+ : built;
1018
+ }
1019
+ /** The recorded facts recovery eligibility is decided from. */
1020
+ function recoveryFacts(root, slot, attempts) {
1021
+ const last = attempts.at(-1);
1022
+ return {
1023
+ attemptStatus: last.meta.judgementStatus,
1024
+ failureClass: last.meta.failureClass,
1025
+ completionComplete: last.meta.complete,
1026
+ attemptIds: attempts.map((entry) => entry.attemptId),
1027
+ judgedAttemptIds: attempts
1028
+ .filter((entry) => entry.meta.judgementStatus === "judged")
1029
+ .map((entry) => entry.attemptId),
1030
+ recoveryClaimsUsed: claimUsage(claimsRoot(root), "recovery").claimed,
1031
+ judgeClaimsUsed: claimUsage(claimsRoot(root), "judge").claimed,
1032
+ ledgerState: "open",
1033
+ freezeProblems: [],
1034
+ unresolvedReservations: readClaims(claimsRoot(root), "judge")
1035
+ .filter((entry) => entry.stamp === slot.slotId && entry.state !== "closed")
1036
+ .map((entry) => entry.path),
1037
+ };
1038
+ }
1039
+ /** Claim the next initial or recovery dispatch for one slot. */
1040
+ function claimAttempt(options) {
1041
+ if (options.attempts.length === 0) {
1042
+ const claimed = claimInitial(options.root, judgeCeiling(options.manifest, options.policy), options.slot, "attempt-001", options.now);
1043
+ return claimed.ok
1044
+ ? {
1045
+ attemptId: "attempt-001",
1046
+ claims: { judge: claimed.claim },
1047
+ }
1048
+ : { problem: claimed.problem };
1049
+ }
1050
+ if (options.policy === null) {
1051
+ return {
1052
+ problem: `slot ${options.slot.slotId} has an unusable judge attempt; no recovery policy is registered`,
1053
+ };
1054
+ }
1055
+ const decision = recoveryDecision(options.policy, recoveryFacts(options.root, options.slot, options.attempts));
1056
+ if (!decision.eligible) {
1057
+ return { problem: `slot ${options.slot.slotId}: ${decision.reason}` };
1058
+ }
1059
+ const claimed = claimRecoveryBudget(claimsRoot(options.root), {
1060
+ ...options.policy,
1061
+ totalJudgeAttempts: judgeCeiling(options.manifest, options.policy),
1062
+ }, {
1063
+ stamp: options.slot.slotId,
1064
+ attemptId: decision.attemptId,
1065
+ key: `${options.slot.slotId}/${decision.attemptId}`,
1066
+ at: options.now(),
1067
+ });
1068
+ return claimed.ok
1069
+ ? {
1070
+ attemptId: decision.attemptId,
1071
+ claims: {
1072
+ judge: claimed.judge,
1073
+ recovery: claimed.recovery,
1074
+ },
1075
+ }
1076
+ : { problem: `slot ${options.slot.slotId}: ${claimed.reason}` };
1077
+ }
1078
+ /** The terminal refusal for a slot whose attempts are all unusable. */
1079
+ function lastFailure(slot, attempts) {
1080
+ const last = attempts.at(-1);
1081
+ return {
1082
+ status: "refused",
1083
+ problems: [
1084
+ `slot ${slot.slotId} judge attempt ${last.attemptId} was ${last.meta.judgementStatus}` +
1085
+ (last.meta.failureClass === null
1086
+ ? ""
1087
+ : ` (${last.meta.failureClass})`),
1088
+ ],
1089
+ };
1090
+ }
1091
+ /** The packet a dispatch will use, writing it once on the first attempt. */
1092
+ function ensurePacket(existing, root, slotId, built) {
1093
+ if (existing !== null)
1094
+ return existing;
1095
+ const frozen = packetFor(root, slotId, built);
1096
+ return frozen.ok
1097
+ ? { packet: frozen.packet, sha256: frozen.sha256 }
1098
+ : { problem: frozen.problem };
1099
+ }
1100
+ /** Store the usable judgement and return the slot's terminal result. */
1101
+ function settleJudged(options) {
1102
+ const selected = recordSelection(options);
1103
+ return selected.ok
1104
+ ? { status: "judged", problems: [] }
1105
+ : { status: "refused", problems: selected.problems };
1106
+ }
1107
+ /** Dispatch and recover for one ready slot. */
1108
+ async function executeAttempts(options) {
1109
+ let attempts = options.attempts;
1110
+ let packet = null;
1111
+ const maximumAttempts = 1 + (options.policy?.maxRecoveriesPerSlot ?? 0);
1112
+ for (let iteration = 0; iteration < maximumAttempts; iteration += 1) {
1113
+ const claimed = claimAttempt({ ...options, attempts });
1114
+ if ("problem" in claimed) {
1115
+ return { status: "refused", problems: [claimed.problem] };
1116
+ }
1117
+ const ensured = ensurePacket(packet, options.root, options.slot.slotId, options.built.packet);
1118
+ if ("problem" in ensured) {
1119
+ releaseClaims(claimed.claims);
1120
+ return { status: "refused", problems: [ensured.problem] };
1121
+ }
1122
+ packet = ensured;
1123
+ const dispatched = await dispatchAttempt({
1124
+ root: options.root,
1125
+ manifest: options.manifest,
1126
+ frozen: options.frozen,
1127
+ slot: options.slot,
1128
+ caseId: options.slot.caseId,
1129
+ attemptId: claimed.attemptId,
1130
+ packet: packet.packet,
1131
+ packetSha256: packet.sha256,
1132
+ judgeModel: options.judgeModel,
1133
+ state: options.built.state,
1134
+ claims: claimed.claims,
1135
+ execute: options.execute,
1136
+ now: options.now,
1137
+ });
1138
+ if (!dispatched.ok) {
1139
+ return { status: "refused", problems: [dispatched.problem] };
1140
+ }
1141
+ attempts = [...attempts, dispatched.attempt];
1142
+ if (dispatched.attempt.meta.judgementStatus === "judged") {
1143
+ return settleJudged({
1144
+ root: options.root,
1145
+ manifest: options.manifest,
1146
+ slot: options.slot,
1147
+ attempts,
1148
+ selectedAttemptId: claimed.attemptId,
1149
+ policy: options.policy,
1150
+ now: options.now,
1151
+ });
1152
+ }
1153
+ if (options.policy === null)
1154
+ return lastFailure(options.slot, attempts);
1155
+ }
1156
+ return lastFailure(options.slot, attempts);
1157
+ }
1158
+ /** Judge one slot, including at most one registered recovery. */
1159
+ async function judgeSlot(options) {
1160
+ const resolved = resolveCase(options.manifest, options.slot);
1161
+ if ("problem" in resolved) {
1162
+ return { status: "refused", problems: [resolved.problem] };
1163
+ }
1164
+ const recorded = recordedAttempts(options.root, options.slot.slotId);
1165
+ if (recorded.problems.length > 0) {
1166
+ return { status: "refused", problems: recorded.problems };
1167
+ }
1168
+ const settled = settleExisting({ ...options, attempts: recorded.attempts });
1169
+ if (settled !== null)
1170
+ return settled;
1171
+ const ready = buildReady({ ...options, ...resolved });
1172
+ if ("problem" in ready) {
1173
+ return { status: "refused", problems: [ready.problem] };
1174
+ }
1175
+ return options.dryRun
1176
+ ? { status: "judged", problems: [] }
1177
+ : executeAttempts({
1178
+ ...options,
1179
+ ...resolved,
1180
+ built: ready,
1181
+ attempts: recorded.attempts,
1182
+ });
1183
+ }
1184
+ /** Fold one slot result into the tally. */
1185
+ function tallyResult(tally, slotId, result) {
1186
+ if (result.status === "judged")
1187
+ tally.judged.push(slotId);
1188
+ else if (result.status === "skipped")
1189
+ tally.skipped.push(slotId);
1190
+ else
1191
+ tally.refused.push(slotId);
1192
+ tally.problems.push(...result.problems);
1193
+ }
1194
+ /** Whether the campaign stops after this slot's result. */
1195
+ function stopAfter(manifest, tally) {
1196
+ return (tally.problems.length > 0 &&
1197
+ manifest.campaign.stopOnFirstFailure === true);
1198
+ }
1199
+ /** Resolve the selected slots, refusing an unknown selector. */
1200
+ function judgeSlots(manifest, slotIds) {
1201
+ if (slotIds === undefined)
1202
+ return manifest.campaign.schedule.slots;
1203
+ return slotIds.map((slotId) => {
1204
+ const found = manifest.campaign.schedule.slots.find((entry) => entry.slotId === slotId);
1205
+ if (found === undefined)
1206
+ throw new Error(`unknown slotId ${slotId}`);
1207
+ return found;
1208
+ });
1209
+ }
1210
+ /**
1211
+ * Judge every selected slot from checked evidence. A usable selection is
1212
+ * idempotent; a complete `json-syntax` failure is recovered once when a policy
1213
+ * is registered, from the same packet bytes and against durable ceilings.
1214
+ * @param root The frozen campaign root.
1215
+ * @param options Slot selection and dry-run choice.
1216
+ * @param options.slotIds The selected slots.
1217
+ * @param options.dryRun Build and check the packet without dispatching.
1218
+ * @param deps Injected process runner and clock.
1219
+ * @returns What was judged, skipped or refused.
1220
+ */
1221
+ export async function judgeCampaign(root, options = {}, deps = {}) {
1222
+ const state = judgeState(root, deps.tool);
1223
+ if (!state.ok) {
1224
+ return {
1225
+ ok: false,
1226
+ judged: [],
1227
+ skipped: [],
1228
+ refused: [],
1229
+ problems: state.problems,
1230
+ };
1231
+ }
1232
+ const manifest = state.manifest;
1233
+ const slots = judgeSlots(manifest, options.slotIds);
1234
+ const execute = deps.runContainer ?? runContainer;
1235
+ const now = deps.now ?? (() => new Date().toISOString());
1236
+ const tally = {
1237
+ judged: [],
1238
+ skipped: [],
1239
+ refused: [],
1240
+ problems: [],
1241
+ };
1242
+ for (const slot of slots) {
1243
+ const result = await judgeSlot({
1244
+ root,
1245
+ manifest,
1246
+ frozen: state.frozen,
1247
+ slot,
1248
+ policy: state.policy,
1249
+ dryRun: options.dryRun === true,
1250
+ execute,
1251
+ now,
1252
+ });
1253
+ tallyResult(tally, slot.slotId, result);
1254
+ if (stopAfter(manifest, tally))
1255
+ break;
1256
+ }
1257
+ return {
1258
+ ok: tally.refused.length === 0 && tally.problems.length === 0,
1259
+ ...tally,
1260
+ };
1261
+ }
1262
+ /** Read a slot's attempt metadata, sorted by attempt id. */
1263
+ export function readJudgeAttempts(root, slotId) {
1264
+ return namesOf(judgeDirectory(root, slotId)).flatMap((attemptId) => {
1265
+ const value = readObject(join(attemptDirectory(root, slotId, attemptId), "meta.json"));
1266
+ return value === null ? [] : [value];
1267
+ });
1268
+ }
1269
+ /**
1270
+ * Read the selected judgement for one slot, or null when no usable selection
1271
+ * exists. The selection is evidence, so callers validate it separately.
1272
+ * @param root The campaign root.
1273
+ * @param slotId The slot id.
1274
+ * @returns The selected attempt's stored judgement, or null.
1275
+ */
1276
+ export function readSelectedJudgement(root, slotId) {
1277
+ const checked = checkedSelectionForSlot(root, slotId);
1278
+ return checked.ok
1279
+ ? {
1280
+ selection: checked.value.selection,
1281
+ meta: checked.value.meta,
1282
+ judgement: checked.value.judgement,
1283
+ }
1284
+ : null;
1285
+ }