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

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 (123) 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/release-0.1.0.md +52 -0
  117. package/docs/releasing.md +82 -0
  118. package/docs/schema.md +97 -0
  119. package/examples/skills/README.md +13 -0
  120. package/examples/skills/campaign.json +73 -0
  121. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  122. package/examples/skills/suite.json +29 -0
  123. package/package.json +45 -5
@@ -0,0 +1,1272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline campaign analyser.
4
+ *
5
+ * node analyse.mts --campaign <manifest> --evidence-root <dir> --out <dir>
6
+ * [--rerender]
7
+ *
8
+ * Replays a declared campaign from recorded evidence through the standard
9
+ * pipeline — manifest validation, explicit-state loading, accounting,
10
+ * declared-identity pairing, bounds, guardrails, the three-limb decision,
11
+ * the static screen, and stable reporting — without calling a model and
12
+ * without executing campaign-supplied code. Evidence is read-only; outputs
13
+ * must land outside the evidence root; an existing analysis is not
14
+ * overwritten without `--rerender`, and INTERPRETATION.md is never written.
15
+ */
16
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { sensitivity, } from "../scoring.js";
21
+ import { accountByArm, accountByScenarioArm, accountTrials, scoreTrial, } from "./accounting.js";
22
+ import { differenceInterval, intervalFromCounts } from "./bounds.js";
23
+ import { measureTrial, parseMeasureDeclarations, personalTrialDecision, summariseMeasures, } from "./measures.js";
24
+ import { evaluateDecision } from "./decision.js";
25
+ import { summariseGuardrails } from "./guardrails.js";
26
+ import { parseManifest, } from "./model.js";
27
+ import { pairObservations, pairedComparison, } from "./pairing.js";
28
+ import { loadLaunchLedger, loadTrialRecord, readMarkdownTree, } from "./records.js";
29
+ import { reconcileFindings, } from "./screening/reconcile.js";
30
+ import { screenTrial } from "./screening/scan.js";
31
+ import { ANALYSER_VERSION, REPORT_TEMPLATE_VERSION, renderAnalysisJson, renderReceipt, renderReport, } from "./render.js";
32
+ /**
33
+ * Record one reader observation. Files and directories are keyed separately so
34
+ * a path probed both ways keeps both identities.
35
+ * @param consumed The trace.
36
+ * @param observation The observation to record.
37
+ */
38
+ function traceObservation(consumed, observation) {
39
+ consumed.set(`${observation.kind}\u0000${observation.reference}`, observation);
40
+ }
41
+ /**
42
+ * Read the filesystem through the analyser's evidence-reader boundary,
43
+ * recording every file and directory observation so the receipt binds to the
44
+ * evidence that determined the result. A readable directory's identity is the
45
+ * SHA-256 of its sorted entries, so a missing tree and a recorded empty tree
46
+ * cannot share a receipt entry.
47
+ * @param root The evidence root.
48
+ * @param consumed The trace collecting every consumed observation.
49
+ * @returns The reader.
50
+ */
51
+ function fsReader(root, consumed) {
52
+ return {
53
+ readTextFile: (path) => {
54
+ const full = join(root, path);
55
+ let text;
56
+ try {
57
+ text = readFileSync(full, "utf8");
58
+ }
59
+ catch (error) {
60
+ if (error.code !== "ENOENT") {
61
+ throw error;
62
+ }
63
+ traceObservation(consumed, {
64
+ reference: path,
65
+ kind: "file",
66
+ sha256: null,
67
+ state: "missing",
68
+ });
69
+ return { state: "missing", source: path };
70
+ }
71
+ traceObservation(consumed, {
72
+ reference: path,
73
+ kind: "file",
74
+ sha256: sha256OfText(text),
75
+ state: "available",
76
+ });
77
+ return { state: "available", value: text, source: path };
78
+ },
79
+ readDirectoryEntries: (path) => {
80
+ const full = join(root, path);
81
+ let stats;
82
+ try {
83
+ stats = statSync(full);
84
+ }
85
+ catch (error) {
86
+ if (error.code !== "ENOENT") {
87
+ traceObservation(consumed, {
88
+ reference: path,
89
+ kind: "directory",
90
+ sha256: null,
91
+ state: "malformed",
92
+ });
93
+ return {
94
+ state: "malformed",
95
+ source: path,
96
+ problems: [
97
+ `directory state unreadable (${String(error)})`,
98
+ ],
99
+ };
100
+ }
101
+ traceObservation(consumed, {
102
+ reference: path,
103
+ kind: "directory",
104
+ sha256: null,
105
+ state: "missing",
106
+ });
107
+ return { state: "missing", source: path };
108
+ }
109
+ // An ordinary file where a directory was expected — a README.txt
110
+ // beside the notes, say — is a skip, never an empty directory.
111
+ if (!stats.isDirectory()) {
112
+ traceObservation(consumed, {
113
+ reference: path,
114
+ kind: "directory",
115
+ sha256: null,
116
+ state: "not-a-directory",
117
+ });
118
+ return { state: "not-a-directory", source: path };
119
+ }
120
+ try {
121
+ const value = readdirSync(full).sort();
122
+ traceObservation(consumed, {
123
+ reference: path,
124
+ kind: "directory",
125
+ sha256: sha256OfText(JSON.stringify(value)),
126
+ state: "available",
127
+ });
128
+ return { state: "available", value, source: path };
129
+ }
130
+ catch (error) {
131
+ traceObservation(consumed, {
132
+ reference: path,
133
+ kind: "directory",
134
+ sha256: null,
135
+ state: "malformed",
136
+ });
137
+ return {
138
+ state: "malformed",
139
+ source: path,
140
+ problems: [`directory unreadable (${String(error)})`],
141
+ };
142
+ }
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * Parse the CLI arguments.
148
+ * @param argv The arguments after the script path.
149
+ * @returns The options, or an error message.
150
+ */
151
+ function parseArgs(argv) {
152
+ const values = new Map();
153
+ let rerender = false;
154
+ let allowLegacyUnbound = false;
155
+ for (let index = 0; index < argv.length; index += 1) {
156
+ const flag = argv[index] ?? "";
157
+ if (flag === "--rerender") {
158
+ rerender = true;
159
+ continue;
160
+ }
161
+ if (flag === "--allow-legacy-unbound") {
162
+ allowLegacyUnbound = true;
163
+ continue;
164
+ }
165
+ if (!["--campaign", "--evidence-root", "--out"].includes(flag)) {
166
+ return { error: `unexpected argument ${JSON.stringify(flag)}` };
167
+ }
168
+ const value = argv[index + 1];
169
+ if (value === undefined)
170
+ return { error: `missing value for ${flag}` };
171
+ values.set(flag, value);
172
+ index += 1;
173
+ }
174
+ const campaign = values.get("--campaign");
175
+ const evidenceRoot = values.get("--evidence-root");
176
+ const out = values.get("--out");
177
+ if (campaign === undefined)
178
+ return { error: "missing --campaign" };
179
+ if (evidenceRoot === undefined)
180
+ return { error: "missing --evidence-root" };
181
+ if (out === undefined)
182
+ return { error: "missing --out" };
183
+ return {
184
+ options: {
185
+ campaign,
186
+ evidenceRoot,
187
+ out,
188
+ rerender,
189
+ allowLegacyUnbound,
190
+ },
191
+ };
192
+ }
193
+ /**
194
+ * Resolve a path through any symlinks in its existing prefix, appending the
195
+ * missing tail — the repository's containment pattern.
196
+ * @param path The path to resolve.
197
+ * @returns The symlink-free absolute path.
198
+ */
199
+ function resolveWithSymlinks(path) {
200
+ const absolute = resolve(path);
201
+ if (existsSync(absolute))
202
+ return realpathSync(absolute);
203
+ const parent = dirname(absolute);
204
+ if (parent === absolute)
205
+ return absolute;
206
+ return join(resolveWithSymlinks(parent), absolute.slice(parent.length + 1));
207
+ }
208
+ /**
209
+ * Refuse output directories that overlap or alias the evidence root.
210
+ * @param out The requested output directory.
211
+ * @param evidenceRoot The evidence root.
212
+ * @returns An error message, or undefined when the destination is safe.
213
+ */
214
+ function overlapProblem(out, evidenceRoot) {
215
+ if (!existsSync(evidenceRoot)) {
216
+ return `evidence root does not exist: ${evidenceRoot}`;
217
+ }
218
+ const evidence = realpathSync(evidenceRoot);
219
+ const destination = resolveWithSymlinks(out);
220
+ if (destination === evidence ||
221
+ destination.startsWith(`${evidence}/`) ||
222
+ evidence.startsWith(`${destination}/`)) {
223
+ return `output directory overlaps the evidence root: ${out} vs ${evidenceRoot}`;
224
+ }
225
+ return undefined;
226
+ }
227
+ /**
228
+ * Prepare the output directory, refusing an accidental overwrite.
229
+ * @param options The CLI options.
230
+ * @returns An error message, or undefined when the directory is usable.
231
+ */
232
+ function prepareOut(options) {
233
+ const overlap = overlapProblem(options.out, options.evidenceRoot);
234
+ if (overlap !== undefined)
235
+ return overlap;
236
+ if (existsSync(options.out)) {
237
+ const entries = readdirSync(options.out);
238
+ if (entries.length > 0 && !options.rerender) {
239
+ return `output directory is not empty; pass --rerender to regenerate analysis outputs: ${options.out}`;
240
+ }
241
+ }
242
+ else {
243
+ mkdirSync(options.out, { recursive: true });
244
+ }
245
+ return undefined;
246
+ }
247
+ /**
248
+ * Read and validate the manifest.
249
+ * @param path The manifest path.
250
+ * @returns The manifest, or problems.
251
+ */
252
+ function readManifest(path) {
253
+ let raw;
254
+ try {
255
+ raw = JSON.parse(readFileSync(path, "utf8"));
256
+ }
257
+ catch (error) {
258
+ return { problems: [`cannot read manifest ${path}: ${String(error)}`] };
259
+ }
260
+ const parsedManifest = parseManifest(raw);
261
+ return {
262
+ manifest: parsedManifest.value,
263
+ problems: parsedManifest.problems,
264
+ };
265
+ }
266
+ /**
267
+ * Load and score every scheduled slot.
268
+ * @param manifest The validated manifest.
269
+ * @param reader The evidence reader.
270
+ * @param allowLegacyUnbound Whether unhashed legacy answers are accepted.
271
+ * @returns The loaded records and the scored trials.
272
+ */
273
+ function loadCampaign(manifest, reader, allowLegacyUnbound) {
274
+ const scenarioById = new Map(manifest.scenarios.map((scenario) => [scenario.id, scenario]));
275
+ const records = [];
276
+ const scored = [];
277
+ for (const slot of manifest.schedule.slots) {
278
+ const scenario = scenarioById.get(slot.scenario);
279
+ const resultRoot = manifest.evidence.resultRoots[slot.arm];
280
+ if (resultRoot === undefined)
281
+ continue;
282
+ const record = loadTrialRecord(reader, {
283
+ resultRoot,
284
+ ...(manifest.assessment.attemptId === undefined
285
+ ? {}
286
+ : { attemptId: manifest.assessment.attemptId }),
287
+ stamp: slot.stamp,
288
+ scenario: slot.scenario,
289
+ slot: slot.slot,
290
+ ...(slot.pairId === undefined ? {} : { pairId: slot.pairId }),
291
+ arm: slot.arm,
292
+ inventoryFactIds: scenario?.preservationInventory?.facts.map((fact) => fact.id) ??
293
+ [],
294
+ selectionPolicy: manifest.assessment.selectionPolicy,
295
+ ...(manifest.assessment.recovery === undefined
296
+ ? {}
297
+ : { recoveryPolicy: manifest.assessment.recovery }),
298
+ allowUnboundAnswer: allowLegacyUnbound,
299
+ });
300
+ records.push(record);
301
+ scored.push(scoreTrial(record, slot, { weights: manifest.policy.weights }));
302
+ }
303
+ return { records, scored };
304
+ }
305
+ /**
306
+ * Group loaded records by their declared arm.
307
+ * @param manifest The validated manifest.
308
+ * @param records The loaded records.
309
+ * @returns Records keyed by arm id.
310
+ */
311
+ function recordsByArm(manifest, records) {
312
+ const grouped = {};
313
+ for (const slot of manifest.schedule.slots) {
314
+ const record = records.find((one) => one.identity.stamp === slot.stamp);
315
+ if (record === undefined)
316
+ continue;
317
+ grouped[slot.arm] = [...(grouped[slot.arm] ?? []), record];
318
+ }
319
+ return grouped;
320
+ }
321
+ /**
322
+ * Summarise every declared guardrail for every arm.
323
+ * @param manifest The validated manifest.
324
+ * @param records The loaded records.
325
+ * @returns Guardrail summaries keyed by arm.
326
+ */
327
+ function guardrailsByArm(manifest, records) {
328
+ const grouped = recordsByArm(manifest, records);
329
+ const summaries = {};
330
+ for (const [arm, armRecords] of Object.entries(grouped)) {
331
+ summaries[arm] = summariseGuardrails(manifest.policy.guardrails, manifest.scenarios, armRecords);
332
+ }
333
+ return summaries;
334
+ }
335
+ /**
336
+ * Build the per-arm report blocks.
337
+ * @param counts Accounting counts keyed by arm.
338
+ * @param guardrails Guardrail summaries keyed by arm.
339
+ * @returns The arm reports.
340
+ */
341
+ function armReports(counts, guardrails) {
342
+ const reports = {};
343
+ for (const [arm, accounting] of Object.entries(counts)) {
344
+ reports[arm] = {
345
+ accounting,
346
+ interval: intervalFromCounts(accounting),
347
+ guardrails: guardrails[arm] ?? [],
348
+ };
349
+ }
350
+ return reports;
351
+ }
352
+ /**
353
+ * Build the full-population candidate-minus-control interval.
354
+ * @param selection The declared selection rule, when present.
355
+ * @param counts Accounting counts keyed by arm.
356
+ * @returns The difference interval, or null without a usable pair of arms.
357
+ */
358
+ function deltaIntervalOf(selection, counts) {
359
+ if (selection === null)
360
+ return null;
361
+ const control = counts[selection.controlArm];
362
+ const candidate = counts[selection.candidateArm];
363
+ if (control === undefined || candidate === undefined)
364
+ return null;
365
+ return differenceInterval(intervalFromCounts(candidate), intervalFromCounts(control));
366
+ }
367
+ /**
368
+ * Build the pairing summary from the pairing result.
369
+ * @param pairing The pairing result.
370
+ * @returns The report summary.
371
+ */
372
+ function pairingSummaryOf(pairing) {
373
+ return {
374
+ pairs: pairing.pairs.length,
375
+ completePairs: pairing.pairs.filter((pair) => pair.complete).length,
376
+ unmatched: pairing.unmatched.map((row) => ({
377
+ stamp: row.identity.stamp,
378
+ arm: row.identity.arm,
379
+ scenario: row.identity.scenario,
380
+ score: row.utility.score,
381
+ })),
382
+ unpaired: pairing.unpaired.length,
383
+ problems: pairing.problems,
384
+ };
385
+ }
386
+ /**
387
+ * Build the sensitivity grid over the measured population.
388
+ * @param manifest The validated manifest.
389
+ * @param records The loaded records.
390
+ * @returns The grid, or null when none was frozen.
391
+ */
392
+ function sensitivityOf(manifest, records) {
393
+ const grid = manifest.policy.sensitivity;
394
+ if (grid === undefined)
395
+ return null;
396
+ const arms = {};
397
+ for (const slot of manifest.schedule.slots) {
398
+ if (slot.disposition === "excluded")
399
+ continue;
400
+ const record = records.find((one) => one.identity.stamp === slot.stamp);
401
+ if (record === undefined)
402
+ continue;
403
+ const trial = scoreTrial(record, slot, {
404
+ weights: manifest.policy.weights,
405
+ }).trial;
406
+ arms[slot.arm] = [...(arms[slot.arm] ?? []), trial];
407
+ }
408
+ return sensitivity(arms, grid.usefulWeights, grid.transientPenalties, manifest.policy.weights);
409
+ }
410
+ /**
411
+ * The judgement text a reconciliation can read, when one was judged.
412
+ * @param record The loaded record.
413
+ * @returns The judgement text, or undefined.
414
+ */
415
+ function judgementTextOf(record) {
416
+ if (record.assessment.status !== "judged" ||
417
+ record.assessment.reason === undefined) {
418
+ return undefined;
419
+ }
420
+ return {
421
+ reason: record.assessment.reason,
422
+ evidence: record.assessment.evidence,
423
+ };
424
+ }
425
+ /**
426
+ * Screen one trial and reconcile its findings with the selected judgement.
427
+ * @param manifest The validated manifest.
428
+ * @param reader The evidence reader.
429
+ * @param record The loaded record.
430
+ * @param slot The declared slot.
431
+ * @returns Findings, reconciliation rows, hashed sources and snapshot state.
432
+ */
433
+ function screenOne(manifest, reader, record, slot) {
434
+ const scenario = manifest.scenarios.find((one) => one.id === slot.scenario);
435
+ const resultRoot = record.identity.resultRoot;
436
+ const beforeDir = join(dirname(resultRoot), "fixtures", slot.scenario, "lakes/docs");
437
+ const afterDir = join(resultRoot, slot.stamp, slot.scenario, "live/lakes/docs");
438
+ const before = readMarkdownTree(reader, beforeDir);
439
+ const after = readMarkdownTree(reader, afterDir);
440
+ const trialDir = join(resultRoot, slot.stamp, slot.scenario);
441
+ const result = screenTrial({
442
+ trial: {
443
+ stamp: slot.stamp,
444
+ arm: slot.arm,
445
+ scenario: slot.scenario,
446
+ },
447
+ taskPrompt: record.artefacts.promptText,
448
+ finalAnswer: record.artefacts.finalAnswer,
449
+ beforeDocs: before,
450
+ afterDocs: after,
451
+ writesText: record.artefacts.writesText,
452
+ paths: {
453
+ prompt: join(trialDir, "prompt.md"),
454
+ finalAnswer: join(trialDir, "final.md"),
455
+ writes: join(trialDir, "writes.txt"),
456
+ beforeRoot: beforeDir,
457
+ afterRoot: afterDir,
458
+ },
459
+ inventory: scenario?.preservationInventory,
460
+ transientPatterns: manifest.policy.screening?.transientWordingPatterns,
461
+ durableCounterexamples: manifest.policy.screening?.durableCounterexamplePatterns,
462
+ mutationPolicy: scenario?.mutationPolicy,
463
+ });
464
+ return {
465
+ findings: result.findings,
466
+ reconciliation: reconcileFindings(result.findings, judgementTextOf(record)),
467
+ sources: result.sources.map((source) => ({
468
+ reference: source.reference,
469
+ sha256: source.sha256,
470
+ })),
471
+ missingSnapshot: before.state === "missing" || after.state === "missing",
472
+ };
473
+ }
474
+ /**
475
+ * Run the static screen over every trial and reconcile with its judgement.
476
+ * @param manifest The validated manifest.
477
+ * @param reader The evidence reader.
478
+ * @param records The loaded records.
479
+ * @returns Findings, reconciliation rows, hashed sources and missing-snapshot count.
480
+ */
481
+ function screenCampaign(manifest, reader, records) {
482
+ const findings = [];
483
+ const reconciliation = [];
484
+ const sources = new Map();
485
+ let missingSnapshots = 0;
486
+ for (const record of records) {
487
+ const slot = manifest.schedule.slots.find((one) => one.stamp === record.identity.stamp);
488
+ if (slot === undefined)
489
+ continue;
490
+ const one = screenOne(manifest, reader, record, slot);
491
+ findings.push(...one.findings);
492
+ reconciliation.push(...one.reconciliation);
493
+ for (const source of one.sources) {
494
+ sources.set(`${source.reference}\u0000${source.sha256}`, source);
495
+ }
496
+ if (one.missingSnapshot)
497
+ missingSnapshots += 1;
498
+ }
499
+ return {
500
+ findings,
501
+ reconciliation,
502
+ sources: [...sources.values()].sort((left, right) => left.reference.localeCompare(right.reference)),
503
+ missingSnapshots,
504
+ };
505
+ }
506
+ /**
507
+ * Sum recorded token usage, keeping absent telemetry unknown rather than zero.
508
+ * @param records The loaded trial records.
509
+ * @returns The usage summary with measured/unknown counts.
510
+ */
511
+ /** The token fields whose coverage is tracked independently. */
512
+ const TOKEN_FIELDS = ["input", "output", "reasoning", "cacheRead"];
513
+ /**
514
+ * Sum per-field token usage across readable trails, keeping each field's
515
+ * coverage independent. A field is `null` when no observed step reported it;
516
+ * an explicit zero keeps a known zero subtotal.
517
+ * @param records The loaded trial records.
518
+ * @returns The usage summary with per-field coverage.
519
+ */
520
+ function usageOf(records) {
521
+ let measuredTrials = 0;
522
+ let unknownTrials = 0;
523
+ let steps = 0;
524
+ let anyTrailReadable = false;
525
+ const accumulators = {
526
+ input: { total: 0, measuredSteps: 0, unknownSteps: 0, known: false },
527
+ output: { total: 0, measuredSteps: 0, unknownSteps: 0, known: false },
528
+ reasoning: {
529
+ total: 0,
530
+ measuredSteps: 0,
531
+ unknownSteps: 0,
532
+ known: false,
533
+ },
534
+ cacheRead: {
535
+ total: 0,
536
+ measuredSteps: 0,
537
+ unknownSteps: 0,
538
+ known: false,
539
+ },
540
+ };
541
+ for (const record of records) {
542
+ if (record.tokens.state !== "available") {
543
+ unknownTrials += 1;
544
+ continue;
545
+ }
546
+ measuredTrials += 1;
547
+ anyTrailReadable = true;
548
+ steps += record.tokens.value.steps;
549
+ for (const field of TOKEN_FIELDS) {
550
+ const usage = record.tokens.value[field];
551
+ const accumulator = accumulators[field];
552
+ accumulator.total += usage.total;
553
+ accumulator.measuredSteps += usage.measuredSteps;
554
+ accumulator.unknownSteps += usage.unknownSteps;
555
+ if (usage.measuredSteps > 0)
556
+ accumulator.known = true;
557
+ }
558
+ }
559
+ const coverageOf = (field) => {
560
+ const accumulator = accumulators[field];
561
+ return {
562
+ total: accumulator.known ? accumulator.total : null,
563
+ measuredSteps: accumulator.measuredSteps,
564
+ unknownSteps: accumulator.unknownSteps,
565
+ };
566
+ };
567
+ return {
568
+ input: coverageOf("input"),
569
+ output: coverageOf("output"),
570
+ reasoning: coverageOf("reasoning"),
571
+ cacheRead: coverageOf("cacheRead"),
572
+ steps: anyTrailReadable ? steps : null,
573
+ measuredTrials,
574
+ unknownTrials,
575
+ };
576
+ }
577
+ /**
578
+ * Count one readable ledger's events and per-stamp launches.
579
+ * @param records The ledger events.
580
+ * @returns The counts.
581
+ */
582
+ function countLedger(records) {
583
+ const events = {};
584
+ const launches = new Map();
585
+ const judgeAttempts = new Map();
586
+ const stamps = new Set();
587
+ for (const event of records) {
588
+ events[event.event] = (events[event.event] ?? 0) + 1;
589
+ if (event.stamp === null)
590
+ continue;
591
+ stamps.add(event.stamp);
592
+ const counter = event.event === "launch"
593
+ ? launches
594
+ : event.event === "judge-launch"
595
+ ? judgeAttempts
596
+ : undefined;
597
+ if (counter !== undefined) {
598
+ counter.set(event.stamp, (counter.get(event.stamp) ?? 0) + 1);
599
+ }
600
+ }
601
+ return { events, launches, judgeAttempts, stamps };
602
+ }
603
+ /**
604
+ * Reconcile a declared attempt ledger against the frozen schedule.
605
+ * @param manifest The validated manifest.
606
+ * @param ledger The loaded ledger.
607
+ * @returns The ledger summary, or null when none was declared.
608
+ */
609
+ function ledgerSummaryOf(manifest, ledger) {
610
+ const reference = manifest.evidence.inputs?.attempts;
611
+ if (reference === undefined)
612
+ return null;
613
+ const scheduled = manifest.schedule.slots.map((slot) => slot.stamp);
614
+ if (ledger.state !== "available") {
615
+ return {
616
+ reference,
617
+ state: ledger.state === "missing" ? "missing" : "malformed",
618
+ events: {},
619
+ scheduled: scheduled.length,
620
+ launched: 0,
621
+ judged: 0,
622
+ missingLaunches: [],
623
+ missingJudgeAttempts: [],
624
+ unknownStamps: [],
625
+ duplicateLaunches: [],
626
+ problems: ledger.state === "malformed" ? ledger.problems : [],
627
+ };
628
+ }
629
+ const counted = countLedger(ledger.value);
630
+ const declared = new Set(scheduled);
631
+ return {
632
+ reference,
633
+ state: "available",
634
+ events: counted.events,
635
+ scheduled: scheduled.length,
636
+ launched: scheduled.filter((stamp) => counted.launches.has(stamp))
637
+ .length,
638
+ judged: scheduled.filter((stamp) => counted.judgeAttempts.has(stamp))
639
+ .length,
640
+ missingLaunches: scheduled.filter((stamp) => !counted.launches.has(stamp)),
641
+ missingJudgeAttempts: scheduled.filter((stamp) => !counted.judgeAttempts.has(stamp)),
642
+ unknownStamps: [...counted.stamps]
643
+ .filter((stamp) => !declared.has(stamp))
644
+ .sort(),
645
+ duplicateLaunches: [...counted.launches]
646
+ .filter(([, count]) => count > 1)
647
+ .map(([stamp]) => stamp)
648
+ .sort(),
649
+ problems: [],
650
+ };
651
+ }
652
+ /**
653
+ * Render one ledger limitation line, or none when the ledger is clean.
654
+ * @param ledger The ledger summary, when one was declared.
655
+ * @returns One limitation, or undefined.
656
+ */
657
+ function ledgerLimitation(ledger) {
658
+ if (ledger === null)
659
+ return undefined;
660
+ if (ledger.state !== "available") {
661
+ return `Attempt ledger ${ledger.reference} is ${ledger.state}; launch and judge-attempt coverage stays unknown.`;
662
+ }
663
+ const problems = [
664
+ ledger.missingLaunches.length > 0
665
+ ? `${String(ledger.missingLaunches.length)} scheduled stamp(s) with no recorded launch`
666
+ : undefined,
667
+ ledger.missingJudgeAttempts.length > 0
668
+ ? `${String(ledger.missingJudgeAttempts.length)} scheduled stamp(s) with no recorded judge attempt`
669
+ : undefined,
670
+ ledger.unknownStamps.length > 0
671
+ ? `${String(ledger.unknownStamps.length)} ledger stamp(s) outside the schedule`
672
+ : undefined,
673
+ ledger.duplicateLaunches.length > 0
674
+ ? `${String(ledger.duplicateLaunches.length)} stamp(s) launched more than once`
675
+ : undefined,
676
+ ].filter((problem) => problem !== undefined);
677
+ if (problems.length === 0)
678
+ return undefined;
679
+ return `Attempt ledger ${ledger.reference}: ${problems.join("; ")}.`;
680
+ }
681
+ /**
682
+ * Collect the distinct recorded judge model identities.
683
+ * @param records The loaded records.
684
+ * @returns The sorted model ids.
685
+ */
686
+ function judgeModelsOf(records) {
687
+ return [
688
+ ...new Set(records
689
+ .map((record) => record.judgeModel.state === "available"
690
+ ? record.judgeModel.value
691
+ : undefined)
692
+ .filter((model) => model !== undefined)),
693
+ ].sort();
694
+ }
695
+ /**
696
+ * Load and reconcile the declared attempt ledger, when the manifest names one.
697
+ * @param manifest The validated manifest.
698
+ * @param reader The evidence reader.
699
+ * @returns The ledger summary, or null when none was declared.
700
+ */
701
+ function ledgerOf(manifest, reader) {
702
+ const path = manifest.evidence.inputs?.attempts;
703
+ if (path === undefined)
704
+ return null;
705
+ return ledgerSummaryOf(manifest, loadLaunchLedger(reader, path));
706
+ }
707
+ /**
708
+ * Build the campaign identity block.
709
+ * @param manifest The validated manifest.
710
+ * @returns The campaign identity.
711
+ */
712
+ function campaignIdentityOf(manifest) {
713
+ return {
714
+ id: manifest.campaign.id,
715
+ question: manifest.campaign.question ?? null,
716
+ };
717
+ }
718
+ /**
719
+ * Build the screening report block from one screen result.
720
+ * @param manifest The validated manifest.
721
+ * @param screen The campaign screen.
722
+ * @returns The screening block.
723
+ */
724
+ function screeningReportOf(manifest, screen, personalTrial) {
725
+ return {
726
+ rulesVersion: manifest.policy.screening?.rulesVersion ?? "not-defined",
727
+ findings: screen.findings,
728
+ reconciliation: screen.reconciliation,
729
+ personalTrial,
730
+ };
731
+ }
732
+ /**
733
+ * Build the explicit limitations list.
734
+ * @param records The loaded records.
735
+ * @param screen The screening result.
736
+ * @param totals The whole-population accounting.
737
+ * @param usage The token usage summary.
738
+ * @param ledger The ledger summary, when declared.
739
+ * @returns The limitations, rendered verbatim.
740
+ */
741
+ function limitationsOf(records, screen, totals, usage, ledger) {
742
+ const ledgerLine = ledgerLimitation(ledger);
743
+ return [
744
+ "Worst-case bounds are missing-data intervals, not confidence intervals.",
745
+ `Documented coverage: ${totals.documented}/${totals.planned} planned slots.`,
746
+ "Static-screen findings are advisory and were generated independently of the judge; reconciliation counts only exact excerpt references.",
747
+ screen.missingSnapshots > 0
748
+ ? `Screening read missing before/after lake snapshots for ${String(screen.missingSnapshots)} trial(s).`
749
+ : "Every screened trial had readable before/after lake snapshots.",
750
+ `Token coverage (measured/unknown steps): input ${String(usage.input.measuredSteps)}/${String(usage.input.unknownSteps)}, output ${String(usage.output.measuredSteps)}/${String(usage.output.unknownSteps)}, reasoning ${String(usage.reasoning.measuredSteps)}/${String(usage.reasoning.unknownSteps)}, cache-read ${String(usage.cacheRead.measuredSteps)}/${String(usage.cacheRead.unknownSteps)}; ${String(usage.measuredTrials)} readable and ${String(usage.unknownTrials)} unreadable trial trail(s).`,
751
+ ...(ledgerLine === undefined ? [] : [ledgerLine]),
752
+ ...records.flatMap((record) => record.attempt.problems
753
+ .concat(record.primary.problems)
754
+ .concat(record.assessment.problems)
755
+ .map((problem) => `${record.identity.stamp}: ${problem}`)),
756
+ "This analysis made no model calls and executed no campaign-supplied code.",
757
+ ];
758
+ }
759
+ /**
760
+ * Load and parse the declared measurement file through the evidence reader,
761
+ * so the receipt binds to its bytes.
762
+ * @param manifest The validated manifest.
763
+ * @param reader The evidence reader.
764
+ * @returns The declaration load.
765
+ */
766
+ function declarationsOf(manifest, reader) {
767
+ const path = manifest.evidence.inputs?.measures;
768
+ if (path === undefined) {
769
+ return {
770
+ status: "missing",
771
+ sha: null,
772
+ problem: "no measurement declaration is declared in the manifest",
773
+ };
774
+ }
775
+ const text = reader.readTextFile(path);
776
+ if (text.state !== "available") {
777
+ return {
778
+ status: "missing",
779
+ sha: null,
780
+ problem: `measurement declaration ${path} is ${text.state}`,
781
+ };
782
+ }
783
+ const sha = sha256OfText(text.value);
784
+ let raw;
785
+ try {
786
+ raw = JSON.parse(text.value);
787
+ }
788
+ catch {
789
+ return {
790
+ status: "malformed",
791
+ sha,
792
+ problem: `measurement declaration ${path} is not JSON`,
793
+ };
794
+ }
795
+ const parsed = parseMeasureDeclarations(raw);
796
+ if (parsed.declarations === undefined) {
797
+ return {
798
+ status: "malformed",
799
+ sha,
800
+ problem: `measurement declaration ${path} is malformed: ${parsed.problems.join("; ")}`,
801
+ };
802
+ }
803
+ return { status: "available", sha, declarations: parsed.declarations };
804
+ }
805
+ /**
806
+ * The measures block when no usable declaration exists.
807
+ * @param reason Why the declaration is unusable.
808
+ * @param status The explicit declaration state.
809
+ * @param decision The utility-threshold decision.
810
+ * @returns An inconclusive measures report.
811
+ */
812
+ function emptyMeasuresReport(reason, status, decision) {
813
+ return {
814
+ status,
815
+ declarationSha256: null,
816
+ arms: {},
817
+ trials: [],
818
+ personalTrial: {
819
+ recommendation: "inconclusive",
820
+ reasons: [reason],
821
+ utilityThreshold: {
822
+ component: "utility-threshold",
823
+ status: decision.threshold.status,
824
+ lead: decision.threshold.lead,
825
+ interval: decision.threshold.interval,
826
+ note: "One component only: a zero-delta 'qualified' at threshold 0 is not the personal-trial recommendation.",
827
+ },
828
+ },
829
+ };
830
+ }
831
+ /**
832
+ * The paired documented outcomes used by the candidate-only misleading veto.
833
+ * Incomplete pairs contribute nothing: they are already a coverage failure, so
834
+ * they cannot also stand in for a paired loss.
835
+ * @param pairing The pairing result.
836
+ * @param selection The declared selection rule.
837
+ * @returns One entry per complete pair.
838
+ */
839
+ function pairedOutcomesOf(pairing, selection) {
840
+ const pairs = [];
841
+ for (const pair of pairing.pairs) {
842
+ if (!pair.complete)
843
+ continue;
844
+ const control = pair.arms[selection.controlArm];
845
+ const candidate = pair.arms[selection.candidateArm];
846
+ if (control === undefined || candidate === undefined)
847
+ continue;
848
+ pairs.push({
849
+ pairId: pair.pairId,
850
+ control: control.trial.outcome === null ||
851
+ control.trial.outcome === undefined
852
+ ? []
853
+ : [control.trial.outcome],
854
+ candidate: candidate.trial.outcome === null ||
855
+ candidate.trial.outcome === undefined
856
+ ? []
857
+ : [candidate.trial.outcome],
858
+ });
859
+ }
860
+ return pairs;
861
+ }
862
+ /**
863
+ * The personal-trial screen from the assembled counts.
864
+ * @param manifest The validated manifest.
865
+ * @param arms The per-arm opportunity summaries.
866
+ * @param counts The per-arm accounting counts.
867
+ * @param guardrails The per-arm guardrail summaries.
868
+ * @param decision The utility-threshold decision.
869
+ * @returns The screen.
870
+ */
871
+ function screenOf(manifest, arms, counts, guardrails, decision, pairs) {
872
+ const selection = manifest.policy.selection;
873
+ if (selection === undefined) {
874
+ return {
875
+ recommendation: "inconclusive",
876
+ reasons: ["the manifest declares no control/candidate selection"],
877
+ utilityThreshold: {
878
+ component: "utility-threshold",
879
+ status: "not-defined",
880
+ lead: null,
881
+ interval: null,
882
+ note: "One component only: a zero-delta 'qualified' at threshold 0 is not the personal-trial recommendation.",
883
+ },
884
+ };
885
+ }
886
+ const candidate = guardrails[selection.candidateArm] ?? [];
887
+ const breached = candidate.filter((one) => one.status === "breached");
888
+ const unresolved = candidate.filter((one) => one.status === "unresolved");
889
+ return personalTrialDecision(selection.controlArm, selection.candidateArm, arms, counts, {
890
+ status: breached.length > 0
891
+ ? "breached"
892
+ : unresolved.length > 0
893
+ ? "unresolved"
894
+ : "clean",
895
+ detail: [...breached, ...unresolved]
896
+ .map((one) => `${one.guardrailId}: ${one.status}`)
897
+ .join("; ") || "no candidate guardrail concern",
898
+ }, decision.coverage, {
899
+ status: decision.threshold.status,
900
+ lead: decision.threshold.lead,
901
+ interval: decision.threshold.interval,
902
+ }, pairs);
903
+ }
904
+ /**
905
+ * Build the measures report from a usable declaration.
906
+ * @param manifest The validated manifest.
907
+ * @param loaded The loaded declaration.
908
+ * @param records The loaded records.
909
+ * @param scored The scored trials.
910
+ * @param counts The per-arm accounting counts.
911
+ * @param guardrails The per-arm guardrail summaries.
912
+ * @param decision The utility-threshold decision.
913
+ * @returns The measures report.
914
+ */
915
+ function completeMeasuresReport(manifest, loaded, records, scored, counts, guardrails, decision, pairs) {
916
+ const declarations = loaded.declarations;
917
+ const trials = scored.map((trial) => {
918
+ const record = records.find((one) => one.identity.stamp === trial.identity.stamp);
919
+ if (record === undefined) {
920
+ throw new Error(`missing loaded record for ${trial.identity.stamp}`);
921
+ }
922
+ return measureTrial(record, declarations, trial.identity.arm);
923
+ });
924
+ return {
925
+ status: "available",
926
+ declarationSha256: loaded.sha,
927
+ arms: summariseMeasures(trials, manifest.schedule.slots, declarations),
928
+ trials,
929
+ personalTrial: screenOf(manifest, summariseMeasures(trials, manifest.schedule.slots, declarations), counts, guardrails, decision, pairs),
930
+ };
931
+ }
932
+ /**
933
+ * Build the recording and retrieval measures from the declared measurement
934
+ * file, binding the file to the analysis receipt through the evidence reader.
935
+ * @param manifest The validated manifest.
936
+ * @param reader The evidence reader.
937
+ * @param records The loaded records.
938
+ * @param scored The scored trials.
939
+ * @param counts The per-arm accounting counts.
940
+ * @param guardrails The per-arm guardrail summaries.
941
+ * @param decision The utility-threshold decision.
942
+ * @returns The measures report, including the personal-trial screen.
943
+ */
944
+ function measuresOf(manifest, reader, records, scored, counts, guardrails, decision, pairs) {
945
+ const loaded = declarationsOf(manifest, reader);
946
+ if (loaded.declarations === undefined) {
947
+ return emptyMeasuresReport(loaded.problem ?? "measurement declaration is unusable", loaded.status === "malformed" ? "malformed" : "missing", decision);
948
+ }
949
+ return completeMeasuresReport(manifest, { ...loaded, declarations: loaded.declarations }, records, scored, counts, guardrails, decision, pairs);
950
+ }
951
+ /**
952
+ * Assemble the structured analysis result.
953
+ * @param manifest The validated manifest.
954
+ * @param manifestText The manifest's exact bytes.
955
+ * @param records The loaded records.
956
+ * @param scored The scored trials.
957
+ * @param reader The evidence reader.
958
+ * @returns The result.
959
+ */
960
+ function assemble(manifest, manifestText, records, scored, reader) {
961
+ const slots = manifest.schedule.slots;
962
+ const counts = accountByArm(scored, slots);
963
+ const guardrails = guardrailsByArm(manifest, records);
964
+ const pairing = pairObservations(scored, slots);
965
+ const selection = manifest.policy.selection ?? null;
966
+ const paired = selection === null
967
+ ? null
968
+ : pairedComparison(pairing.pairs, selection.controlArm, selection.candidateArm);
969
+ const candidateGuardrails = selection === null ? [] : (guardrails[selection.candidateArm] ?? []);
970
+ const delta = deltaIntervalOf(selection, counts);
971
+ const decision = evaluateDecision(manifest.policy.selection, counts, candidateGuardrails);
972
+ const ledgerSummary = ledgerOf(manifest, reader);
973
+ const usage = usageOf(records);
974
+ const screen = screenCampaign(manifest, reader, records);
975
+ const judgedBy = judgeModelsOf(records);
976
+ const measures = measuresOf(manifest, reader, records, scored, counts, guardrails, decision, selection === null ? [] : pairedOutcomesOf(pairing, selection));
977
+ const totals = accountTrials(scored, slots.length);
978
+ const limitations = limitationsOf(records, screen, totals, usage, ledgerSummary);
979
+ return {
980
+ analysisVersion: ANALYSER_VERSION,
981
+ campaign: campaignIdentityOf(manifest),
982
+ manifestSha256: sha256OfText(manifestText),
983
+ layout: manifest.evidence.layout,
984
+ selectionPolicy: manifest.assessment.selectionPolicy,
985
+ policyId: manifest.policy.policyId,
986
+ weights: manifest.policy.weights,
987
+ guardrails: manifest.policy.guardrails,
988
+ selection,
989
+ arms: armReports(counts, guardrails),
990
+ scenarios: accountByScenarioArm(scored, slots),
991
+ totals,
992
+ pairing: pairingSummaryOf(pairing),
993
+ paired,
994
+ bounds: { delta },
995
+ decision,
996
+ measures,
997
+ sensitivity: sensitivityOf(manifest, records),
998
+ screening: screeningReportOf(manifest, screen, measures.personalTrial),
999
+ integrity: {
1000
+ sources: screen.sources,
1001
+ judgedBy,
1002
+ },
1003
+ usage,
1004
+ cost: {
1005
+ available: false,
1006
+ note: "No monetary cost was recorded; token and step totals are reported without an invented price.",
1007
+ },
1008
+ ledger: ledgerSummary,
1009
+ assessmentSelection: (() => {
1010
+ const manifestAttempt = manifest.assessment.attemptId ?? "attempt-001";
1011
+ const ordinal = (attemptId) => Number(attemptId.slice("attempt-".length));
1012
+ const slots = records.map((record) => ({
1013
+ stamp: record.identity.stamp,
1014
+ selectedAttemptId: record.assessment.selectedAttemptId ?? null,
1015
+ recovered: record.assessment.selectedAttemptId !== undefined &&
1016
+ ordinal(record.assessment.selectedAttemptId) >
1017
+ ordinal(manifestAttempt),
1018
+ }));
1019
+ return {
1020
+ slots,
1021
+ recovered: slots.filter((slot) => slot.recovered).length,
1022
+ initial: slots.filter((slot) => slot.selectedAttemptId !== null && !slot.recovered).length,
1023
+ unrecorded: slots.filter((slot) => slot.selectedAttemptId === null).length,
1024
+ };
1025
+ })(),
1026
+ limitations,
1027
+ };
1028
+ }
1029
+ /**
1030
+ * SHA-256 of exact text.
1031
+ * @param text The text.
1032
+ * @returns The lowercase hex digest.
1033
+ */
1034
+ function sha256OfText(text) {
1035
+ return createHash("sha256").update(text, "utf8").digest("hex");
1036
+ }
1037
+ /** The fixed output set one run publishes. */
1038
+ const OUTPUT_NAMES = [
1039
+ "analysis.json",
1040
+ "REPORT.md",
1041
+ "SCREENING.json",
1042
+ "analysis-receipt.json",
1043
+ ];
1044
+ /**
1045
+ * Write every byte through the descriptor, never a reopened pathname.
1046
+ * @param fd The exclusive-create descriptor.
1047
+ * @param contents The full contents.
1048
+ */
1049
+ function writeFullSync(fd, contents) {
1050
+ const buffer = Buffer.from(contents, "utf8");
1051
+ let offset = 0;
1052
+ while (offset < buffer.length) {
1053
+ const written = writeSync(fd, buffer, offset, buffer.length - offset);
1054
+ if (written <= 0) {
1055
+ throw new Error(`write stalled after ${String(offset)} of ${String(buffer.length)} bytes`);
1056
+ }
1057
+ offset += written;
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Refuse an output name that is a symlink, a non-file, or an alias of a
1062
+ * protected input. Checked before any publication, so a refusal changes
1063
+ * nothing.
1064
+ * @param target The absolute output path.
1065
+ * @param protectedPaths The real paths of inputs a write must never touch.
1066
+ * @returns A refusal message, or undefined when the target is safe.
1067
+ */
1068
+ function publicTargetProblem(target, protectedPaths) {
1069
+ let stats;
1070
+ try {
1071
+ stats = lstatSync(target);
1072
+ }
1073
+ catch (error) {
1074
+ if (error.code === "ENOENT")
1075
+ return undefined;
1076
+ return `cannot inspect output ${target}: ${String(error)}`;
1077
+ }
1078
+ if (stats.isSymbolicLink()) {
1079
+ return `refusing to write ${target}: the output name is a symlink`;
1080
+ }
1081
+ if (!stats.isFile()) {
1082
+ return `refusing to write ${target}: the output name is not a regular file`;
1083
+ }
1084
+ const real = realpathSync(target);
1085
+ if (protectedPaths.has(real)) {
1086
+ return `refusing to write ${target}: it aliases protected input ${real}`;
1087
+ }
1088
+ return undefined;
1089
+ }
1090
+ /**
1091
+ * Collect the real paths of every input a publication must never overwrite:
1092
+ * the campaign manifest, authored interpretation and every consumed evidence
1093
+ * file.
1094
+ * @param options The CLI options.
1095
+ * @param consumed The reader trace.
1096
+ * @returns The protected real paths.
1097
+ */
1098
+ function protectedPathsOf(options, consumed) {
1099
+ const protectedPaths = new Set();
1100
+ const add = (path) => {
1101
+ try {
1102
+ protectedPaths.add(realpathSync(path));
1103
+ }
1104
+ catch {
1105
+ // A path that does not exist cannot be aliased by an output.
1106
+ }
1107
+ };
1108
+ add(options.campaign);
1109
+ add(join(options.out, "INTERPRETATION.md"));
1110
+ for (const reference of consumed.keys()) {
1111
+ add(join(options.evidenceRoot, reference));
1112
+ }
1113
+ return protectedPaths;
1114
+ }
1115
+ /**
1116
+ * Create one exclusive publication temp beside the target. A foreign object
1117
+ * at a candidate name — including a seeded symlink — makes the create fail
1118
+ * without touching it, and the bounded retry moves to a fresh name.
1119
+ * @param target The absolute target path.
1120
+ * @param nextCounter The fresh-name counter.
1121
+ * @returns The owned temp, or null when every candidate was occupied.
1122
+ */
1123
+ function acquireTemp(target, nextCounter) {
1124
+ for (let attempt = 0; attempt < 8; attempt += 1) {
1125
+ const candidate = `${target}.${String(process.pid)}.${String(nextCounter())}.tmp`;
1126
+ try {
1127
+ return { fd: openSync(candidate, "wx"), temp: candidate };
1128
+ }
1129
+ catch (error) {
1130
+ if (error.code !== "EEXIST")
1131
+ throw error;
1132
+ }
1133
+ }
1134
+ return null;
1135
+ }
1136
+ /**
1137
+ * Publish the pre-flighted output set. Every exclusive temp is created and
1138
+ * fully written before the first rename, so a failure during staging leaves
1139
+ * the previous outputs untouched. Each rename then replaces one file
1140
+ * atomically, but an interruption between renames can leave outputs from two
1141
+ * generations (some new, some previous). A seeded symlink or foreign object at
1142
+ * a temp name is never followed or removed.
1143
+ * @param out The output directory.
1144
+ * @param contents The fixed output names and their bytes.
1145
+ */
1146
+ function publishOutputs(out, contents) {
1147
+ let counter = 0;
1148
+ const nextCounter = () => {
1149
+ counter += 1;
1150
+ return counter;
1151
+ };
1152
+ const temps = [];
1153
+ try {
1154
+ for (const [name, content] of contents) {
1155
+ const target = join(out, name);
1156
+ const owned = acquireTemp(target, nextCounter);
1157
+ if (owned === null) {
1158
+ throw new Error(`every candidate temp name for ${target} was occupied; nothing was changed`);
1159
+ }
1160
+ temps.push({ temp: owned.temp, target });
1161
+ try {
1162
+ writeFullSync(owned.fd, content);
1163
+ }
1164
+ finally {
1165
+ closeSync(owned.fd);
1166
+ }
1167
+ }
1168
+ for (const { temp, target } of temps) {
1169
+ renameSync(temp, target);
1170
+ }
1171
+ }
1172
+ catch (error) {
1173
+ for (const { temp } of temps) {
1174
+ try {
1175
+ unlinkSync(temp);
1176
+ }
1177
+ catch {
1178
+ // A temp already renamed into place owns the target's bytes.
1179
+ }
1180
+ }
1181
+ throw error;
1182
+ }
1183
+ }
1184
+ /**
1185
+ * Run the analyser.
1186
+ * @param argv The arguments after the script path.
1187
+ * @returns The exit status and messages.
1188
+ */
1189
+ export function runAnalysis(argv) {
1190
+ const parsed = parseArgs(argv);
1191
+ if (parsed.options === undefined) {
1192
+ return {
1193
+ status: 2,
1194
+ messages: [`analyse: ${parsed.error ?? "bad arguments"}`],
1195
+ };
1196
+ }
1197
+ const options = parsed.options;
1198
+ const manifestRead = readManifest(options.campaign);
1199
+ if (manifestRead.manifest === undefined) {
1200
+ return {
1201
+ status: 2,
1202
+ messages: manifestRead.problems.map((problem) => `manifest: ${problem}`),
1203
+ };
1204
+ }
1205
+ const manifest = manifestRead.manifest;
1206
+ const prepared = prepareOut(options);
1207
+ if (prepared !== undefined) {
1208
+ return { status: 1, messages: [`analyse: ${prepared}`] };
1209
+ }
1210
+ const consumed = new Map();
1211
+ const reader = fsReader(options.evidenceRoot, consumed);
1212
+ const loaded = loadCampaign(manifest, reader, options.allowLegacyUnbound);
1213
+ const manifestText = readFileSync(options.campaign, "utf8");
1214
+ const result = assemble(manifest, manifestText, loaded.records, loaded.scored, reader);
1215
+ const renderedAt = new Date().toISOString();
1216
+ const receipt = {
1217
+ analyserVersion: ANALYSER_VERSION,
1218
+ templateVersion: REPORT_TEMPLATE_VERSION,
1219
+ manifestSha256: result.manifestSha256,
1220
+ policyId: result.policyId,
1221
+ renderedAt,
1222
+ inputs: [
1223
+ {
1224
+ reference: "campaign-manifest",
1225
+ kind: "file",
1226
+ sha256: result.manifestSha256,
1227
+ state: "available",
1228
+ },
1229
+ ...[...consumed.values()].sort((left, right) => left.reference.localeCompare(right.reference) ||
1230
+ left.kind.localeCompare(right.kind)),
1231
+ ],
1232
+ };
1233
+ const protectedPaths = protectedPathsOf(options, consumed);
1234
+ for (const name of OUTPUT_NAMES) {
1235
+ const problem = publicTargetProblem(join(options.out, name), protectedPaths);
1236
+ if (problem !== undefined) {
1237
+ return { status: 1, messages: [`analyse: ${problem}`] };
1238
+ }
1239
+ }
1240
+ publishOutputs(options.out, new Map([
1241
+ ["analysis.json", renderAnalysisJson(result)],
1242
+ ["REPORT.md", renderReport(result)],
1243
+ [
1244
+ "SCREENING.json",
1245
+ `${JSON.stringify({
1246
+ rulesVersion: result.screening.rulesVersion,
1247
+ findings: result.screening.findings,
1248
+ reconciliation: result.screening.reconciliation,
1249
+ }, null, 2)}\n`,
1250
+ ],
1251
+ ["analysis-receipt.json", renderReceipt(receipt)],
1252
+ ]));
1253
+ return {
1254
+ status: 0,
1255
+ messages: [
1256
+ result.measures.status === "available"
1257
+ ? `${manifest.campaign.id}: ${result.measures.personalTrial.recommendation}; written to ${options.out}`
1258
+ : `${manifest.campaign.id}: ${result.decision.overall}; written to ${options.out}`,
1259
+ ],
1260
+ };
1261
+ }
1262
+ if (process.argv[1] !== undefined &&
1263
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1264
+ const run = runAnalysis(process.argv.slice(2));
1265
+ for (const message of run.messages) {
1266
+ if (run.status === 0)
1267
+ console.log(message);
1268
+ else
1269
+ console.error(message);
1270
+ }
1271
+ process.exitCode = run.status;
1272
+ }