@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,483 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { canonicalJson, sha256Bytes, sha256Of, } from "../contracts/canonical.js";
4
+ import { isRecord, loadCampaignBundle, readJson, } from "../contracts/validate.js";
5
+ import { realpathSync } from "node:fs";
6
+ import { isAbsolute } from "node:path";
7
+ import { inventory, stageTree } from "./inventory.js";
8
+ import { toolIdentity } from "../contracts/version.js";
9
+ /** Resolve a declared local subject source. */
10
+ function sourcePath(base, path) {
11
+ const resolved = isAbsolute(path) ? path : resolve(base, path);
12
+ if (!existsSync(resolved)) {
13
+ return { ok: false, problem: `${resolved} does not exist` };
14
+ }
15
+ try {
16
+ return { ok: true, path: realpathSync(resolved) };
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ problem: `${resolved}: ${error instanceof Error ? error.message : String(error)}`,
22
+ };
23
+ }
24
+ }
25
+ /** Read and parse a JSON file that preparation just wrote. */
26
+ function readManifest(path) {
27
+ const parsed = readJson(path);
28
+ if (!parsed.ok || !isRecord(parsed.value))
29
+ return null;
30
+ return parsed.value;
31
+ }
32
+ /**
33
+ * A campaign with source paths erased: identities are staged content, not the
34
+ * developer's checkout paths.
35
+ * @param bundle The validated campaign and suite.
36
+ * @returns The normalised campaign stored in the manifest.
37
+ */
38
+ export function normaliseCampaign(bundle) {
39
+ const campaign = {
40
+ schemaVersion: 1,
41
+ campaignId: bundle.campaign.campaignId,
42
+ suite: {
43
+ path: "suite.json",
44
+ ...(bundle.declaredSuiteSha256 === null
45
+ ? {}
46
+ : { sha256: bundle.declaredSuiteSha256 }),
47
+ },
48
+ subjects: bundle.campaign.subjects.map((arm) => {
49
+ const staged = { armId: arm.armId };
50
+ if (arm.skills !== undefined) {
51
+ staged.skills = arm.skills.map((skill) => ({
52
+ id: skill.id,
53
+ path: `subjects/${arm.armId}/skills/${skill.id}`,
54
+ }));
55
+ }
56
+ if (arm.plugins !== undefined) {
57
+ staged.plugins = arm.plugins.map((plugin) => ({
58
+ id: plugin.id,
59
+ path: `subjects/${arm.armId}/plugins/${plugin.id}`,
60
+ ...(plugin.include === undefined
61
+ ? {}
62
+ : { include: plugin.include }),
63
+ ...(plugin.options === undefined
64
+ ? {}
65
+ : { options: plugin.options }),
66
+ }));
67
+ }
68
+ if (arm.instructions !== undefined) {
69
+ staged.instructions = arm.instructions.map((instruction) => ({
70
+ id: instruction.id,
71
+ path: `subjects/${arm.armId}/instructions/${instruction.id}/${instruction.as ?? "AGENTS.md"}`,
72
+ as: instruction.as ?? "AGENTS.md",
73
+ }));
74
+ }
75
+ return staged;
76
+ }),
77
+ schedule: bundle.campaign.schedule,
78
+ runtime: bundle.campaign.runtime,
79
+ budgets: bundle.campaign.budgets,
80
+ assessment: bundle.campaign.assessment,
81
+ ...(bundle.campaign.permissions === undefined
82
+ ? {}
83
+ : { permissions: bundle.campaign.permissions }),
84
+ ...(bundle.campaign.stopOnFirstFailure === undefined
85
+ ? {}
86
+ : { stopOnFirstFailure: bundle.campaign.stopOnFirstFailure }),
87
+ };
88
+ return campaign;
89
+ }
90
+ /** Write a file, creating its parent directory. */
91
+ function write(path, contents) {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ writeFileSync(path, contents, "utf8");
94
+ }
95
+ /** The default plugin members staged for the runtime. */
96
+ export const DEFAULT_PLUGIN_INCLUDE = [
97
+ "package.json",
98
+ "package-lock.json",
99
+ "src",
100
+ "dist",
101
+ "node_modules",
102
+ "CONVENTION.md",
103
+ "README.md",
104
+ "LICENSE",
105
+ ];
106
+ /** Validate one skill bundle's required shape. */
107
+ function skillProblem(directory) {
108
+ const entry = join(directory, "SKILL.md");
109
+ if (!existsSync(entry))
110
+ return `${directory} has no SKILL.md`;
111
+ if (!lstatSync(entry).isFile())
112
+ return `${entry} is not a file`;
113
+ return null;
114
+ }
115
+ /** Reserve a staged target, refusing a collision. */
116
+ function reserve(reserved, target, armId, id, problems) {
117
+ if (reserved.has(target)) {
118
+ problems.push(`arm ${armId} has colliding subject ${id}`);
119
+ return false;
120
+ }
121
+ reserved.add(target);
122
+ return true;
123
+ }
124
+ /** Stage one skill bundle. */
125
+ function stageSkill(armId, skill, bundleDir, destination, reserved, problems) {
126
+ const checked = sourcePath(bundleDir, skill.path);
127
+ if (!checked.ok) {
128
+ problems.push(`arm ${armId} skill ${skill.id}: ${checked.problem}`);
129
+ return null;
130
+ }
131
+ const problem = skillProblem(checked.path);
132
+ if (problem !== null) {
133
+ problems.push(`arm ${armId} skill ${skill.id}: ${problem}`);
134
+ return null;
135
+ }
136
+ const target = join(destination, "skills", skill.id);
137
+ if (!reserve(reserved, target, armId, skill.id, problems))
138
+ return null;
139
+ const staged = stageTree(checked.path, target);
140
+ if (!staged.ok) {
141
+ problems.push(`arm ${armId} skill ${skill.id}: ${staged.problem}`);
142
+ return null;
143
+ }
144
+ return { id: skill.id, path: `subjects/${armId}/skills/${skill.id}` };
145
+ }
146
+ /** Stage one plugin package. */
147
+ function stagePlugin(armId, plugin, bundleDir, destination, reserved, problems) {
148
+ const checked = sourcePath(bundleDir, plugin.path);
149
+ if (!checked.ok) {
150
+ problems.push(`arm ${armId} plugin ${plugin.id}: ${checked.problem}`);
151
+ return null;
152
+ }
153
+ if (!existsSync(join(checked.path, "package.json"))) {
154
+ problems.push(`arm ${armId} plugin ${plugin.id}: package.json is missing`);
155
+ return null;
156
+ }
157
+ const target = join(destination, "plugins", plugin.id);
158
+ if (!reserve(reserved, target, armId, plugin.id, problems))
159
+ return null;
160
+ const include = plugin.include ??
161
+ DEFAULT_PLUGIN_INCLUDE.filter((member) => existsSync(join(checked.path, member)));
162
+ const staged = stageTree(checked.path, target, include);
163
+ if (!staged.ok) {
164
+ problems.push(`arm ${armId} plugin ${plugin.id}: ${staged.problem}`);
165
+ return null;
166
+ }
167
+ return {
168
+ id: plugin.id,
169
+ path: `subjects/${armId}/plugins/${plugin.id}`,
170
+ ...(plugin.options === undefined ? {} : { options: plugin.options }),
171
+ };
172
+ }
173
+ /** Stage one instruction overlay. */
174
+ function stageInstruction(armId, instruction, bundleDir, root, reserved, problems) {
175
+ const checked = sourcePath(bundleDir, instruction.path);
176
+ if (!checked.ok) {
177
+ problems.push(`arm ${armId} instruction ${instruction.id}: ${checked.problem}`);
178
+ return null;
179
+ }
180
+ if (!lstatSync(checked.path).isFile()) {
181
+ problems.push(`arm ${armId} instruction ${instruction.id}: not a file`);
182
+ return null;
183
+ }
184
+ const as = instruction.as ?? "AGENTS.md";
185
+ const relativePath = `subjects/${armId}/instructions/${instruction.id}/${as}`;
186
+ const target = join(root, "prepare", relativePath);
187
+ if (!reserve(reserved, target, armId, instruction.id, problems)) {
188
+ return null;
189
+ }
190
+ mkdirSync(dirname(target), { recursive: true });
191
+ copyFileSync(checked.path, target);
192
+ return { id: instruction.id, path: relativePath, as };
193
+ }
194
+ /** Stage one arm's declared subjects. */
195
+ function stageArm(arm, bundleDir, root, problems) {
196
+ const destination = join(root, "prepare", "subjects", arm.armId);
197
+ mkdirSync(destination, { recursive: true });
198
+ const reserved = new Set();
199
+ const staged = {
200
+ armId: arm.armId,
201
+ skills: (arm.skills ?? []).flatMap((skill) => {
202
+ const one = stageSkill(arm.armId, skill, bundleDir, destination, reserved, problems);
203
+ return one === null ? [] : [one];
204
+ }),
205
+ plugins: (arm.plugins ?? []).flatMap((plugin) => {
206
+ const one = stagePlugin(arm.armId, plugin, bundleDir, destination, reserved, problems);
207
+ return one === null ? [] : [one];
208
+ }),
209
+ instructions: (arm.instructions ?? []).flatMap((instruction) => {
210
+ const one = stageInstruction(arm.armId, instruction, bundleDir, root, reserved, problems);
211
+ return one === null ? [] : [one];
212
+ }),
213
+ inventory: [],
214
+ };
215
+ const armInventory = inventory(destination);
216
+ if (!armInventory.ok) {
217
+ problems.push(`arm ${arm.armId}: ${armInventory.problem}`);
218
+ return null;
219
+ }
220
+ staged.inventory = armInventory.entries;
221
+ return staged;
222
+ }
223
+ /** Stage one case's product primary checker, never into the trial payload. */
224
+ function stagePrimaryCheck(testCase, suiteDir, root, problems) {
225
+ if (testCase.kind !== "docs-task" || testCase.primaryCheck === undefined) {
226
+ return undefined;
227
+ }
228
+ const spec = testCase.primaryCheck;
229
+ const checked = sourcePath(suiteDir, spec.directory);
230
+ if (!checked.ok) {
231
+ problems.push(`case ${testCase.caseId} primary check: ${checked.problem}`);
232
+ return undefined;
233
+ }
234
+ if (!lstatSync(checked.path).isDirectory()) {
235
+ problems.push(`case ${testCase.caseId} primary check: directory is not a directory`);
236
+ return undefined;
237
+ }
238
+ const relativePath = `primary-checks/${testCase.caseId}`;
239
+ const target = join(root, "prepare", relativePath);
240
+ const staged = stageTree(checked.path, target);
241
+ if (!staged.ok) {
242
+ problems.push(`case ${testCase.caseId} primary check: ${staged.problem}`);
243
+ return undefined;
244
+ }
245
+ const script = join(target, spec.script);
246
+ if (!existsSync(script) || !lstatSync(script).isFile()) {
247
+ problems.push(`case ${testCase.caseId} primary check: ${spec.script} is not a file`);
248
+ return undefined;
249
+ }
250
+ const stagedInventory = inventory(target);
251
+ if (!stagedInventory.ok) {
252
+ problems.push(`case ${testCase.caseId} primary check: ${stagedInventory.problem}`);
253
+ return undefined;
254
+ }
255
+ return {
256
+ id: spec.id,
257
+ directory: relativePath,
258
+ script: spec.script,
259
+ timeoutMs: spec.timeoutMs ?? 30_000,
260
+ inventory: stagedInventory.entries,
261
+ };
262
+ }
263
+ /** Stage one case's prompt, pristine workspace and optional checker. */
264
+ function stageCase(testCase, suiteDir, root, problems) {
265
+ const prompt = testCase.prompt.endsWith("\n")
266
+ ? testCase.prompt
267
+ : `${testCase.prompt}\n`;
268
+ write(join(root, "prepare", "prompts", `${testCase.caseId}.txt`), prompt);
269
+ const workspace = join(root, "prepare", "workspaces", testCase.caseId);
270
+ if (testCase.fixture !== undefined) {
271
+ const checked = sourcePath(suiteDir, testCase.fixture);
272
+ if (!checked.ok) {
273
+ problems.push(`case ${testCase.caseId}: ${checked.problem}`);
274
+ return null;
275
+ }
276
+ if (!lstatSync(checked.path).isDirectory()) {
277
+ problems.push(`case ${testCase.caseId}: fixture is not a directory`);
278
+ return null;
279
+ }
280
+ const staged = stageTree(checked.path, workspace);
281
+ if (!staged.ok) {
282
+ problems.push(`case ${testCase.caseId}: ${staged.problem}`);
283
+ return null;
284
+ }
285
+ }
286
+ else {
287
+ mkdirSync(workspace, { recursive: true });
288
+ }
289
+ for (const [path, contents] of Object.entries(testCase.files ?? {})) {
290
+ write(join(workspace, path), contents);
291
+ }
292
+ const primaryCheck = stagePrimaryCheck(testCase, suiteDir, root, problems);
293
+ const stagedWorkspace = inventory(workspace);
294
+ if (!stagedWorkspace.ok) {
295
+ problems.push(`case ${testCase.caseId}: ${stagedWorkspace.problem}`);
296
+ return null;
297
+ }
298
+ return {
299
+ caseId: testCase.caseId,
300
+ promptSha256: sha256Bytes(readFileSync(join(root, "prepare", "prompts", `${testCase.caseId}.txt`))),
301
+ workspace: stagedWorkspace.entries,
302
+ ...(primaryCheck === undefined ? {} : { primaryCheck }),
303
+ };
304
+ }
305
+ /**
306
+ * Validate a campaign, then stage an immutable root: suite, prompts, pristine
307
+ * workspaces and subject payloads. Truth never enters a workspace.
308
+ * @param campaignPath The campaign document.
309
+ * @param root The fresh campaign root.
310
+ * @returns The prepared manifest, or actionable problems.
311
+ */
312
+ export function prepareCampaign(campaignPath, root) {
313
+ const loaded = loadCampaignBundle(campaignPath);
314
+ if (!loaded.ok)
315
+ return { ok: false, problems: loaded.problems };
316
+ const bundle = loaded.value;
317
+ if (existsSync(root)) {
318
+ return {
319
+ ok: false,
320
+ problems: [`${root} already exists; prepare needs a fresh root`],
321
+ };
322
+ }
323
+ mkdirSync(root, { recursive: true });
324
+ const problems = [];
325
+ const suiteDir = dirname(bundle.suitePath);
326
+ write(join(root, "prepare", "suite.json"), `${JSON.stringify(bundle.suite, null, 2)}\n`);
327
+ write(join(root, "prepare", "campaign.json"), readFileSync(bundle.campaignPath, "utf8"));
328
+ const subjects = [];
329
+ for (const arm of bundle.campaign.subjects) {
330
+ const staged = stageArm(arm, dirname(bundle.campaignPath), root, problems);
331
+ if (staged !== null)
332
+ subjects.push(staged);
333
+ }
334
+ const cases = [];
335
+ for (const testCase of bundle.suite.cases) {
336
+ const staged = stageCase(testCase, suiteDir, root, problems);
337
+ if (staged !== null)
338
+ cases.push(staged);
339
+ }
340
+ if (problems.length > 0)
341
+ return { ok: false, problems };
342
+ const campaign = normaliseCampaign(bundle);
343
+ const subjectsSha256 = sha256Of(Object.fromEntries(subjects.map((arm) => [arm.armId, arm.inventory])));
344
+ const workspacesSha256 = sha256Of(Object.fromEntries(cases.map((testCase) => [testCase.caseId, testCase.workspace])));
345
+ const promptsSha256 = sha256Of(Object.fromEntries(cases.map((testCase) => [testCase.caseId, testCase.promptSha256])));
346
+ const primaryCheckEntries = cases.flatMap((testCase) => testCase.primaryCheck === undefined
347
+ ? []
348
+ : [[testCase.caseId, testCase.primaryCheck.inventory]]);
349
+ const manifest = {
350
+ schemaVersion: 1,
351
+ campaignId: bundle.campaign.campaignId,
352
+ suiteId: bundle.suite.suiteId,
353
+ campaignSha256: sha256Bytes(readFileSync(bundle.campaignPath)),
354
+ suiteSha256: sha256Bytes(readFileSync(bundle.suitePath)),
355
+ suiteCanonicalSha256: sha256Of(bundle.suite),
356
+ scheduleSha256: sha256Of(bundle.campaign.schedule.slots),
357
+ subjectsSha256,
358
+ workspacesSha256,
359
+ promptsSha256,
360
+ primaryChecksSha256: sha256Of(Object.fromEntries(primaryCheckEntries)),
361
+ tool: toolIdentity(),
362
+ suite: bundle.suite,
363
+ campaign,
364
+ subjects,
365
+ cases,
366
+ };
367
+ write(join(root, "prepare", "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
368
+ return { ok: true, root, manifest };
369
+ }
370
+ /** The hash of a prepared manifest's canonical content. */
371
+ export function manifestSha256(manifest) {
372
+ return sha256Of(manifest);
373
+ }
374
+ /** Read the prepared manifest. */
375
+ export function loadPreparedManifest(root) {
376
+ const path = join(root, "prepare", "manifest.json");
377
+ if (!existsSync(path)) {
378
+ return { ok: false, problem: `${path} is missing; run otc prepare` };
379
+ }
380
+ const manifest = readManifest(path);
381
+ if (manifest === null) {
382
+ return { ok: false, problem: `${path} is not a readable manifest` };
383
+ }
384
+ if (manifest.schemaVersion !== 1) {
385
+ return {
386
+ ok: false,
387
+ problem: `${path} has an unsupported schemaVersion`,
388
+ };
389
+ }
390
+ return { ok: true, manifest };
391
+ }
392
+ /**
393
+ * Recheck every staged payload against the prepared manifest.
394
+ * @param root The campaign root.
395
+ * @param manifest The prepared manifest.
396
+ * @returns Differences; empty means the payload is intact.
397
+ */
398
+ export function preparedDifferences(root, manifest) {
399
+ const problems = [];
400
+ const suitePath = join(root, "prepare", "suite.json");
401
+ if (!existsSync(suitePath)) {
402
+ problems.push("missing prepare/suite.json");
403
+ }
404
+ else {
405
+ const observed = sha256Bytes(readFileSync(suitePath));
406
+ if (observed !==
407
+ sha256Bytes(`${JSON.stringify(manifest.suite, null, 2)}\n`)) {
408
+ problems.push("changed prepare/suite.json");
409
+ }
410
+ }
411
+ for (const arm of manifest.subjects) {
412
+ const directory = join(root, "prepare", "subjects", arm.armId);
413
+ for (const difference of inventoryDifferencesOf(directory, arm.inventory)) {
414
+ problems.push(`subjects/${arm.armId}: ${difference}`);
415
+ }
416
+ }
417
+ for (const testCase of manifest.cases) {
418
+ const workspace = join(root, "prepare", "workspaces", testCase.caseId);
419
+ for (const difference of inventoryDifferencesOf(workspace, testCase.workspace)) {
420
+ problems.push(`workspaces/${testCase.caseId}: ${difference}`);
421
+ }
422
+ const promptProblem = promptDifference(root, testCase);
423
+ if (promptProblem !== null)
424
+ problems.push(promptProblem);
425
+ problems.push(...primaryCheckDifferences(root, testCase));
426
+ }
427
+ return problems;
428
+ }
429
+ /** The prompt identity difference for one case, or null when intact. */
430
+ function promptDifference(root, testCase) {
431
+ const prompt = join(root, "prepare", "prompts", `${testCase.caseId}.txt`);
432
+ if (!existsSync(prompt))
433
+ return `missing prompts/${testCase.caseId}.txt`;
434
+ return sha256Bytes(readFileSync(prompt)) !== testCase.promptSha256
435
+ ? `changed prompts/${testCase.caseId}.txt`
436
+ : null;
437
+ }
438
+ /** The staged primary-check differences for one case. */
439
+ function primaryCheckDifferences(root, testCase) {
440
+ if (testCase.primaryCheck === undefined)
441
+ return [];
442
+ const checker = join(root, "prepare", testCase.primaryCheck.directory);
443
+ return inventoryDifferencesOf(checker, testCase.primaryCheck.inventory).map((difference) => `primary-checks/${testCase.caseId}: ${difference}`);
444
+ }
445
+ /** Inventory differences with a missing-directory distinction. */
446
+ function inventoryDifferencesOf(root, expected) {
447
+ if (!existsSync(root))
448
+ return ["directory is missing"];
449
+ const observed = inventory(root);
450
+ if (!observed.ok)
451
+ return [observed.problem];
452
+ const expectedMap = new Map(expected.map((entry) => [entry.path, entry]));
453
+ const observedMap = new Map(observed.entries.map((entry) => [entry.path, entry]));
454
+ const problems = [];
455
+ for (const [path, entry] of expectedMap) {
456
+ const found = observedMap.get(path);
457
+ if (found === undefined)
458
+ problems.push(`missing ${path}`);
459
+ else if (found.sha256 !== entry.sha256)
460
+ problems.push(`changed ${path}`);
461
+ }
462
+ for (const path of observedMap.keys()) {
463
+ if (!expectedMap.has(path))
464
+ problems.push(`added ${path}`);
465
+ }
466
+ return problems;
467
+ }
468
+ /** The canonical text of a prepared manifest, for receipts and displays. */
469
+ export function manifestText(manifest) {
470
+ return canonicalJson(manifest);
471
+ }
472
+ /** A human-readable staging summary. */
473
+ export function prepareSummary(manifest) {
474
+ return [
475
+ `campaign ${manifest.campaignId} (suite ${manifest.suiteId})`,
476
+ `${manifest.subjects.length} arm(s), ${manifest.cases.length} case(s)`,
477
+ `manifest ${manifestSha256(manifest)}`,
478
+ ].join("\n");
479
+ }
480
+ /** A path's basename, used in diagnostics without exposing an absolute path. */
481
+ export function displayPath(path) {
482
+ return basename(resolve(path));
483
+ }
@@ -0,0 +1,96 @@
1
+ import type { DocsTaskCase } from "../contracts/types.ts";
2
+ import { runContainer, type ContainerPlan } from "../adapters/podman/container.ts";
3
+ import type { PreparedCase } from "./prepare.ts";
4
+ /** One recorded primary-check outcome, kept distinct from task failure. */
5
+ export interface PrimaryCheckRecord {
6
+ /** The schema version value. */
7
+ schemaVersion: 1;
8
+ /** The declared checker identity. */
9
+ id: string;
10
+ /** The suite-relative script name. */
11
+ script: string;
12
+ /** The SHA-256 of the executed script bytes. */
13
+ sourceHash: string;
14
+ /** The fixed interpreter. */
15
+ command: string;
16
+ /** The fixed argument contract. */
17
+ args: string[];
18
+ /** The wall-clock bound. */
19
+ timeoutMs: number;
20
+ /** When the checker started. */
21
+ startedAt: string;
22
+ /** How long it ran. */
23
+ durationMs: number;
24
+ /** The terminal checker state. */
25
+ status: "pass" | "fail" | "timeout" | "error";
26
+ /** The exit code, or null. */
27
+ exit: number | null;
28
+ /** The signal, or null. */
29
+ signal: string | null;
30
+ /** Why it could not run, when applicable. */
31
+ reason: string | null;
32
+ /** Captured standard output, bounded. */
33
+ stdout: string;
34
+ /** Captured standard error, bounded. */
35
+ stderr: string;
36
+ }
37
+ /** The container-side checker paths. */
38
+ export declare const CONTAINER_CHECKER = "/checker";
39
+ /** The container-side completed workspace. */
40
+ export declare const CONTAINER_CHECKER_LIVE = "/live";
41
+ /** The container-side pristine workspace. */
42
+ export declare const CONTAINER_CHECKER_PRISTINE = "/pristine";
43
+ /**
44
+ * The checker plan: no network, no provider variables, no evidence root.
45
+ * @param options The resolved paths and identity.
46
+ * @param options.imageId The frozen image.
47
+ * @param options.checkerDir The staged checker directory.
48
+ * @param options.script The suite-relative script.
49
+ * @param options.workspace The completed trial workspace.
50
+ * @param options.pristine The prepared pristine workspace.
51
+ * @param options.envFile The 0600 environment file.
52
+ * @param options.timeoutMs The wall-clock bound.
53
+ * @returns The validated container plan.
54
+ */
55
+ export declare function primaryCheckPlan(options: {
56
+ imageId: string;
57
+ checkerDir: string;
58
+ script: string;
59
+ workspace: string;
60
+ pristine: string;
61
+ envFile: string;
62
+ timeoutMs: number;
63
+ }): ContainerPlan;
64
+ /**
65
+ * Run one product primary check against a completed trial.
66
+ * @param options The case, prepared identities and trial directory.
67
+ * @param options.root The campaign root.
68
+ * @param options.testCase The docs case with a declared checker.
69
+ * @param options.prepared The prepared case record.
70
+ * @param options.trialDir The trial evidence directory.
71
+ * @param options.workspace The completed trial workspace.
72
+ * @param options.imageId The frozen image identity.
73
+ * @param options.run Process adapter, injected for tests.
74
+ * @param options.now Injected clock.
75
+ * @returns The recorded outcome and the readable record.
76
+ */
77
+ export declare function executePrimaryCheck(options: {
78
+ root: string;
79
+ testCase: DocsTaskCase;
80
+ prepared: PreparedCase;
81
+ trialDir: string;
82
+ workspace: string;
83
+ imageId: string;
84
+ run?: typeof runContainer;
85
+ now?: () => string;
86
+ }): Promise<{
87
+ ok: boolean;
88
+ record: PrimaryCheckRecord | null;
89
+ problem: string | null;
90
+ }>;
91
+ /**
92
+ * Read a recorded primary check without trusting its shape.
93
+ * @param trialDir The trial evidence directory.
94
+ * @returns The record, or null when absent or unreadable.
95
+ */
96
+ export declare function readPrimaryCheck(trialDir: string): PrimaryCheckRecord | null;