@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,1021 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { sha256Bytes } from "./canonical.js";
4
+ /** The identifier syntax every new OTC identity uses. */
5
+ export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
6
+ /** Keys whose values must never enter a committed campaign document. */
7
+ const SECRET_KEY = /key|token|secret|password|credential/i;
8
+ /** Whether a value is a plain JSON object. */
9
+ export function isRecord(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ /** Whether a string is a usable identifier. */
13
+ export function isId(value) {
14
+ return typeof value === "string" && ID_PATTERN.test(value);
15
+ }
16
+ /** Whether a string is a non-blank value. */
17
+ export function isText(value) {
18
+ return typeof value === "string" && value.trim() !== "";
19
+ }
20
+ /**
21
+ * Whether a path is relative, non-empty and free of parent traversal. It is
22
+ * syntax validation only; containment of an existing file is checked when it
23
+ * is resolved.
24
+ * @param value The candidate path.
25
+ * @returns True for a safe relative path.
26
+ */
27
+ export function isSafeRelativePath(value) {
28
+ if (!isText(value) || isAbsolute(value) || value.includes("\0")) {
29
+ return false;
30
+ }
31
+ const normalised = value.replaceAll("\\", "/");
32
+ return !normalised.split("/").some((part) => part === "..");
33
+ }
34
+ /**
35
+ * Whether a subject source path is acceptable. Subject payloads are staged
36
+ * and hashed before dispatch, so a source outside the campaign document is
37
+ * allowed; containment and symlink escapes are enforced on the staged tree,
38
+ * not on the developer's layout.
39
+ * @param value The candidate source path.
40
+ * @returns True for a non-blank, NUL-free path.
41
+ */
42
+ export function isSourcePath(value) {
43
+ return isText(value) && !value.includes("\0");
44
+ }
45
+ /** Read JSON from a path, preserving parse failure as a problem. */
46
+ export function readJson(path) {
47
+ try {
48
+ return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
49
+ }
50
+ catch (error) {
51
+ return {
52
+ ok: false,
53
+ problem: `${path}: ${error instanceof Error ? error.message : String(error)}`,
54
+ };
55
+ }
56
+ }
57
+ /** Collect a string member, or record that it is missing. */
58
+ function requiredText(record, field, where, problems) {
59
+ const value = record[field];
60
+ if (!isText(value)) {
61
+ problems.push(`${where}.${field} must be a non-blank string`);
62
+ return undefined;
63
+ }
64
+ return value;
65
+ }
66
+ /** Collect one identifier member. */
67
+ function requiredId(record, field, where, problems) {
68
+ const value = record[field];
69
+ if (!isId(value)) {
70
+ problems.push(`${where}.${field} must match ${ID_PATTERN.source} (got ${JSON.stringify(value)})`);
71
+ return undefined;
72
+ }
73
+ return value;
74
+ }
75
+ /** Reject a nested object that looks like a credential. */
76
+ function secretProblems(value, where) {
77
+ if (Array.isArray(value)) {
78
+ return value.flatMap((entry, index) => secretProblems(entry, `${where}[${index}]`));
79
+ }
80
+ if (!isRecord(value))
81
+ return [];
82
+ return Object.entries(value).flatMap(([key, entry]) => SECRET_KEY.test(key)
83
+ ? [`${where}.${key} names a credential; inject it through env`]
84
+ : secretProblems(entry, `${where}.${key}`));
85
+ }
86
+ /** Validate a case's inline file map. */
87
+ function filesProblems(value, where) {
88
+ if (value === undefined)
89
+ return [];
90
+ if (!isRecord(value))
91
+ return [`${where}.files must be an object`];
92
+ return Object.entries(value).flatMap(([path, contents]) => [
93
+ ...(isSafeRelativePath(path)
94
+ ? []
95
+ : [`${where}.files has an unsafe path ${path}`]),
96
+ ...(typeof contents === "string"
97
+ ? []
98
+ : [`${where}.files.${path} must be a string`]),
99
+ ]);
100
+ }
101
+ /** The string-valued inline file map. */
102
+ function filesOf(value) {
103
+ if (!isRecord(value))
104
+ return undefined;
105
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
106
+ }
107
+ /** Validate the shared case fields. */
108
+ function caseBase(record, where, problems) {
109
+ const caseId = requiredId(record, "caseId", where, problems);
110
+ const prompt = requiredText(record, "prompt", where, problems);
111
+ if (caseId === undefined || prompt === undefined)
112
+ return null;
113
+ for (const field of ["fixture", "lakeRoot"]) {
114
+ if (record[field] !== undefined && !isSafeRelativePath(record[field])) {
115
+ problems.push(`${where}.${field} must be a safe relative path`);
116
+ }
117
+ }
118
+ problems.push(...filesProblems(record.files, where));
119
+ const base = { caseId, prompt };
120
+ if (typeof record.fixture === "string")
121
+ base.fixture = record.fixture;
122
+ const files = filesOf(record.files);
123
+ if (files !== undefined)
124
+ base.files = files;
125
+ return base;
126
+ }
127
+ /** Validate the optional skill-case policy flags. */
128
+ function skillOptions(record, where, problems) {
129
+ const contamination = record.contamination;
130
+ if (contamination !== undefined &&
131
+ contamination !== "fail" &&
132
+ contamination !== "record") {
133
+ problems.push(`${where}.contamination must be "fail" or "record"`);
134
+ return null;
135
+ }
136
+ if (record.allowMissingTarget !== undefined &&
137
+ typeof record.allowMissingTarget !== "boolean") {
138
+ problems.push(`${where}.allowMissingTarget must be a boolean`);
139
+ return null;
140
+ }
141
+ return {
142
+ ...(contamination === undefined ? {} : { contamination }),
143
+ ...(record.allowMissingTarget === true
144
+ ? { allowMissingTarget: true }
145
+ : {}),
146
+ };
147
+ }
148
+ /** Validate one skill-trigger case. */
149
+ function skillCase(record, where, problems) {
150
+ const base = caseBase(record, where, problems);
151
+ const targetSkillId = requiredId(record, "targetSkillId", where, problems);
152
+ const expected = record.expected;
153
+ if (expected !== "activation" && expected !== "no-activation") {
154
+ problems.push(`${where}.expected must be "activation" or "no-activation"`);
155
+ }
156
+ const options = skillOptions(record, where, problems);
157
+ if (base === null ||
158
+ targetSkillId === undefined ||
159
+ options === null ||
160
+ (expected !== "activation" && expected !== "no-activation")) {
161
+ return null;
162
+ }
163
+ return {
164
+ ...base,
165
+ kind: "skill-trigger",
166
+ targetSkillId,
167
+ expected,
168
+ ...options,
169
+ };
170
+ }
171
+ /** Validate the string-list fields of an expected-docs block. */
172
+ function expectedListProblems(value, where) {
173
+ return ["retains", "omits"].flatMap((field) => value[field] !== undefined &&
174
+ (!Array.isArray(value[field]) || !value[field].every(isText))
175
+ ? [`${where}.expectedDocs.${field} must be strings`]
176
+ : []);
177
+ }
178
+ /** Validate the shape of an expected-docs block. */
179
+ function expectedShapeProblems(value, where) {
180
+ const problems = [];
181
+ if (value.kind !== "none" && !isSafeRelativePath(value.target)) {
182
+ problems.push(`${where}.expectedDocs.target must be a safe relative lake path`);
183
+ }
184
+ problems.push(...expectedListProblems(value, where));
185
+ if (value.link !== undefined && !isSafeRelativePath(value.link)) {
186
+ problems.push(`${where}.expectedDocs.link must be a safe relative path`);
187
+ }
188
+ return problems;
189
+ }
190
+ /** The typed expected-docs value. */
191
+ function expectedValue(value, kind) {
192
+ return {
193
+ kind,
194
+ ...(typeof value.target === "string" ? { target: value.target } : {}),
195
+ ...(typeof value.link === "string" ? { link: value.link } : {}),
196
+ ...(Array.isArray(value.retains)
197
+ ? { retains: value.retains.filter(isText) }
198
+ : {}),
199
+ ...(Array.isArray(value.omits)
200
+ ? { omits: value.omits.filter(isText) }
201
+ : {}),
202
+ };
203
+ }
204
+ /** Validate an expected-docs block. */
205
+ function expectedDocsOf(value, where, problems) {
206
+ if (!isRecord(value)) {
207
+ problems.push(`${where}.expectedDocs must be an object`);
208
+ return undefined;
209
+ }
210
+ const kind = value.kind;
211
+ if (kind !== "create" && kind !== "replace" && kind !== "none") {
212
+ problems.push(`${where}.expectedDocs.kind must be create, replace or none`);
213
+ return undefined;
214
+ }
215
+ problems.push(...expectedShapeProblems(value, where));
216
+ return expectedValue(value, kind);
217
+ }
218
+ /** Validate a docs criterion block. */
219
+ function criterionOf(value, where, problems) {
220
+ if (value === undefined)
221
+ return undefined;
222
+ if (!isRecord(value)) {
223
+ problems.push(`${where}.criterion must be an object`);
224
+ return undefined;
225
+ }
226
+ const minVerifiedCalls = value.minVerifiedCalls;
227
+ if (typeof minVerifiedCalls !== "number" ||
228
+ !Number.isInteger(minVerifiedCalls) ||
229
+ minVerifiedCalls < 0) {
230
+ problems.push(`${where}.criterion.minVerifiedCalls must be a non-negative integer`);
231
+ return undefined;
232
+ }
233
+ if (value.verbs !== undefined && !Array.isArray(value.verbs)) {
234
+ problems.push(`${where}.criterion.verbs must be an array`);
235
+ return undefined;
236
+ }
237
+ return {
238
+ minVerifiedCalls,
239
+ ...(Array.isArray(value.verbs)
240
+ ? { verbs: value.verbs.filter(isText) }
241
+ : {}),
242
+ };
243
+ }
244
+ /** Validate a docs guardrails block. */
245
+ function guardrailsOf(value, where, problems) {
246
+ if (value === undefined)
247
+ return undefined;
248
+ if (!isRecord(value)) {
249
+ problems.push(`${where}.guardrails must be an object`);
250
+ return undefined;
251
+ }
252
+ for (const field of ["unchanged", "absent"]) {
253
+ if (value[field] !== undefined &&
254
+ (!Array.isArray(value[field]) ||
255
+ !value[field].every(isSafeRelativePath))) {
256
+ problems.push(`${where}.guardrails.${field} must be safe relative paths`);
257
+ }
258
+ }
259
+ return {
260
+ ...(Array.isArray(value.unchanged)
261
+ ? { unchanged: value.unchanged.filter(isText) }
262
+ : {}),
263
+ ...(Array.isArray(value.absent)
264
+ ? { absent: value.absent.filter(isText) }
265
+ : {}),
266
+ };
267
+ }
268
+ /** The required path fields of a primary-check declaration. */
269
+ function primaryIdentity(value, where, problems) {
270
+ const id = requiredId(value, "id", `${where}.primaryCheck`, problems);
271
+ const directory = isSourcePath(value.directory);
272
+ if (!directory) {
273
+ problems.push(`${where}.primaryCheck.directory must be a non-blank path without NUL`);
274
+ }
275
+ const script = isSafeRelativePath(value.script);
276
+ if (!script) {
277
+ problems.push(`${where}.primaryCheck.script must be a safe relative path`);
278
+ }
279
+ if (id === undefined || !directory || !script)
280
+ return null;
281
+ return {
282
+ id,
283
+ directory: value.directory,
284
+ script: value.script,
285
+ };
286
+ }
287
+ /** An optional positive-integer timeout, or undefined when invalid. */
288
+ function positiveTimeout(value, where, problems) {
289
+ if (value === undefined)
290
+ return undefined;
291
+ const ok = typeof value === "number" && Number.isInteger(value) && value > 0;
292
+ if (!ok) {
293
+ problems.push(`${where} must be a positive integer`);
294
+ return undefined;
295
+ }
296
+ return value;
297
+ }
298
+ /** Validate one product-supplied primary-check declaration. */
299
+ function primaryCheckOf(value, where, problems) {
300
+ if (value === undefined)
301
+ return undefined;
302
+ if (!isRecord(value)) {
303
+ problems.push(`${where}.primaryCheck must be an object`);
304
+ return undefined;
305
+ }
306
+ const identity = primaryIdentity(value, where, problems);
307
+ const timeoutMs = positiveTimeout(value.timeoutMs, `${where}.primaryCheck.timeoutMs`, problems);
308
+ if (identity === null ||
309
+ (value.timeoutMs !== undefined && timeoutMs === undefined)) {
310
+ return undefined;
311
+ }
312
+ return {
313
+ ...identity,
314
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
315
+ };
316
+ }
317
+ /** Validate one docs-task case without exposing its truth to a trial. */
318
+ function docsCase(record, where, problems) {
319
+ const base = caseBase(record, where, problems);
320
+ const expectedDocs = expectedDocsOf(record.expectedDocs, where, problems);
321
+ const criterion = criterionOf(record.criterion, where, problems);
322
+ const guardrails = guardrailsOf(record.guardrails, where, problems);
323
+ const primaryCheck = primaryCheckOf(record.primaryCheck, where, problems);
324
+ if (base === null || expectedDocs === undefined)
325
+ return null;
326
+ if (record.truth !== undefined && !isText(record.truth)) {
327
+ problems.push(`${where}.truth must be a non-blank string`);
328
+ return null;
329
+ }
330
+ return {
331
+ ...base,
332
+ kind: "docs-task",
333
+ expectedDocs,
334
+ ...(criterion === undefined ? {} : { criterion }),
335
+ ...(guardrails === undefined ? {} : { guardrails }),
336
+ ...(typeof record.lakeRoot === "string"
337
+ ? { lakeRoot: record.lakeRoot }
338
+ : {}),
339
+ ...(typeof record.truth === "string" ? { truth: record.truth } : {}),
340
+ ...(primaryCheck === undefined ? {} : { primaryCheck }),
341
+ };
342
+ }
343
+ /** Parse one discriminated suite case. */
344
+ function suiteCaseOf(value, where, problems) {
345
+ if (!isRecord(value)) {
346
+ problems.push(`${where} must be an object`);
347
+ return null;
348
+ }
349
+ if (value.kind === "skill-trigger")
350
+ return skillCase(value, where, problems);
351
+ if (value.kind === "docs-task")
352
+ return docsCase(value, where, problems);
353
+ problems.push(`${where}.kind must be "skill-trigger" or "docs-task"`);
354
+ return null;
355
+ }
356
+ /** Check every case matches the suite's assessment kind. */
357
+ function suiteKindProblems(assessment, cases) {
358
+ const expected = assessment === "skill-activation" ? "skill-trigger" : "docs-task";
359
+ if (cases.every((one) => one.kind === expected))
360
+ return [];
361
+ return [
362
+ assessment === "skill-activation"
363
+ ? "a skill-activation suite may contain only skill-trigger cases"
364
+ : "a docs-recording suite may contain only docs-task cases",
365
+ ];
366
+ }
367
+ /** Collect unique suite cases, reporting duplicates. */
368
+ function collectSuiteCases(value, problems) {
369
+ if (!Array.isArray(value) || value.length === 0) {
370
+ problems.push("suite.cases must be a non-empty array");
371
+ return [];
372
+ }
373
+ const seen = new Set();
374
+ return value.flatMap((entry, index) => {
375
+ const parsed = suiteCaseOf(entry, `suite.cases[${index}]`, problems);
376
+ if (parsed === null)
377
+ return [];
378
+ if (seen.has(parsed.caseId)) {
379
+ problems.push(`duplicate caseId ${parsed.caseId}`);
380
+ return [];
381
+ }
382
+ seen.add(parsed.caseId);
383
+ return [parsed];
384
+ });
385
+ }
386
+ /**
387
+ * Validate a suite document.
388
+ * @param value The parsed JSON value.
389
+ * @returns The typed suite or every problem found.
390
+ */
391
+ export function validateSuite(value) {
392
+ if (!isRecord(value)) {
393
+ return { ok: false, problems: ["suite must be an object"] };
394
+ }
395
+ const problems = [];
396
+ if (value.schemaVersion !== 1) {
397
+ problems.push("suite.schemaVersion must be 1");
398
+ }
399
+ const suiteId = requiredId(value, "suiteId", "suite", problems);
400
+ const assessment = value.assessment;
401
+ const assessmentValid = assessment === "skill-activation" || assessment === "docs-recording";
402
+ if (!assessmentValid) {
403
+ problems.push('suite.assessment must be "skill-activation" or "docs-recording"');
404
+ }
405
+ const cases = collectSuiteCases(value.cases, problems);
406
+ if (assessmentValid) {
407
+ problems.push(...suiteKindProblems(assessment, cases));
408
+ }
409
+ if (problems.length > 0 || suiteId === undefined || !assessmentValid) {
410
+ return { ok: false, problems };
411
+ }
412
+ const suite = { schemaVersion: 1, suiteId, assessment, cases };
413
+ if (typeof value.description === "string") {
414
+ suite.description = value.description;
415
+ }
416
+ return { ok: true, value: suite };
417
+ }
418
+ /** Parse one arm's skill declarations. */
419
+ function parseSkills(value, where, base, problems) {
420
+ if (value === undefined)
421
+ return [];
422
+ if (!Array.isArray(value)) {
423
+ problems.push(`${where}.skills must be an array`);
424
+ return [];
425
+ }
426
+ const seen = new Set();
427
+ return value.flatMap((entry, index) => {
428
+ const at = `${where}.skills[${index}]`;
429
+ if (!isRecord(entry)) {
430
+ problems.push(`${at} must be an object`);
431
+ return [];
432
+ }
433
+ const id = requiredId(entry, "id", at, problems);
434
+ if (!isSourcePath(entry.path)) {
435
+ problems.push(`${at}.path must be a local source path`);
436
+ return [];
437
+ }
438
+ if (id === undefined)
439
+ return [];
440
+ if (seen.has(id)) {
441
+ problems.push(`${at}.id duplicates ${id}`);
442
+ return [];
443
+ }
444
+ seen.add(id);
445
+ return [{ id, path: resolve(base, entry.path) }];
446
+ });
447
+ }
448
+ /** Parse one arm's plugin declarations. */
449
+ function parsePlugins(value, where, base, problems) {
450
+ if (value === undefined)
451
+ return [];
452
+ if (!Array.isArray(value)) {
453
+ problems.push(`${where}.plugins must be an array`);
454
+ return [];
455
+ }
456
+ return value.flatMap((entry, index) => {
457
+ const at = `${where}.plugins[${index}]`;
458
+ if (!isRecord(entry)) {
459
+ problems.push(`${at} must be an object`);
460
+ return [];
461
+ }
462
+ const id = requiredId(entry, "id", at, problems);
463
+ if (!isSourcePath(entry.path)) {
464
+ problems.push(`${at}.path must be a local source path`);
465
+ return [];
466
+ }
467
+ if (id === undefined)
468
+ return [];
469
+ if (entry.include !== undefined &&
470
+ (!Array.isArray(entry.include) ||
471
+ !entry.include.every(isSafeRelativePath))) {
472
+ problems.push(`${at}.include must be safe relative paths`);
473
+ }
474
+ if (entry.options !== undefined) {
475
+ problems.push(...secretProblems(entry.options, `${at}.options`));
476
+ }
477
+ return [
478
+ {
479
+ id,
480
+ path: resolve(base, entry.path),
481
+ ...(Array.isArray(entry.include)
482
+ ? { include: entry.include.filter(isText) }
483
+ : {}),
484
+ ...(entry.options === undefined
485
+ ? {}
486
+ : { options: entry.options }),
487
+ },
488
+ ];
489
+ });
490
+ }
491
+ /** Parse one arm's instruction overlays. */
492
+ function parseInstructions(value, where, base, problems) {
493
+ if (value === undefined)
494
+ return [];
495
+ if (!Array.isArray(value)) {
496
+ problems.push(`${where}.instructions must be an array`);
497
+ return [];
498
+ }
499
+ return value.flatMap((entry, index) => {
500
+ const at = `${where}.instructions[${index}]`;
501
+ if (!isRecord(entry)) {
502
+ problems.push(`${at} must be an object`);
503
+ return [];
504
+ }
505
+ const id = requiredId(entry, "id", at, problems);
506
+ if (!isSourcePath(entry.path)) {
507
+ problems.push(`${at}.path must be a local source path`);
508
+ return [];
509
+ }
510
+ const destination = entry.as ?? "AGENTS.md";
511
+ if (!isSafeRelativePath(destination)) {
512
+ problems.push(`${at}.as must be a safe relative path`);
513
+ return [];
514
+ }
515
+ if (id === undefined)
516
+ return [];
517
+ return [
518
+ {
519
+ id,
520
+ path: resolve(base, entry.path),
521
+ as: destination,
522
+ },
523
+ ];
524
+ });
525
+ }
526
+ /**
527
+ * Parse and validate one arm's subject declarations.
528
+ * @param value The parsed arm.
529
+ * @param where The diagnostic path.
530
+ * @param base Directory for relative sources.
531
+ * @param problems The accumulating findings.
532
+ * @returns The arm, or null when its identity is unusable.
533
+ */
534
+ function parseArm(value, where, base, problems) {
535
+ if (!isRecord(value)) {
536
+ problems.push(`${where} must be an object`);
537
+ return null;
538
+ }
539
+ const armId = requiredId(value, "armId", where, problems);
540
+ if (armId === undefined)
541
+ return null;
542
+ const skills = parseSkills(value.skills, where, base, problems);
543
+ const plugins = parsePlugins(value.plugins, where, base, problems);
544
+ const instructions = parseInstructions(value.instructions, where, base, problems);
545
+ return {
546
+ armId,
547
+ ...(skills.length > 0 ? { skills } : {}),
548
+ ...(plugins.length > 0 ? { plugins } : {}),
549
+ ...(instructions.length > 0 ? { instructions } : {}),
550
+ };
551
+ }
552
+ /** Validate a provider overlay and return it when usable. */
553
+ function overlayOf(value, problems) {
554
+ if (value === undefined)
555
+ return undefined;
556
+ if (!isRecord(value)) {
557
+ problems.push("campaign.runtime.provider.overlay must be an object");
558
+ return undefined;
559
+ }
560
+ problems.push(...secretProblems(value, "campaign.runtime.provider.overlay"));
561
+ return value;
562
+ }
563
+ /** Parse the provider route. */
564
+ function parseProvider(value, problems) {
565
+ if (!isRecord(value)) {
566
+ problems.push("campaign.runtime.provider must be an object");
567
+ return null;
568
+ }
569
+ const route = requiredText(value, "route", "campaign.runtime.provider", problems);
570
+ const model = requiredText(value, "model", "campaign.runtime.provider", problems);
571
+ if (model !== undefined && !model.includes("/")) {
572
+ problems.push('campaign.runtime.provider.model must be "provider/model"');
573
+ }
574
+ const overlay = overlayOf(value.overlay, problems);
575
+ if (route === undefined || model === undefined || !model.includes("/")) {
576
+ return null;
577
+ }
578
+ return { route, model, ...(overlay === undefined ? {} : { overlay }) };
579
+ }
580
+ /** Validate the optional, non-secret runtime flags. */
581
+ function runtimeExtras(value, problems) {
582
+ if (value.containerCli !== undefined && value.containerCli !== "podman") {
583
+ problems.push('campaign.runtime.containerCli must be "podman"');
584
+ }
585
+ if (value.passthroughEnv !== undefined &&
586
+ (!Array.isArray(value.passthroughEnv) ||
587
+ !value.passthroughEnv.every(isText))) {
588
+ problems.push("campaign.runtime.passthroughEnv must be environment names");
589
+ return {};
590
+ }
591
+ return {
592
+ ...(typeof value.imageId === "string" && value.imageId !== ""
593
+ ? { imageId: value.imageId }
594
+ : {}),
595
+ ...(value.containerCli === "podman" ? { containerCli: "podman" } : {}),
596
+ ...(Array.isArray(value.passthroughEnv)
597
+ ? { passthroughEnv: value.passthroughEnv }
598
+ : {}),
599
+ };
600
+ }
601
+ /** Validate the mandatory runtime scalars. */
602
+ function runtimeBase(value, problems) {
603
+ const image = requiredText(value, "image", "campaign.runtime", problems);
604
+ const opencodeVersion = requiredText(value, "opencodeVersion", "campaign.runtime", problems);
605
+ const timeoutMs = value.timeoutMs;
606
+ const timeoutValid = typeof timeoutMs === "number" &&
607
+ Number.isFinite(timeoutMs) &&
608
+ timeoutMs > 0;
609
+ if (!timeoutValid) {
610
+ problems.push("campaign.runtime.timeoutMs must be positive");
611
+ }
612
+ const networkValid = value.network === "host" || value.network === "none";
613
+ if (!networkValid) {
614
+ problems.push('campaign.runtime.network must be "host" or "none"');
615
+ }
616
+ if (image === undefined ||
617
+ opencodeVersion === undefined ||
618
+ !timeoutValid ||
619
+ !networkValid) {
620
+ return null;
621
+ }
622
+ return {
623
+ image,
624
+ opencodeVersion,
625
+ timeoutMs: timeoutMs,
626
+ network: value.network,
627
+ };
628
+ }
629
+ /** Parse the runtime block. */
630
+ function parseRuntime(value, problems) {
631
+ if (!isRecord(value)) {
632
+ problems.push("campaign.runtime must be an object");
633
+ return null;
634
+ }
635
+ const base = runtimeBase(value, problems);
636
+ const provider = parseProvider(value.provider, problems);
637
+ const extras = runtimeExtras(value, problems);
638
+ if (base === null || provider === null)
639
+ return null;
640
+ return { ...base, provider, ...extras };
641
+ }
642
+ /** Parse the campaign's suite reference. */
643
+ function suiteRefOf(value, problems) {
644
+ if (!isRecord(value) || !isSafeRelativePath(value.path)) {
645
+ problems.push("campaign.suite.path must be a safe relative path");
646
+ return undefined;
647
+ }
648
+ if (value.sha256 === undefined)
649
+ return { path: value.path };
650
+ if (typeof value.sha256 !== "string" ||
651
+ !/^[0-9a-f]{64}$/.test(value.sha256)) {
652
+ problems.push("campaign.suite.sha256 must be a SHA-256");
653
+ return undefined;
654
+ }
655
+ return { path: value.path, sha256: value.sha256 };
656
+ }
657
+ /** Parse the campaign's arms. */
658
+ function subjectsOf(value, baseDir, problems) {
659
+ if (!Array.isArray(value) || value.length === 0) {
660
+ problems.push("campaign.subjects must be a non-empty array");
661
+ return [];
662
+ }
663
+ const seen = new Set();
664
+ return value.flatMap((entry, index) => {
665
+ const arm = parseArm(entry, `campaign.subjects[${index}]`, baseDir, problems);
666
+ if (arm === null)
667
+ return [];
668
+ if (seen.has(arm.armId)) {
669
+ problems.push(`duplicate armId ${arm.armId}`);
670
+ return [];
671
+ }
672
+ seen.add(arm.armId);
673
+ return [arm];
674
+ });
675
+ }
676
+ /** Parse one explicit slot. */
677
+ function slotOf(value, where, problems) {
678
+ if (!isRecord(value)) {
679
+ problems.push(`${where} must be an object`);
680
+ return null;
681
+ }
682
+ const slotId = requiredId(value, "slotId", where, problems);
683
+ const caseId = requiredId(value, "caseId", where, problems);
684
+ const armId = requiredId(value, "armId", where, problems);
685
+ const repetitionId = requiredId(value, "repetitionId", where, problems);
686
+ if (slotId === undefined ||
687
+ caseId === undefined ||
688
+ armId === undefined ||
689
+ repetitionId === undefined) {
690
+ return null;
691
+ }
692
+ return { slotId, caseId, armId, repetitionId };
693
+ }
694
+ /** Parse the explicit campaign schedule. */
695
+ function scheduleSlotsOf(value, problems) {
696
+ if (!isRecord(value) ||
697
+ value.kind !== "explicit" ||
698
+ !Array.isArray(value.slots) ||
699
+ value.slots.length === 0) {
700
+ problems.push('campaign.schedule must be {kind:"explicit", slots:[...]} with at least one slot');
701
+ return [];
702
+ }
703
+ const seen = new Set();
704
+ return value.slots.flatMap((entry, index) => {
705
+ const slot = slotOf(entry, `campaign.schedule.slots[${index}]`, problems);
706
+ if (slot === null)
707
+ return [];
708
+ if (seen.has(slot.slotId)) {
709
+ problems.push(`duplicate slotId ${slot.slotId}`);
710
+ return [];
711
+ }
712
+ seen.add(slot.slotId);
713
+ return [slot];
714
+ });
715
+ }
716
+ /** Parse the campaign budgets. */
717
+ function budgetsOf(value, slots, problems) {
718
+ if (!isRecord(value)) {
719
+ problems.push("campaign.budgets must be an object");
720
+ return undefined;
721
+ }
722
+ const trials = value.trials;
723
+ const judgeAttempts = value.judgeAttempts;
724
+ const validCount = (candidate) => typeof candidate === "number" &&
725
+ Number.isInteger(candidate) &&
726
+ candidate >= 0;
727
+ if (!validCount(trials)) {
728
+ problems.push("campaign.budgets.trials must be a non-negative integer");
729
+ }
730
+ if (!validCount(judgeAttempts)) {
731
+ problems.push("campaign.budgets.judgeAttempts must be a non-negative integer");
732
+ }
733
+ if (validCount(trials) && slots > 0 && trials > slots) {
734
+ problems.push("campaign.budgets.trials may not exceed the scheduled slot count");
735
+ }
736
+ if (!validCount(trials) || !validCount(judgeAttempts))
737
+ return undefined;
738
+ return { trials, judgeAttempts };
739
+ }
740
+ /** One non-negative integer field, or undefined when invalid. */
741
+ function nonNegativeCount(value, where, problems) {
742
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
743
+ return value;
744
+ }
745
+ problems.push(`${where} must be a non-negative integer`);
746
+ return undefined;
747
+ }
748
+ /** The string-list field of a recovery registration. */
749
+ function recoveryClasses(value, problems) {
750
+ if (!Array.isArray(value) || !value.every(isText)) {
751
+ problems.push("campaign.assessment.recovery.eligibleFailureClasses must be strings");
752
+ return null;
753
+ }
754
+ return value.filter(isText);
755
+ }
756
+ /** Whether a provider-request limit is null or a positive integer. */
757
+ function validRequestLimit(value) {
758
+ return (value === null || (typeof value === "number" && Number.isInteger(value)));
759
+ }
760
+ /** Validate the optional recovery registration's shape. */
761
+ /** The four numeric fields of a recovery registration. */
762
+ function recoveryCounts(value, problems) {
763
+ const version = nonNegativeCount(value.version, "campaign.assessment.recovery.version", problems);
764
+ const maxRecoveriesPerSlot = nonNegativeCount(value.maxRecoveriesPerSlot, "campaign.assessment.recovery.maxRecoveriesPerSlot", problems);
765
+ const recoveryCeiling = nonNegativeCount(value.recoveryCeiling, "campaign.assessment.recovery.recoveryCeiling", problems);
766
+ const totalJudgeAttempts = nonNegativeCount(value.totalJudgeAttempts, "campaign.assessment.recovery.totalJudgeAttempts", problems);
767
+ return version === undefined ||
768
+ maxRecoveriesPerSlot === undefined ||
769
+ recoveryCeiling === undefined ||
770
+ totalJudgeAttempts === undefined
771
+ ? null
772
+ : {
773
+ version,
774
+ maxRecoveriesPerSlot,
775
+ recoveryCeiling,
776
+ totalJudgeAttempts,
777
+ };
778
+ }
779
+ /** Validate the optional recovery registration's shape. */
780
+ function recoveryOf(value, problems) {
781
+ if (!isRecord(value)) {
782
+ problems.push("campaign.assessment.recovery must be an object");
783
+ return undefined;
784
+ }
785
+ const counts = recoveryCounts(value, problems);
786
+ if (counts === null)
787
+ return undefined;
788
+ const classes = recoveryClasses(value.eligibleFailureClasses, problems);
789
+ if (classes === null)
790
+ return undefined;
791
+ if (typeof value.requiresCompletionEvidence !== "boolean") {
792
+ problems.push("campaign.assessment.recovery.requiresCompletionEvidence must be a boolean");
793
+ return undefined;
794
+ }
795
+ const limit = value.providerRequestLimit;
796
+ if (!validRequestLimit(limit)) {
797
+ problems.push("campaign.assessment.recovery.providerRequestLimit must be an integer or null");
798
+ return undefined;
799
+ }
800
+ return {
801
+ ...counts,
802
+ eligibleFailureClasses: classes,
803
+ requiresCompletionEvidence: value.requiresCompletionEvidence,
804
+ providerRequestLimit: limit,
805
+ };
806
+ }
807
+ /** The one accepted assessment kind, or undefined with a problem. */
808
+ function assessmentKind(value, problems) {
809
+ if (value === "skill-activation" || value === "docs-recording") {
810
+ return value;
811
+ }
812
+ problems.push('campaign.assessment.kind must be "skill-activation" or "docs-recording"');
813
+ return undefined;
814
+ }
815
+ /** The optional judge block, or undefined with a problem. */
816
+ function judgeOf(value, problems) {
817
+ if (!isRecord(value) ||
818
+ !isText(value.model) ||
819
+ !value.model.includes("/")) {
820
+ problems.push("campaign.assessment.judge.model must be provider/model");
821
+ return undefined;
822
+ }
823
+ const timeoutMs = positiveTimeout(value.timeoutMs, "campaign.assessment.judge.timeoutMs", problems);
824
+ if (value.timeoutMs !== undefined && timeoutMs === undefined) {
825
+ return undefined;
826
+ }
827
+ return {
828
+ model: value.model,
829
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
830
+ };
831
+ }
832
+ /** Parse the campaign assessment block. */
833
+ function assessmentOf(value, problems) {
834
+ if (!isRecord(value)) {
835
+ problems.push("campaign.assessment must be an object");
836
+ return undefined;
837
+ }
838
+ const kind = assessmentKind(value.kind, problems);
839
+ if (kind === undefined)
840
+ return undefined;
841
+ const composed = assessmentComposition(value, kind, problems);
842
+ return composed === null ? undefined : { kind, ...composed };
843
+ }
844
+ /** The optional judge and recovery blocks, or null after a problem. */
845
+ function assessmentComposition(value, kind, problems) {
846
+ const recovery = value.recovery === undefined
847
+ ? undefined
848
+ : recoveryOf(value.recovery, problems);
849
+ if (recovery !== undefined && kind !== "docs-recording") {
850
+ problems.push("campaign.assessment.recovery is only supported for docs-recording");
851
+ return null;
852
+ }
853
+ if (value.judge === undefined) {
854
+ return recovery === undefined ? {} : { recovery };
855
+ }
856
+ const judge = judgeOf(value.judge, problems);
857
+ if (judge === undefined)
858
+ return null;
859
+ return { judge, ...(recovery === undefined ? {} : { recovery }) };
860
+ }
861
+ /** Whether every mandatory campaign part parsed without problems. */
862
+ function campaignPartsComplete(parts) {
863
+ return (parts.problems.length === 0 &&
864
+ parts.campaignId !== undefined &&
865
+ parts.suiteRef !== undefined &&
866
+ parts.runtime !== null &&
867
+ parts.budgets !== undefined &&
868
+ parts.assessment !== undefined);
869
+ }
870
+ /**
871
+ * Validate a campaign document, before its suite is resolved.
872
+ * @param value The parsed JSON value.
873
+ * @param baseDir Directory for relative subject sources.
874
+ * @returns The typed campaign or every problem found.
875
+ */
876
+ export function validateCampaign(value, baseDir = ".") {
877
+ if (!isRecord(value)) {
878
+ return { ok: false, problems: ["campaign must be an object"] };
879
+ }
880
+ const problems = [];
881
+ if (value.schemaVersion !== 1) {
882
+ problems.push("campaign.schemaVersion must be 1");
883
+ }
884
+ const campaignId = requiredId(value, "campaignId", "campaign", problems);
885
+ const suiteRef = suiteRefOf(value.suite, problems);
886
+ const arms = subjectsOf(value.subjects, baseDir, problems);
887
+ const slots = scheduleSlotsOf(value.schedule, problems);
888
+ const runtime = parseRuntime(value.runtime, problems);
889
+ const budgets = budgetsOf(value.budgets, slots.length, problems);
890
+ const assessment = assessmentOf(value.assessment, problems);
891
+ if (value.permissions !== undefined && !isRecord(value.permissions)) {
892
+ problems.push("campaign.permissions must be an object");
893
+ }
894
+ if (!campaignPartsComplete({
895
+ problems,
896
+ campaignId,
897
+ suiteRef,
898
+ runtime,
899
+ budgets,
900
+ assessment,
901
+ })) {
902
+ return { ok: false, problems };
903
+ }
904
+ return {
905
+ ok: true,
906
+ value: {
907
+ schemaVersion: 1,
908
+ campaignId: campaignId,
909
+ suite: suiteRef,
910
+ subjects: arms,
911
+ schedule: { kind: "explicit", slots },
912
+ runtime: runtime,
913
+ budgets: budgets,
914
+ assessment: assessment,
915
+ ...(isRecord(value.permissions)
916
+ ? { permissions: value.permissions }
917
+ : {}),
918
+ ...(value.stopOnFirstFailure === true
919
+ ? { stopOnFirstFailure: true }
920
+ : {}),
921
+ },
922
+ };
923
+ }
924
+ /** Check one slot's case/arm references and target-skill supply. */
925
+ function slotReferenceProblems(slot, cases, arms) {
926
+ const testCase = cases.get(slot.caseId);
927
+ const arm = arms.get(slot.armId);
928
+ if (testCase === undefined) {
929
+ return [`slot ${slot.slotId} names unknown caseId ${slot.caseId}`];
930
+ }
931
+ if (arm === undefined) {
932
+ return [`slot ${slot.slotId} names unknown armId ${slot.armId}`];
933
+ }
934
+ if (testCase.kind === "skill-trigger" &&
935
+ testCase.allowMissingTarget !== true &&
936
+ !(arm.skills ?? []).some((skill) => skill.id === testCase.targetSkillId)) {
937
+ return [
938
+ `slot ${slot.slotId}: arm ${arm.armId} does not supply skill ${testCase.targetSkillId}`,
939
+ ];
940
+ }
941
+ return [];
942
+ }
943
+ /**
944
+ * Validate a campaign together with its suite and cross-references.
945
+ * @param campaignValue The parsed campaign document.
946
+ * @param suiteValue The parsed suite document.
947
+ * @param campaignPath Absolute campaign path, used for suite containment.
948
+ * @param suitePath Absolute suite path.
949
+ * @returns The bundle or every problem found.
950
+ */
951
+ export function validateBundle(campaignValue, suiteValue, campaignPath, suitePath) {
952
+ const campaign = validateCampaign(campaignValue, dirname(campaignPath));
953
+ const suite = validateSuite(suiteValue);
954
+ if (!campaign.ok || !suite.ok) {
955
+ return {
956
+ ok: false,
957
+ problems: [
958
+ ...(campaign.ok ? [] : campaign.problems),
959
+ ...(suite.ok ? [] : suite.problems),
960
+ ],
961
+ };
962
+ }
963
+ const problems = [];
964
+ if (campaign.value.assessment.kind !== suite.value.assessment) {
965
+ problems.push(`campaign.assessment.kind ${campaign.value.assessment.kind} does not match suite.assessment ${suite.value.assessment}`);
966
+ }
967
+ const cases = new Map(suite.value.cases.map((one) => [one.caseId, one]));
968
+ const arms = new Map(campaign.value.subjects.map((one) => [one.armId, one]));
969
+ for (const slot of campaign.value.schedule.slots) {
970
+ problems.push(...slotReferenceProblems(slot, cases, arms));
971
+ }
972
+ if (problems.length > 0)
973
+ return { ok: false, problems };
974
+ return {
975
+ ok: true,
976
+ value: {
977
+ campaign: campaign.value,
978
+ suite: suite.value,
979
+ campaignPath,
980
+ suitePath,
981
+ declaredSuiteSha256: campaign.value.suite.sha256 ?? null,
982
+ },
983
+ };
984
+ }
985
+ /**
986
+ * Read and validate a campaign and its suite from disk.
987
+ * @param campaignPath The campaign document path.
988
+ * @returns The bundle, or actionable problems including unreadable files.
989
+ */
990
+ export function loadCampaignBundle(campaignPath) {
991
+ const resolvedCampaign = resolve(campaignPath);
992
+ const campaignJson = readJson(resolvedCampaign);
993
+ if (!campaignJson.ok) {
994
+ return { ok: false, problems: [campaignJson.problem] };
995
+ }
996
+ const parsed = validateCampaign(campaignJson.value, dirname(resolvedCampaign));
997
+ if (!parsed.ok) {
998
+ return { ok: false, problems: parsed.problems };
999
+ }
1000
+ const suitePath = resolve(dirname(resolvedCampaign), parsed.value.suite.path);
1001
+ const suiteJson = readJson(suitePath);
1002
+ if (!suiteJson.ok) {
1003
+ return { ok: false, problems: [suiteJson.problem] };
1004
+ }
1005
+ if (parsed.value.suite.sha256 !== undefined) {
1006
+ const observed = sha256Bytes(readFileSync(suitePath));
1007
+ if (observed !== parsed.value.suite.sha256) {
1008
+ return {
1009
+ ok: false,
1010
+ problems: [
1011
+ `suite sha256 mismatch: declared ${parsed.value.suite.sha256}, observed ${observed}`,
1012
+ ],
1013
+ };
1014
+ }
1015
+ }
1016
+ return validateBundle(campaignJson.value, suiteJson.value, resolvedCampaign, suitePath);
1017
+ }
1018
+ /** Whether a value is one of the finite assessment kinds. */
1019
+ export function isAssessmentKind(value) {
1020
+ return value === "skill-activation" || value === "docs-recording";
1021
+ }