planr 1.9.0 → 1.10.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +32 -5
  2. package/docs/ARCHITECTURE.md +1 -1
  3. package/docs/RELEASE.md +37 -7
  4. package/docs/contracts/EVIDENCE_CONTRACT_V1.md +283 -0
  5. package/docs/contracts/fixtures/evidence/v1/examples/coverage-verdict.json +66 -0
  6. package/docs/contracts/fixtures/evidence/v1/examples/evidence-attempt.json +39 -0
  7. package/docs/contracts/fixtures/evidence/v1/examples/evidence-policy.json +117 -0
  8. package/docs/contracts/fixtures/evidence/v1/examples/evidence-receipt.json +93 -0
  9. package/docs/contracts/fixtures/evidence/v1/examples/evidence-waiver.json +31 -0
  10. package/docs/contracts/fixtures/evidence/v1/examples/proof-obligation.json +67 -0
  11. package/docs/contracts/fixtures/evidence/v1/examples/proof-preset.json +28 -0
  12. package/docs/contracts/fixtures/evidence/v1/examples/untrusted-evidence-proposal.json +20 -0
  13. package/docs/contracts/fixtures/evidence/v1/examples/verification-capability-instance.json +39 -0
  14. package/docs/contracts/fixtures/evidence/v1/examples/verification-capability-manifest.json +52 -0
  15. package/docs/contracts/fixtures/evidence/v1/negative/aborted-zero-exit-attempt.json +39 -0
  16. package/docs/contracts/fixtures/evidence/v1/negative/adapter-registration-missing-execution-contract.json +97 -0
  17. package/docs/contracts/fixtures/evidence/v1/negative/available-empty-probe-capability.json +27 -0
  18. package/docs/contracts/fixtures/evidence/v1/negative/blocked-coverage-with-stale-reason.json +27 -0
  19. package/docs/contracts/fixtures/evidence/v1/negative/empty-trust-bindings-receipt.json +72 -0
  20. package/docs/contracts/fixtures/evidence/v1/negative/failed-zero-exit-attempt.json +39 -0
  21. package/docs/contracts/fixtures/evidence/v1/negative/forged-trusted-receipt.json +35 -0
  22. package/docs/contracts/fixtures/evidence/v1/negative/inconclusive-clean-exit-attempt.json +39 -0
  23. package/docs/contracts/fixtures/evidence/v1/negative/inconclusive-coverage-with-blocked-reason.json +27 -0
  24. package/docs/contracts/fixtures/evidence/v1/negative/minimal-policy-missing-owned-inputs.json +5 -0
  25. package/docs/contracts/fixtures/evidence/v1/negative/mismatched-probe-execution-id-capability.json +42 -0
  26. package/docs/contracts/fixtures/evidence/v1/negative/passed-empty-attempt.json +22 -0
  27. package/docs/contracts/fixtures/evidence/v1/negative/policy-default-preset-unregistered.json +111 -0
  28. package/docs/contracts/fixtures/evidence/v1/negative/policy-weakening-without-waiver.json +111 -0
  29. package/docs/contracts/fixtures/evidence/v1/negative/process-probe-missing-output-limits.json +49 -0
  30. package/docs/contracts/fixtures/evidence/v1/negative/satisfied-empty-coverage-verdict.json +14 -0
  31. package/docs/contracts/fixtures/evidence/v1/negative/skipped-zero-exit-attempt.json +39 -0
  32. package/docs/contracts/fixtures/evidence/v1/negative/stale-coverage-with-inconclusive-reason.json +27 -0
  33. package/docs/contracts/fixtures/evidence/v1/negative/timed-out-zero-exit-attempt.json +39 -0
  34. package/docs/contracts/fixtures/evidence/v1/negative/unavailable-zero-exit-attempt.json +39 -0
  35. package/docs/contracts/fixtures/evidence/v1/negative/unbound-waiver.json +21 -0
  36. package/docs/contracts/fixtures/evidence/v1/negative/unsatisfied-coverage-without-gap.json +63 -0
  37. package/docs/contracts/fixtures/evidence/v1/negative/waived-coverage-forbidden-gap-reason.json +28 -0
  38. package/docs/contracts/fixtures/evidence/v1/negative/waived-coverage-missing-waiver-id.json +27 -0
  39. package/docs/contracts/fixtures/evidence/v1/schemas/evidence-contract-v1.schema.json +2705 -0
  40. package/docs/documentation/CONTRACT.md +1 -1
  41. package/docs/documentation/COVERAGE.md +8 -5
  42. package/docs/documentation/INFORMATION_ARCHITECTURE.md +1 -1
  43. package/docs/fixtures/mcp-contract.json +18 -0
  44. package/npm/native/darwin-arm64/planr +0 -0
  45. package/npm/native/darwin-arm64/planr-host-capability-validator +0 -0
  46. package/npm/native/darwin-x86_64/planr +0 -0
  47. package/npm/native/darwin-x86_64/planr-host-capability-validator +0 -0
  48. package/npm/native/linux-arm64/planr +0 -0
  49. package/npm/native/linux-arm64/planr-host-capability-validator +0 -0
  50. package/npm/native/linux-x86_64/planr +0 -0
  51. package/npm/native/linux-x86_64/planr-host-capability-validator +0 -0
  52. package/package.json +15 -2
  53. package/plugins/planr/.claude-plugin/plugin.json +1 -1
  54. package/plugins/planr/.codex-plugin/plugin.json +1 -1
  55. package/plugins/planr/skills/planr-goal/SKILL.md +7 -4
  56. package/plugins/planr/skills/planr-loop/SKILL.md +11 -8
  57. package/plugins/planr/skills/planr-loop/references/recovery-and-verification.md +5 -4
  58. package/plugins/planr/skills/planr-plan/SKILL.md +1 -1
  59. package/plugins/planr/skills/planr-review/SKILL.md +5 -1
  60. package/plugins/planr/skills/planr-status/SKILL.md +3 -1
  61. package/plugins/planr/skills/planr-verify-web/SKILL.md +9 -6
  62. package/plugins/planr/skills/planr-work/SKILL.md +17 -3
  63. package/scripts/host-capability-experiment.mjs +2477 -0
  64. package/scripts/host-capability-runtime/v1/schemas/host-capability-expected-manifest.schema.json +377 -0
  65. package/scripts/host-capability-runtime/v1/schemas/host-capability-observed-raw.schema.json +439 -0
  66. package/scripts/host-capability-runtime/v1/schemas/host-capability-provenance.schema.json +178 -0
  67. package/scripts/planr-host-capability-validator +51 -0
@@ -0,0 +1,2477 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ copyFileSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ rmdirSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { spawn, spawnSync } from "node:child_process";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+
23
+ const RAW_SCHEMA = "planr.host_capability_observed_raw.v1";
24
+ const EXPECTED_SCHEMA = "planr.host_capability_expected_manifest.v1";
25
+ const SUITE_SCHEMA = "planr.host_capability_experiment_suite.v1";
26
+ const RAW_PAYLOAD_PREFIX = "host-capability-raw/";
27
+ const EXPECTED_PAYLOAD_PREFIX = "host-capability-expected/";
28
+ const SUMMARY_SCHEMA = "planr.host_capability_experiment_summary.v1";
29
+ const EXTERNAL_CAPTURE_ENVELOPE_SCHEMA = "planr.host_capability_external_capture_envelope.v1";
30
+ const EXTERNAL_CAPTURE_ENVELOPE_PATH = "external-capture-envelope.json";
31
+ const ADAPTER_VERSION = "planr-host-experiment-harness/1.0.0";
32
+ const MANIFEST_REF_PATH = "manifests/phase1-host-capability-manifests.json";
33
+ const RAW_SCHEMA_REF_PATH = "schemas/host-capability-observed-raw.schema.json";
34
+ const EXPECTED_SCHEMA_REF_PATH = "schemas/host-capability-expected-manifest.schema.json";
35
+ const PROVENANCE_SCHEMA_REF_PATH = "schemas/host-capability-provenance.schema.json";
36
+ const PROVENANCE_REF_PATH = "provenance/host-capability-captures.json";
37
+ const VALIDATOR_IDENTITY_SCHEMA = "planr.host_capability_validator_identity.v1";
38
+ const VALIDATOR_RESULT_SCHEMA = "planr.host_capability_validator_result.v1";
39
+ const VALIDATOR_NAME = "planr-host-capability-validator";
40
+ const VALIDATOR_VERSION = "1.0.0";
41
+ const VALIDATOR_IDENTITY_FIELDS = new Set(["schema_version", "validator", "validator_version"]);
42
+ const VALIDATOR_RESULT_FIELDS = new Set([
43
+ "schema_version",
44
+ "validator",
45
+ "validator_version",
46
+ "verdict",
47
+ "input_digest",
48
+ "validated_raw_documents",
49
+ "validated_instances",
50
+ ]);
51
+
52
+ const EVIDENCE_AVAILABILITY_STATUSES = new Set([
53
+ "available",
54
+ "unavailable",
55
+ "degraded",
56
+ "permission_denied",
57
+ "sandbox_blocked",
58
+ "unsupported",
59
+ "probe_failed",
60
+ ]);
61
+ const EVIDENCE_ATTEMPT_STATUSES = new Set([
62
+ "passed",
63
+ "failed",
64
+ "skipped",
65
+ "timed_out",
66
+ "aborted",
67
+ "unavailable",
68
+ "inconclusive",
69
+ ]);
70
+ const OBSERVED_CLAIM_SOURCES = new Set(["observed_capture"]);
71
+ const CLAIM_SOURCE_RULES = {
72
+ observed_capture: {
73
+ sourceKind: "external_observed_capture",
74
+ observationMode: "observed_payload",
75
+ inputKinds: new Set(["controlled_probe"]),
76
+ },
77
+ mechanical_unavailable_probe: {
78
+ sourceKind: "mechanical_unavailable_probe",
79
+ observationMode: "mechanical_invocation",
80
+ inputKinds: new Set(["mechanical_availability_probe"]),
81
+ },
82
+ capture_mode_placeholder: {
83
+ sourceKind: "unprobed_placeholder",
84
+ observationMode: "unprobed_placeholder",
85
+ inputKinds: new Set(["unprobed_placeholder"]),
86
+ },
87
+ };
88
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
89
+ const EXPERIMENT_ID_PATTERN = /^exp-[a-z0-9]+(?:-[a-z0-9]+)*$/;
90
+ const EXTERNAL_CAPTURE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
91
+ const EXTERNAL_CAPTURE_FUTURE_SKEW_MS = 5 * 60 * 1000;
92
+ const IMPORT_METADATA = Symbol("planrHostExternalImportMetadata");
93
+
94
+ const RAW_TOP_LEVEL_FIELDS = new Set([
95
+ "schema_version",
96
+ "payload_version",
97
+ "experiment_id",
98
+ "host_identity",
99
+ "surface",
100
+ "tool_name",
101
+ "event_source",
102
+ "started_at",
103
+ "ended_at",
104
+ "input",
105
+ "events",
106
+ "result",
107
+ "provenance_ref",
108
+ ]);
109
+ const RAW_INPUT_FIELDS = new Set([
110
+ "input_kind",
111
+ "command",
112
+ "cwd",
113
+ "javascript",
114
+ "probe",
115
+ "replay_mode",
116
+ "attempts",
117
+ "reset_between_attempts",
118
+ "tool",
119
+ "arguments",
120
+ "navigation",
121
+ "setup",
122
+ "operations",
123
+ "ui_write_actions",
124
+ "function",
125
+ "args",
126
+ ]);
127
+ const RAW_RESULT_FIELDS = new Set([
128
+ "final_status",
129
+ "permissions",
130
+ "sandbox",
131
+ "availability_reason",
132
+ "missing_fields",
133
+ "blind_spots",
134
+ "artifact_refs",
135
+ "artifact_digests",
136
+ "experiment_plan",
137
+ "notes",
138
+ ]);
139
+ const CLAIM_SOURCES = new Set([
140
+ "observed_capture",
141
+ "mechanical_unavailable_probe",
142
+ "capture_mode_placeholder",
143
+ ]);
144
+ const DIGEST_SUBSTRING_PATTERN = /sha256:[0-9a-f]{64}/;
145
+
146
+ function usage() {
147
+ console.error(
148
+ "Usage: node scripts/host-capability-experiment.mjs replay --fixture-root <dir>\n" +
149
+ " node scripts/host-capability-experiment.mjs capture --out-dir <dir> [--import-fixture-root <external-envelope-dir>]",
150
+ );
151
+ }
152
+
153
+ function fail(message) {
154
+ throw new Error(message);
155
+ }
156
+
157
+ function packageRoot() {
158
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
159
+ }
160
+
161
+ function packagedFixtureRoot() {
162
+ return path.join(packageRoot(), "tests/fixtures/evidence/host-capabilities");
163
+ }
164
+
165
+ function packagedRuntimeRoot() {
166
+ return path.join(packageRoot(), "scripts/host-capability-runtime");
167
+ }
168
+
169
+ function readJson(file) {
170
+ try {
171
+ return JSON.parse(readFileSync(file, "utf8"));
172
+ } catch (error) {
173
+ fail(`${file} must be readable JSON: ${error.message}`);
174
+ }
175
+ }
176
+
177
+ function writeJson(file, value) {
178
+ mkdirSync(path.dirname(file), { recursive: true });
179
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
180
+ }
181
+
182
+ function sha256Prefixed(bytes) {
183
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
184
+ }
185
+
186
+ function sha256File(file) {
187
+ return sha256Prefixed(readFileSync(file));
188
+ }
189
+
190
+ function stableDigest(value) {
191
+ return sha256Prefixed(JSON.stringify(value));
192
+ }
193
+
194
+ function assertObject(value, label) {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
196
+ fail(`${label} must be an object`);
197
+ }
198
+ return value;
199
+ }
200
+
201
+ function assertOnlyKeys(value, allowed, label) {
202
+ assertObject(value, label);
203
+ for (const key of Object.keys(value)) {
204
+ if (!allowed.has(key)) {
205
+ fail(`${label}.${key} is not part of the v1 contract`);
206
+ }
207
+ }
208
+ }
209
+
210
+ function assertArray(value, label) {
211
+ if (!Array.isArray(value)) {
212
+ fail(`${label} must be an array`);
213
+ }
214
+ return value;
215
+ }
216
+
217
+ function assertNonEmptyString(value, label) {
218
+ if (typeof value !== "string" || value.length === 0) {
219
+ fail(`${label} must be a non-empty string`);
220
+ }
221
+ return value;
222
+ }
223
+
224
+ function assertTimestamp(value, label) {
225
+ assertNonEmptyString(value, label);
226
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) {
227
+ fail(`${label} must be a deterministic UTC timestamp`);
228
+ }
229
+ parseUtcTimestamp(value, label);
230
+ }
231
+
232
+ function parseUtcTimestamp(value, label) {
233
+ assertNonEmptyString(value, label);
234
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) {
235
+ fail(`${label} must be a deterministic UTC timestamp`);
236
+ }
237
+ const millis = Date.parse(value);
238
+ if (!Number.isFinite(millis)) {
239
+ fail(`${label} must be a valid UTC timestamp`);
240
+ }
241
+ const canonical = new Date(millis).toISOString().replace(/\.\d{3}Z$/, "Z");
242
+ if (canonical !== value) {
243
+ fail(`${label} must be a valid UTC timestamp`);
244
+ }
245
+ return millis;
246
+ }
247
+
248
+ function assertTimeRange(startedAt, endedAt, label) {
249
+ const started = parseUtcTimestamp(startedAt, `${label}.started_at`);
250
+ const ended = parseUtcTimestamp(endedAt, `${label}.ended_at`);
251
+ if (started > ended) {
252
+ fail(`${label}.started_at must not be later than ended_at`);
253
+ }
254
+ return { started, ended };
255
+ }
256
+
257
+ function assertExperimentId(value, label) {
258
+ const id = assertNonEmptyString(value, label);
259
+ if (!EXPERIMENT_ID_PATTERN.test(id)) {
260
+ fail(`${label} must be a canonical experiment id`);
261
+ }
262
+ return id;
263
+ }
264
+
265
+ function assertDigest(value, label) {
266
+ assertNonEmptyString(value, label);
267
+ if (!DIGEST_PATTERN.test(value)) {
268
+ fail(`${label} must be sha256:<64 lowercase hex>`);
269
+ }
270
+ }
271
+
272
+ function assertMajorOne(schema, expected, label) {
273
+ if (schema !== expected) {
274
+ const version = assertNonEmptyString(schema, label).match(/\.v(\d+)$/);
275
+ if (version && Number(version[1]) !== 1) {
276
+ fail(`${label} has unsupported major version ${version[1]}`);
277
+ }
278
+ fail(`${label} must be ${expected}`);
279
+ }
280
+ }
281
+
282
+ function assertPayloadMajorOne(payloadVersion, prefix, label) {
283
+ const value = assertNonEmptyString(payloadVersion, label);
284
+ if (!value.startsWith(prefix)) {
285
+ fail(`${label} must start with ${prefix}`);
286
+ }
287
+ const major = Number(value.slice(prefix.length).split(".")[0]);
288
+ if (!Number.isInteger(major) || major !== 1) {
289
+ fail(`${label} has unsupported major version ${major}`);
290
+ }
291
+ }
292
+
293
+ function safeFixturePath(root, relativePath, label) {
294
+ const value = assertNonEmptyString(relativePath, label);
295
+ if (path.isAbsolute(value) || value.split(/[\\/]/).includes("..")) {
296
+ fail(`${label} must be a relative fixture path without parent traversal`);
297
+ }
298
+ const resolved = path.resolve(root, value);
299
+ const resolvedRoot = path.resolve(root);
300
+ if (!resolved.startsWith(`${resolvedRoot}${path.sep}`) && resolved !== resolvedRoot) {
301
+ fail(`${label} escapes fixture root`);
302
+ }
303
+ return resolved;
304
+ }
305
+
306
+ function readdirSyncSorted(dir) {
307
+ try {
308
+ return readdirSync(dir).sort();
309
+ } catch (error) {
310
+ fail(`${dir} must be readable: ${error.message}`);
311
+ }
312
+ }
313
+
314
+ function loadSuite(fixtureRoot) {
315
+ const suitePath = path.join(fixtureRoot, "experiment-suite.json");
316
+ const suite = assertObject(readJson(suitePath), suitePath);
317
+ assertOnlyKeys(
318
+ suite,
319
+ new Set(["schema_version", "suite_id", "docs_are_experiment_design_only", "source_docs", "experiments"]),
320
+ "suite",
321
+ );
322
+ assertMajorOne(suite.schema_version, SUITE_SCHEMA, "suite.schema_version");
323
+ assertNonEmptyString(suite.suite_id, "suite.suite_id");
324
+ if (suite.source_docs !== undefined) {
325
+ assertArray(suite.source_docs, "suite.source_docs");
326
+ }
327
+ const experiments = new Map();
328
+ for (const experiment of assertArray(suite.experiments, "suite.experiments")) {
329
+ assertObject(experiment, "suite.experiments[]");
330
+ assertOnlyKeys(
331
+ experiment,
332
+ new Set([
333
+ "id",
334
+ "host",
335
+ "surface",
336
+ "input_kind",
337
+ "expected_tool_name",
338
+ "expected_event_source",
339
+ "expected_result_status",
340
+ ]),
341
+ "suite.experiments[]",
342
+ );
343
+ const id = assertExperimentId(experiment.id, "experiment.id");
344
+ if (experiments.has(id)) {
345
+ fail(`duplicate experiment id ${id}`);
346
+ }
347
+ for (const field of [
348
+ "surface",
349
+ "host",
350
+ "expected_tool_name",
351
+ "expected_event_source",
352
+ "expected_result_status",
353
+ "input_kind",
354
+ ]) {
355
+ assertNonEmptyString(experiment[field], `experiment.${field}`);
356
+ }
357
+ if (!EVIDENCE_AVAILABILITY_STATUSES.has(experiment.expected_result_status)) {
358
+ fail(`experiment ${id} uses unknown Evidence availability status`);
359
+ }
360
+ experiments.set(id, experiment);
361
+ }
362
+ if (experiments.size === 0) {
363
+ fail("suite.experiments must not be empty");
364
+ }
365
+ return { suite, experiments };
366
+ }
367
+
368
+ function loadSchemaRefs(fixtureRoot, expected) {
369
+ const schemaRefs = assertObject(expected.schema_refs, "expected.schema_refs");
370
+ assertOnlyKeys(schemaRefs, new Set(["raw", "expected", "provenance"]), "expected.schema_refs");
371
+ return {
372
+ raw: validateFileRef(fixtureRoot, schemaRefs.raw, "expected.schema_refs.raw"),
373
+ expected: validateFileRef(fixtureRoot, schemaRefs.expected, "expected.schema_refs.expected"),
374
+ provenance: validateFileRef(fixtureRoot, schemaRefs.provenance, "expected.schema_refs.provenance"),
375
+ };
376
+ }
377
+
378
+ function validateFileRef(fixtureRoot, ref, label) {
379
+ assertOnlyKeys(ref, new Set(["path", "digest"]), label);
380
+ const file = safeFixturePath(fixtureRoot, ref.path, `${label}.path`);
381
+ if (!existsSync(file)) {
382
+ fail(`${label}.path points at a missing file`);
383
+ }
384
+ assertDigest(ref.digest, `${label}.digest`);
385
+ const digest = sha256File(file);
386
+ if (ref.digest !== digest) {
387
+ fail(`${label}.digest mismatch`);
388
+ }
389
+ return { path: ref.path, digest, document: readJson(file) };
390
+ }
391
+
392
+ function loadProvenance(fixtureRoot, provenanceRef, schemaRefs) {
393
+ const ref = validateFileRef(fixtureRoot, provenanceRef, "provenance_ref");
394
+ const record = assertObject(readJson(safeFixturePath(fixtureRoot, ref.path, "provenance_ref.path")), ref.path);
395
+ assertOnlyKeys(record, new Set(["schema_version", "schema_ref", "schema_digest", "suite_id", "captures"]), "provenance");
396
+ assertMajorOne(record.schema_version, "planr.host_capability_provenance.v1", "provenance.schema_version");
397
+ if (record.schema_ref !== schemaRefs.provenance.path || record.schema_digest !== schemaRefs.provenance.digest) {
398
+ fail("provenance schema_ref/schema_digest must match expected schema_refs.provenance");
399
+ }
400
+ const byId = new Map();
401
+ for (const capture of assertArray(record.captures, "provenance.captures")) {
402
+ assertOnlyKeys(
403
+ capture,
404
+ new Set([
405
+ "experiment_id",
406
+ "source_kind",
407
+ "host",
408
+ "surface",
409
+ "input_kind",
410
+ "observation_mode",
411
+ "tool_name",
412
+ "event_source",
413
+ "host_version",
414
+ "adapter_version",
415
+ "claim_source",
416
+ "availability_reason",
417
+ "probe_checks",
418
+ "missing_fields",
419
+ "artifact_ids",
420
+ "captured_at",
421
+ "external_producer",
422
+ ]),
423
+ "provenance.captures[]",
424
+ );
425
+ const id = assertNonEmptyString(capture.experiment_id, "provenance.captures[].experiment_id");
426
+ if (byId.has(id)) {
427
+ fail(`duplicate provenance capture ${id}`);
428
+ }
429
+ if (!CLAIM_SOURCES.has(assertNonEmptyString(capture.claim_source, "provenance.captures[].claim_source"))) {
430
+ fail(`provenance ${id} has unsupported claim_source`);
431
+ }
432
+ assertNonEmptyString(capture.observation_mode, `provenance ${id}.observation_mode`);
433
+ validateProbeChecks(capture.probe_checks, `provenance ${id}.probe_checks`);
434
+ validateOptionalStringList(assertArray(capture.missing_fields, `provenance ${id}.missing_fields`), `provenance ${id}.missing_fields`);
435
+ validateOptionalStringList(assertArray(capture.artifact_ids, `provenance ${id}.artifact_ids`), `provenance ${id}.artifact_ids`);
436
+ assertTimestamp(capture.captured_at, `provenance ${id}.captured_at`);
437
+ if (capture.external_producer !== undefined) {
438
+ assertOnlyKeys(
439
+ capture.external_producer,
440
+ new Set(["name", "version", "captured_at", "envelope_digest"]),
441
+ `provenance ${id}.external_producer`,
442
+ );
443
+ assertNonEmptyString(capture.external_producer.name, `provenance ${id}.external_producer.name`);
444
+ assertNonEmptyString(capture.external_producer.version, `provenance ${id}.external_producer.version`);
445
+ assertTimestamp(capture.external_producer.captured_at, `provenance ${id}.external_producer.captured_at`);
446
+ assertDigest(capture.external_producer.envelope_digest, `provenance ${id}.external_producer.envelope_digest`);
447
+ if (parseUtcTimestamp(capture.external_producer.captured_at, `provenance ${id}.external_producer.captured_at`) < parseUtcTimestamp(capture.captured_at, `provenance ${id}.captured_at`)) {
448
+ fail(`provenance ${id}.external_producer.captured_at must not be earlier than captured_at`);
449
+ }
450
+ }
451
+ byId.set(id, capture);
452
+ }
453
+ return { ref, record, byId };
454
+ }
455
+
456
+ function loadRawCaptures(fixtureRoot, experiments) {
457
+ const observedDir = path.join(fixtureRoot, "observed");
458
+ const rawById = new Map();
459
+ for (const fileName of readdirSyncSorted(observedDir)) {
460
+ if (!fileName.endsWith(".json")) {
461
+ continue;
462
+ }
463
+ const file = path.join(observedDir, fileName);
464
+ const raw = assertObject(readJson(file), file);
465
+ validateRawCapture(fixtureRoot, raw, experiments, file);
466
+ if (rawById.has(raw.experiment_id)) {
467
+ fail(`duplicate raw capture for ${raw.experiment_id}`);
468
+ }
469
+ rawById.set(raw.experiment_id, raw);
470
+ }
471
+ for (const id of experiments.keys()) {
472
+ if (!rawById.has(id)) {
473
+ fail(`missing raw capture for experiment ${id}`);
474
+ }
475
+ }
476
+ validateArtifactGraph(fixtureRoot, rawById);
477
+ return rawById;
478
+ }
479
+
480
+ function validateRawCapture(fixtureRoot, raw, experiments, file) {
481
+ assertOnlyKeys(raw, RAW_TOP_LEVEL_FIELDS, file);
482
+ assertMajorOne(raw.schema_version, RAW_SCHEMA, `${file}.schema_version`);
483
+ assertPayloadMajorOne(raw.payload_version, RAW_PAYLOAD_PREFIX, `${file}.payload_version`);
484
+ const experimentId = assertExperimentId(raw.experiment_id, `${file}.experiment_id`);
485
+ const experiment = experiments.get(experimentId);
486
+ if (!experiment) {
487
+ fail(`${file} references unknown experiment ${experimentId}`);
488
+ }
489
+ assertOnlyKeys(raw.host_identity, new Set(["host", "surface", "version", "adapter_version"]), `${file}.host_identity`);
490
+ for (const field of ["host", "surface", "version", "adapter_version"]) {
491
+ assertNonEmptyString(raw.host_identity[field], `${file}.host_identity.${field}`);
492
+ }
493
+ if (raw.host_identity.host !== experiment.host) {
494
+ fail(`${file}.host_identity.host does not match experiment ${experimentId}`);
495
+ }
496
+ if (raw.host_identity.surface !== experiment.surface || raw.surface !== experiment.surface) {
497
+ fail(`${file}.host_identity.surface does not match experiment ${experimentId}`);
498
+ }
499
+ if (raw.tool_name !== experiment.expected_tool_name) {
500
+ fail(`${file}.tool_name is forged or drifted for ${experimentId}`);
501
+ }
502
+ if (raw.event_source !== experiment.expected_event_source) {
503
+ fail(`${file}.event_source is forged or drifted for ${experimentId}`);
504
+ }
505
+ assertOnlyKeys(raw.input, RAW_INPUT_FIELDS, `${file}.input`);
506
+ if (raw.input.input_kind !== experiment.input_kind) {
507
+ fail(`${file}.input.input_kind does not match experiment ${experimentId}`);
508
+ }
509
+ assertOnlyKeys(raw.result, RAW_RESULT_FIELDS, `${file}.result`);
510
+ assertTimeRange(raw.started_at, raw.ended_at, file);
511
+ validateRawEvents(raw, experiment, file);
512
+ validateRawResult(fixtureRoot, raw, experiment, file);
513
+ assertNoUnboundDigests(raw, file, (segments) => {
514
+ return (
515
+ isPath(segments, ["result", "artifact_refs", "*", "digest"]) ||
516
+ isPath(segments, ["provenance_ref", "digest"]) ||
517
+ (segments.length === 3 && segments[0] === "result" && segments[1] === "artifact_digests")
518
+ );
519
+ });
520
+ }
521
+
522
+ function validateRawEvents(raw, experiment, file) {
523
+ const events = assertArray(raw.events, `${file}.events`);
524
+ const finalEvents = events.filter((event) => event && event.final === true);
525
+ if (finalEvents.length !== 1) {
526
+ fail(`${file} must contain exactly one final event`);
527
+ }
528
+ const seenSequences = new Set();
529
+ for (const [index, event] of events.entries()) {
530
+ assertObject(event, `${file}.events[${index}]`);
531
+ assertOnlyKeys(
532
+ event,
533
+ new Set(["sequence", "event_name", "final", "payload_version", "tool_name", "event_source", "payload"]),
534
+ `${file}.events[${index}]`,
535
+ );
536
+ if (!Number.isInteger(event.sequence) || event.sequence < 1) {
537
+ fail(`${file}.events[${index}].sequence must be a positive integer`);
538
+ }
539
+ if (seenSequences.has(event.sequence)) {
540
+ fail(`${file}.events[${index}].sequence is duplicated`);
541
+ }
542
+ seenSequences.add(event.sequence);
543
+ assertNonEmptyString(event.event_name, `${file}.events[${index}].event_name`);
544
+ assertPayloadMajorOne(event.payload_version, "host-event/", `${file}.events[${index}].payload_version`);
545
+ assertOnlyKeys(
546
+ event.payload,
547
+ new Set([
548
+ "input_kind",
549
+ "final_status",
550
+ "exit_code",
551
+ "title",
552
+ "url",
553
+ "bodyVisible",
554
+ "heading_count",
555
+ "isError",
556
+ "redacted_message",
557
+ "call_tool_keys",
558
+ "status",
559
+ ]),
560
+ `${file}.events[${index}].payload`,
561
+ );
562
+ if (event.payload.input_kind && event.payload.input_kind !== experiment.input_kind) {
563
+ fail(`${file}.events[${index}].payload.input_kind does not match experiment ${raw.experiment_id}`);
564
+ }
565
+ if (event.tool_name && event.tool_name !== experiment.expected_tool_name) {
566
+ fail(`${file}.events[${index}].tool_name is forged or drifted`);
567
+ }
568
+ if (event.event_source && event.event_source !== experiment.expected_event_source) {
569
+ fail(`${file}.events[${index}].event_source is forged or drifted`);
570
+ }
571
+ }
572
+ const finalEvent = finalEvents[0];
573
+ if (events[events.length - 1] !== finalEvent) {
574
+ fail(`${file} final event must be the last event`);
575
+ }
576
+ if (finalEvent.sequence !== Math.max(...events.map((event) => event.sequence))) {
577
+ fail(`${file} final event must have the last sequence`);
578
+ }
579
+ if (finalEvent.event_name !== "final") {
580
+ fail(`${file} final event must be named final`);
581
+ }
582
+ if (finalEvent.payload.final_status !== raw.result.final_status) {
583
+ fail(`${file} final event status must match result.final_status`);
584
+ }
585
+ }
586
+
587
+ function validateRawResult(fixtureRoot, raw, experiment, file) {
588
+ const finalStatus = assertNonEmptyString(raw.result.final_status, `${file}.result.final_status`);
589
+ if (!EVIDENCE_AVAILABILITY_STATUSES.has(finalStatus)) {
590
+ fail(`${file}.result.final_status must reuse Evidence availability status vocabulary`);
591
+ }
592
+ if (finalStatus !== experiment.expected_result_status) {
593
+ fail(`${file}.result.final_status does not match the experiment contract`);
594
+ }
595
+ validatePermissionState(raw.result.permissions, `${file}.result.permissions`);
596
+ assertOnlyKeys(raw.result.sandbox, new Set(["mode", "writable_roots"]), `${file}.result.sandbox`);
597
+ assertNonEmptyString(raw.result.sandbox.mode, `${file}.result.sandbox.mode`);
598
+ validateOptionalStringList(assertArray(raw.result.sandbox.writable_roots, `${file}.result.sandbox.writable_roots`), `${file}.result.sandbox.writable_roots`);
599
+ validateOptionalStringList(assertArray(raw.result.missing_fields, `${file}.result.missing_fields`), `${file}.result.missing_fields`);
600
+ validateOptionalStringList(assertArray(raw.result.blind_spots, `${file}.result.blind_spots`), `${file}.result.blind_spots`);
601
+ if (raw.result.experiment_plan !== undefined) {
602
+ validateOptionalStringList(assertArray(raw.result.experiment_plan, `${file}.result.experiment_plan`), `${file}.result.experiment_plan`);
603
+ }
604
+ if (raw.result.notes !== undefined) {
605
+ validateOptionalStringList(assertArray(raw.result.notes, `${file}.result.notes`), `${file}.result.notes`);
606
+ }
607
+ const artifactRefs = assertArray(raw.result.artifact_refs, `${file}.result.artifact_refs`);
608
+ assertObject(raw.result.artifact_digests, `${file}.result.artifact_digests`);
609
+ const artifactIds = new Set(artifactRefs.map((artifactRef) => artifactRef.id));
610
+ for (const artifactId of Object.keys(raw.result.artifact_digests)) {
611
+ if (!artifactIds.has(artifactId)) {
612
+ fail(`${file}.result.artifact_digests.${artifactId} has no matching artifact_ref`);
613
+ }
614
+ }
615
+ for (const artifactRef of artifactRefs) {
616
+ validateArtifactRef(fixtureRoot, artifactRef, raw.result.artifact_digests, file);
617
+ }
618
+ }
619
+
620
+ function validateArtifactRef(fixtureRoot, artifactRef, artifactDigests, file) {
621
+ assertOnlyKeys(artifactRef, new Set(["id", "kind", "root_kind", "path", "digest"]), `${file}.result.artifact_refs[]`);
622
+ const id = assertNonEmptyString(artifactRef.id, `${file}.artifact.id`);
623
+ assertNonEmptyString(artifactRef.kind, `${file}.artifact.kind`);
624
+ if (artifactRef.root_kind !== "fixture_root") {
625
+ fail(`${file}.artifact ${id} root_kind must be fixture_root`);
626
+ }
627
+ const artifactPath = safeFixturePath(fixtureRoot, artifactRef.path, `${file}.artifact.path`);
628
+ if (!existsSync(artifactPath)) {
629
+ fail(`${file}.artifact ${id} points at missing file ${artifactRef.path}`);
630
+ }
631
+ const digest = sha256File(artifactPath);
632
+ assertDigest(artifactRef.digest, `${file}.artifact.digest`);
633
+ if (digest !== artifactRef.digest) {
634
+ fail(`${file}.artifact ${id} digest mismatch`);
635
+ }
636
+ if (artifactDigests[id] !== digest) {
637
+ fail(`${file}.result.artifact_digests.${id} must match artifact file digest`);
638
+ }
639
+ }
640
+
641
+ function validateArtifactGraph(fixtureRoot, rawById) {
642
+ const referencedPaths = new Map();
643
+ const artifactIds = new Set();
644
+ for (const raw of rawById.values()) {
645
+ for (const artifactRef of raw.result.artifact_refs) {
646
+ if (artifactIds.has(artifactRef.id)) {
647
+ fail(`duplicate artifact id ${artifactRef.id}`);
648
+ }
649
+ artifactIds.add(artifactRef.id);
650
+ const current = referencedPaths.get(artifactRef.path);
651
+ if (current) {
652
+ fail(`artifact path ${artifactRef.path} is referenced by both ${current} and ${raw.experiment_id}`);
653
+ }
654
+ referencedPaths.set(artifactRef.path, raw.experiment_id);
655
+ }
656
+ }
657
+ const artifactsDir = path.join(fixtureRoot, "artifacts");
658
+ if (!existsSync(artifactsDir)) {
659
+ return;
660
+ }
661
+ for (const file of collectFiles(artifactsDir)) {
662
+ const relative = path.relative(fixtureRoot, file).split(path.sep).join("/");
663
+ if (!referencedPaths.has(relative)) {
664
+ fail(`orphan artifact file ${relative}`);
665
+ }
666
+ assertNoUnboundDigestsInArtifact(file, relative);
667
+ }
668
+ }
669
+
670
+ function assertNoUnboundDigestsInArtifact(file, relative) {
671
+ const text = readFileSync(file, "utf8");
672
+ if (!DIGEST_SUBSTRING_PATTERN.test(text)) {
673
+ return;
674
+ }
675
+ fail(`artifact ${relative} contains a nested digest claim without an artifact_ref`);
676
+ }
677
+
678
+ function collectFiles(dir) {
679
+ const files = [];
680
+ for (const name of readdirSyncSorted(dir)) {
681
+ const file = path.join(dir, name);
682
+ if (statSync(file).isDirectory()) {
683
+ files.push(...collectFiles(file));
684
+ } else {
685
+ files.push(file);
686
+ }
687
+ }
688
+ return files;
689
+ }
690
+
691
+ async function validateExpectedManifest(fixtureRoot, suite, rawById) {
692
+ const expectedPath = path.join(fixtureRoot, "expected", "normalized-manifest.json");
693
+ const expected = assertObject(readJson(expectedPath), expectedPath);
694
+ assertOnlyKeys(
695
+ expected,
696
+ new Set(["schema_version", "payload_version", "suite_id", "schema_refs", "provenance_ref", "capability_instances"]),
697
+ "expected",
698
+ );
699
+ assertMajorOne(expected.schema_version, EXPECTED_SCHEMA, "expected.schema_version");
700
+ assertPayloadMajorOne(expected.payload_version, EXPECTED_PAYLOAD_PREFIX, "expected.payload_version");
701
+ if (expected.suite_id !== suite.suite_id) {
702
+ fail("expected.suite_id must match experiment suite");
703
+ }
704
+ const schemaRefs = loadSchemaRefs(fixtureRoot, expected);
705
+ if (schemaRefs.raw.path !== RAW_SCHEMA_REF_PATH || schemaRefs.expected.path !== EXPECTED_SCHEMA_REF_PATH || schemaRefs.provenance.path !== PROVENANCE_SCHEMA_REF_PATH) {
706
+ fail("expected.schema_refs must point at the versioned host-capability schemas");
707
+ }
708
+ const provenance = loadProvenance(fixtureRoot, expected.provenance_ref, schemaRefs);
709
+ if (provenance.record.suite_id !== suite.suite_id) {
710
+ fail("provenance.suite_id must match experiment suite");
711
+ }
712
+ const entries = assertArray(expected.capability_instances, "expected.capability_instances");
713
+ if (entries.length !== rawById.size) {
714
+ fail("expected.capability_instances must cover every raw capture exactly once");
715
+ }
716
+ const seen = new Set();
717
+ for (const entry of entries) {
718
+ await validateExpectedEntry(fixtureRoot, entry, rawById, provenance, schemaRefs);
719
+ if (seen.has(entry.raw_capture_id)) {
720
+ fail(`duplicate expected entry for ${entry.raw_capture_id}`);
721
+ }
722
+ seen.add(entry.raw_capture_id);
723
+ }
724
+ await runCanonicalValidator({
725
+ raw_documents: [...rawById.values()],
726
+ expected_document: expected,
727
+ provenance_document: provenance.record,
728
+ schemas: {
729
+ raw: schemaRefs.raw.document,
730
+ expected: schemaRefs.expected.document,
731
+ provenance: schemaRefs.provenance.document,
732
+ },
733
+ capability_instances: entries.map((entry) => entry.capability_instance),
734
+ });
735
+ validateHostSurfaceMatrix(fixtureRoot, expected, rawById, provenance.record);
736
+ for (const id of rawById.keys()) {
737
+ if (!seen.has(id)) {
738
+ fail(`missing expected entry for ${id}`);
739
+ }
740
+ }
741
+ assertNoUnboundDigests(expected, "expected", (segments) => {
742
+ return (
743
+ isPath(segments, ["capability_instances", "*", "manifest_ref", "digest"]) ||
744
+ isPath(segments, ["capability_instances", "*", "provenance_ref", "digest"]) ||
745
+ isPath(segments, ["capability_instances", "*", "capability_instance", "manifest_digest"]) ||
746
+ isPath(segments, ["capability_instances", "*", "capability_instance", "environment", "digest"]) ||
747
+ isPath(segments, ["schema_refs", "raw", "digest"]) ||
748
+ isPath(segments, ["schema_refs", "expected", "digest"]) ||
749
+ isPath(segments, ["schema_refs", "provenance", "digest"]) ||
750
+ isPath(segments, ["provenance_ref", "digest"])
751
+ );
752
+ });
753
+ return expected;
754
+ }
755
+
756
+ function validateHostSurfaceMatrix(fixtureRoot, expected, rawById, provenanceRecord) {
757
+ const matrixPath = path.join(fixtureRoot, "expected", "host-surface-matrix.json");
758
+ if (!existsSync(matrixPath)) {
759
+ fail("expected/host-surface-matrix.json is required");
760
+ }
761
+ const actual = readJson(matrixPath);
762
+ const generated = hostSurfaceMatrixFromExpected(expected, rawById, provenanceRecord);
763
+ if (stableJson(actual) !== stableJson(generated)) {
764
+ fail("expected host-surface matrix drifted from verified manifests and fixtures");
765
+ }
766
+ }
767
+
768
+ function hostSurfaceMatrixFromExpected(expected, rawById, provenanceRecord) {
769
+ const provenanceById = new Map(provenanceRecord.captures.map((capture) => [capture.experiment_id, capture]));
770
+ return {
771
+ schema_version: "planr.host_surface_capability_matrix.v1",
772
+ fixture_contract: "host-capability-raw/1.0.0",
773
+ suite_id: expected.suite_id,
774
+ surfaces: expected.capability_instances.map((entry) => {
775
+ const instance = entry.capability_instance;
776
+ const raw = rawById.get(entry.raw_capture_id);
777
+ const provenance = provenanceById.get(entry.raw_capture_id);
778
+ return {
779
+ host: instance.host,
780
+ surface: instance.surface,
781
+ host_version: instance.host_version,
782
+ trusted_adapter_enabled: entry.trusted_adapter_enabled,
783
+ availability_status: instance.availability.status,
784
+ reason: instance.availability.reason,
785
+ observation_types: instance.observed_payload_contract.observation_types,
786
+ provenance: {
787
+ claim_source: entry.claim_source,
788
+ source_kind: provenance.source_kind,
789
+ observation_mode: provenance.observation_mode,
790
+ },
791
+ permissions: instance.permissions,
792
+ artifact_kinds: raw.result.artifact_refs.map((artifact) => artifact.kind).sort(),
793
+ blind_spots: instance.limitations,
794
+ };
795
+ }),
796
+ };
797
+ }
798
+
799
+ async function validateExpectedEntry(fixtureRoot, entry, rawById, provenance, schemaRefs) {
800
+ assertOnlyKeys(
801
+ entry,
802
+ new Set([
803
+ "raw_capture_id",
804
+ "claim_source",
805
+ "trusted_adapter_enabled",
806
+ "manifest_ref",
807
+ "provenance_ref",
808
+ "capability_instance",
809
+ ]),
810
+ "expected.capability_instances[]",
811
+ );
812
+ const rawCaptureId = assertNonEmptyString(entry.raw_capture_id, "entry.raw_capture_id");
813
+ const raw = rawById.get(rawCaptureId);
814
+ if (!raw) {
815
+ fail(`expected entry references unknown raw capture ${rawCaptureId}`);
816
+ }
817
+ const claimSource = assertNonEmptyString(entry.claim_source, "entry.claim_source");
818
+ if (raw.result.final_status === "available" && !OBSERVED_CLAIM_SOURCES.has(claimSource)) {
819
+ fail(`available entry ${rawCaptureId} cannot be supported by docs-only claims`);
820
+ }
821
+ if (entry.trusted_adapter_enabled !== false) {
822
+ fail(`entry ${rawCaptureId} must not enable trusted adapters in phase 1`);
823
+ }
824
+ if (!sameFileRef(entry.provenance_ref, provenance.ref)) {
825
+ fail(`entry ${rawCaptureId} provenance_ref must match expected provenance_ref`);
826
+ }
827
+ const provenanceCapture = provenance.byId.get(rawCaptureId);
828
+ if (!provenanceCapture) {
829
+ fail(`missing provenance capture for ${rawCaptureId}`);
830
+ }
831
+ await validateProvenanceBinding(fixtureRoot, entry, raw, provenanceCapture);
832
+ const manifestRef = validateManifestRef(fixtureRoot, entry.manifest_ref, rawCaptureId);
833
+ const instance = assertObject(entry.capability_instance, "entry.capability_instance");
834
+ validateEvidenceCapabilityInstanceShape(instance, raw, manifestRef, provenanceCapture, schemaRefs);
835
+ }
836
+
837
+ function validateManifestRef(fixtureRoot, manifestRef, rawCaptureId) {
838
+ assertOnlyKeys(manifestRef, new Set(["path", "digest"]), "entry.manifest_ref");
839
+ const file = safeFixturePath(fixtureRoot, manifestRef.path, "entry.manifest_ref.path");
840
+ if (!existsSync(file)) {
841
+ fail(`entry ${rawCaptureId} manifest_ref points at missing file`);
842
+ }
843
+ assertDigest(manifestRef.digest, "entry.manifest_ref.digest");
844
+ const digest = sha256File(file);
845
+ if (manifestRef.digest !== digest) {
846
+ fail(`entry ${rawCaptureId} manifest_ref digest mismatch`);
847
+ }
848
+ const manifest = assertObject(readJson(file), manifestRef.path);
849
+ assertOnlyKeys(
850
+ manifest,
851
+ new Set(["schema_version", "manifest_ids", "trusted_adapter_enabled", "source"]),
852
+ "manifest_ref",
853
+ );
854
+ if (manifest.schema_version !== "planr.host_capability_manifest_reference.v1") {
855
+ fail("manifest_ref.schema_version must be planr.host_capability_manifest_reference.v1");
856
+ }
857
+ const expectedManifestId = `host-${rawCaptureId.replace(/^exp-/, "")}-manifest`;
858
+ if (!assertArray(manifest.manifest_ids, "manifest_ref.manifest_ids").includes(expectedManifestId)) {
859
+ fail(`entry ${rawCaptureId} manifest_ref does not bind the manifest id`);
860
+ }
861
+ if (manifest.trusted_adapter_enabled !== false) {
862
+ fail("manifest_ref must not enable trusted adapters in phase 1");
863
+ }
864
+ return manifestRef;
865
+ }
866
+
867
+ function validateEvidenceCapabilityInstanceShape(instance, raw, manifestRef, provenance, schemaRefs) {
868
+ const required = new Set([
869
+ "id",
870
+ "schema_version",
871
+ "manifest_id",
872
+ "manifest_digest",
873
+ "host",
874
+ "surface",
875
+ "host_version",
876
+ "adapter_version",
877
+ "environment",
878
+ "permissions",
879
+ "availability",
880
+ "probe_result",
881
+ "observed_payload_contract",
882
+ "limitations",
883
+ "captured_at",
884
+ ]);
885
+ assertOnlyKeys(instance, required, "capability_instance");
886
+ for (const field of required) {
887
+ if (!(field in instance)) {
888
+ fail(`capability_instance missing ${field}`);
889
+ }
890
+ }
891
+ if (instance.schema_version !== "evidence.contract.v1") {
892
+ fail("capability_instance.schema_version must reuse Evidence contract v1");
893
+ }
894
+ assertNonEmptyString(instance.id, "capability_instance.id");
895
+ assertNonEmptyString(instance.manifest_id, "capability_instance.manifest_id");
896
+ assertDigest(instance.manifest_digest, "capability_instance.manifest_digest");
897
+ if (instance.manifest_digest !== manifestRef.digest) {
898
+ fail("capability_instance.manifest_digest must be content-bound by manifest_ref");
899
+ }
900
+ if (instance.host !== raw.host_identity.host) {
901
+ fail("capability_instance.host must come from raw host identity");
902
+ }
903
+ if (instance.surface !== raw.surface) {
904
+ fail("capability_instance.surface must come from raw capture");
905
+ }
906
+ if (instance.host_version !== raw.host_identity.version) {
907
+ fail("capability_instance.host_version must come from raw host identity");
908
+ }
909
+ if (instance.adapter_version !== raw.host_identity.adapter_version) {
910
+ fail("capability_instance.adapter_version must come from raw host identity");
911
+ }
912
+ validateEnvironmentBinding(instance.environment, "capability_instance.environment");
913
+ validatePermissionState(instance.permissions, "capability_instance.permissions");
914
+ if (stableJson(instance.permissions) !== stableJson(raw.result.permissions)) {
915
+ fail("capability_instance.permissions must be projected from raw result permissions");
916
+ }
917
+ validateAvailability(instance.availability, raw, provenance);
918
+ validateProbeResult(instance.probe_result, raw, instance.availability.status, provenance);
919
+ validateObservedPayloadContract(instance.observed_payload_contract, schemaRefs);
920
+ validateOptionalStringList(assertArray(instance.limitations, "capability_instance.limitations"), "capability_instance.limitations");
921
+ if (stableJson(instance.limitations) !== stableJson(raw.result.blind_spots)) {
922
+ fail("capability_instance.limitations must be projected from raw result blind_spots");
923
+ }
924
+ assertTimestamp(instance.captured_at, "capability_instance.captured_at");
925
+ if (instance.captured_at !== raw.ended_at) {
926
+ fail("capability_instance.captured_at must come from raw ended_at");
927
+ }
928
+ }
929
+
930
+ function validateEnvironmentBinding(environment, label) {
931
+ assertOnlyKeys(environment, new Set(["kind", "id", "digest"]), label);
932
+ assertNonEmptyString(environment.kind, `${label}.kind`);
933
+ assertNonEmptyString(environment.id, `${label}.id`);
934
+ assertDigest(environment.digest, `${label}.digest`);
935
+ }
936
+
937
+ function validatePermissionState(permissions, label) {
938
+ assertOnlyKeys(permissions, new Set(["network", "filesystem", "environment", "secrets"]), label);
939
+ assertNonEmptyString(permissions.network, `${label}.network`);
940
+ assertNonEmptyString(permissions.filesystem, `${label}.filesystem`);
941
+ if (permissions.environment !== undefined) {
942
+ assertNonEmptyString(permissions.environment, `${label}.environment`);
943
+ }
944
+ if (permissions.secrets !== undefined) {
945
+ assertNonEmptyString(permissions.secrets, `${label}.secrets`);
946
+ }
947
+ }
948
+
949
+ function validateAvailability(availability, raw, provenance) {
950
+ assertOnlyKeys(availability, new Set(["status", "reason"]), "capability_instance.availability");
951
+ const status = assertNonEmptyString(availability.status, "capability_instance.availability.status");
952
+ if (!EVIDENCE_AVAILABILITY_STATUSES.has(status)) {
953
+ fail("capability_instance.availability.status must reuse Evidence status vocabulary");
954
+ }
955
+ if (status !== raw.result.final_status) {
956
+ fail("capability_instance.availability.status must be projected from raw result.final_status");
957
+ }
958
+ if (status !== "available" && availability.reason === undefined) {
959
+ fail("capability_instance.availability.reason is required for non-available statuses");
960
+ }
961
+ if (provenance.availability_reason !== raw.result.availability_reason) {
962
+ fail("provenance availability_reason must match raw result.availability_reason");
963
+ }
964
+ if (availability.reason !== undefined) {
965
+ assertNonEmptyString(availability.reason, "capability_instance.availability.reason");
966
+ }
967
+ if (availability.reason !== provenance.availability_reason) {
968
+ fail("capability_instance.availability.reason must be bound to raw and provenance");
969
+ }
970
+ }
971
+
972
+ function validateProbeResult(probe, raw, availabilityStatus, provenance) {
973
+ assertOnlyKeys(
974
+ probe,
975
+ new Set(["probe_execution_id", "outcome", "observed_at", "checks"]),
976
+ "capability_instance.probe_result",
977
+ );
978
+ assertNonEmptyString(probe.probe_execution_id, "capability_instance.probe_result.probe_execution_id");
979
+ const probeOutcome = assertNonEmptyString(probe.outcome, "capability_instance.probe_result.outcome");
980
+ if (!EVIDENCE_ATTEMPT_STATUSES.has(probeOutcome)) {
981
+ fail("capability_instance.probe_result.outcome must reuse Evidence attempt status vocabulary");
982
+ }
983
+ if (availabilityStatus === "available" && probeOutcome !== "passed") {
984
+ fail("capability_instance.probe_result.outcome must pass when availability is available");
985
+ }
986
+ assertTimestamp(probe.observed_at, "capability_instance.probe_result.observed_at");
987
+ if (probe.observed_at !== raw.ended_at) {
988
+ fail("capability_instance.probe_result.observed_at must come from raw ended_at");
989
+ }
990
+ const checks = assertArray(probe.checks, "capability_instance.probe_result.checks");
991
+ if (checks.length === 0) {
992
+ fail("capability_instance.probe_result.checks must not be empty");
993
+ }
994
+ validateProbeChecks(checks, "capability_instance.probe_result.checks");
995
+ if (stableJson(checks) !== stableJson(provenance.probe_checks)) {
996
+ fail("capability_instance.probe_result.checks must be bound to provenance");
997
+ }
998
+ }
999
+
1000
+ function stableJson(value) {
1001
+ if (!value || typeof value !== "object") {
1002
+ return JSON.stringify(value);
1003
+ }
1004
+ if (Array.isArray(value)) {
1005
+ return `[${value.map(stableJson).join(",")}]`;
1006
+ }
1007
+ return `{${Object.keys(value)
1008
+ .sort()
1009
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
1010
+ .join(",")}}`;
1011
+ }
1012
+
1013
+ function validateProbeChecks(checks, label) {
1014
+ const entries = assertArray(checks, label);
1015
+ if (entries.length === 0) {
1016
+ fail(`${label} must not be empty`);
1017
+ }
1018
+ for (const [index, check] of entries.entries()) {
1019
+ assertOnlyKeys(
1020
+ check,
1021
+ new Set(["name", "outcome", "detail"]),
1022
+ `${label}[${index}]`,
1023
+ );
1024
+ assertNonEmptyString(check.name, `${label}[${index}].name`);
1025
+ const outcome = assertNonEmptyString(check.outcome, `${label}[${index}].outcome`);
1026
+ if (!EVIDENCE_ATTEMPT_STATUSES.has(outcome)) {
1027
+ fail(`${label}[].outcome must reuse Evidence attempt status vocabulary`);
1028
+ }
1029
+ if (check.detail !== undefined) {
1030
+ assertNonEmptyString(check.detail, `${label}[${index}].detail`);
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ function validateObservedPayloadContract(payloadContract, schemaRefs) {
1036
+ assertOnlyKeys(
1037
+ payloadContract,
1038
+ new Set(["schema_ref", "observation_types"]),
1039
+ "capability_instance.observed_payload_contract",
1040
+ );
1041
+ assertNonEmptyString(payloadContract.schema_ref, "observed_payload_contract.schema_ref");
1042
+ if (payloadContract.schema_ref !== schemaRefs.raw.path) {
1043
+ fail("observed_payload_contract.schema_ref must point at the versioned raw schema");
1044
+ }
1045
+ const observationTypes = assertArray(payloadContract.observation_types, "observed_payload_contract.observation_types");
1046
+ if (observationTypes.length === 0) {
1047
+ fail("observed_payload_contract.observation_types must not be empty");
1048
+ }
1049
+ validateOptionalStringList(observationTypes, "observed_payload_contract.observation_types");
1050
+ }
1051
+
1052
+ async function validateProvenanceBinding(fixtureRoot, entry, raw, provenance) {
1053
+ const rawCaptureId = entry.raw_capture_id;
1054
+ const rule = CLAIM_SOURCE_RULES[entry.claim_source];
1055
+ if (provenance.claim_source !== entry.claim_source) {
1056
+ fail(`entry ${rawCaptureId} claim_source must match provenance`);
1057
+ }
1058
+ if (provenance.source_kind !== rule.sourceKind) {
1059
+ fail(`entry ${rawCaptureId} claim_source is not valid for provenance source_kind`);
1060
+ }
1061
+ if (provenance.observation_mode !== rule.observationMode) {
1062
+ fail(`entry ${rawCaptureId} observation_mode is not valid for claim_source`);
1063
+ }
1064
+ if (!rule.inputKinds.has(raw.input.input_kind)) {
1065
+ fail(`entry ${rawCaptureId} claim_source is not valid for raw input_kind`);
1066
+ }
1067
+ if (entry.claim_source === "capture_mode_placeholder" && raw.input.replay_mode !== "unprobed-placeholder") {
1068
+ fail(`entry ${rawCaptureId} capture_mode_placeholder requires unprobed placeholder replay_mode`);
1069
+ }
1070
+ if (entry.claim_source === "mechanical_unavailable_probe") {
1071
+ validateMechanicalUnavailableProbe(fixtureRoot, rawCaptureId, raw, provenance);
1072
+ }
1073
+ if (!sameFileRef(raw.provenance_ref, entry.provenance_ref)) {
1074
+ fail(`entry ${rawCaptureId} raw provenance_ref must match expected entry`);
1075
+ }
1076
+ for (const [field, actual] of [
1077
+ ["host", raw.host_identity.host],
1078
+ ["surface", raw.surface],
1079
+ ["input_kind", raw.input.input_kind],
1080
+ ["tool_name", raw.tool_name],
1081
+ ["event_source", raw.event_source],
1082
+ ["host_version", raw.host_identity.version],
1083
+ ["adapter_version", raw.host_identity.adapter_version],
1084
+ ]) {
1085
+ if (provenance[field] !== actual) {
1086
+ fail(`entry ${rawCaptureId} provenance ${field} must match raw capture`);
1087
+ }
1088
+ }
1089
+ if (
1090
+ raw.host_identity.version === "current-session" ||
1091
+ raw.host_identity.version.endsWith("-current-session") ||
1092
+ raw.host_identity.version === "unavailable-in-fixture-replay"
1093
+ ) {
1094
+ fail(`entry ${rawCaptureId} host_version must be actual or explicitly missing`);
1095
+ }
1096
+ if (raw.host_identity.version === "missing" && !raw.result.missing_fields.includes("host_version")) {
1097
+ fail(`entry ${rawCaptureId} missing host_version must be declared in raw missing_fields`);
1098
+ }
1099
+ if (provenance.host_version === "missing" && !provenance.missing_fields.includes("host_version")) {
1100
+ fail(`entry ${rawCaptureId} missing host_version must be declared in provenance missing_fields`);
1101
+ }
1102
+ if (provenance.captured_at !== raw.ended_at) {
1103
+ fail(`entry ${rawCaptureId} provenance captured_at must match raw ended_at`);
1104
+ }
1105
+ const artifactIds = raw.result.artifact_refs.map((artifactRef) => artifactRef.id).sort();
1106
+ if (JSON.stringify([...provenance.artifact_ids].sort()) !== JSON.stringify(artifactIds)) {
1107
+ fail(`entry ${rawCaptureId} provenance artifact_ids must match raw artifact_refs`);
1108
+ }
1109
+ await validateClaimedObservationSupport(fixtureRoot, rawCaptureId, raw, provenance);
1110
+ }
1111
+
1112
+ function validateMechanicalUnavailableProbe(fixtureRoot, rawCaptureId, raw, provenance) {
1113
+ if (raw.result.artifact_refs.length === 0 || provenance.artifact_ids.length === 0) {
1114
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe requires invocation artifacts`);
1115
+ }
1116
+ if (raw.result.final_status === "available") {
1117
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe must not claim available`);
1118
+ }
1119
+ if (provenance.external_producer !== undefined) {
1120
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe must not use external observed producer provenance`);
1121
+ }
1122
+ if (raw.host_identity.host !== "codex") {
1123
+ validatePeerVersionProbeArtifacts(fixtureRoot, rawCaptureId, raw, provenance);
1124
+ if (!raw.result.missing_fields.includes("final_result_payload")) {
1125
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe must declare missing final_result_payload`);
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ function validatePeerVersionProbeArtifacts(fixtureRoot, rawCaptureId, raw, provenance) {
1131
+ if (!Array.isArray(raw.input.command) || raw.input.command.length === 0) {
1132
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe requires exact argv command`);
1133
+ }
1134
+ const artifactsByKind = new Map(raw.result.artifact_refs.map((artifactRef) => [artifactRef.kind, artifactRef]));
1135
+ for (const kind of ["invocation-stdout", "invocation-stderr", "invocation-result"]) {
1136
+ if (!artifactsByKind.has(kind)) {
1137
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe missing ${kind} artifact`);
1138
+ }
1139
+ }
1140
+ const resultArtifact = readJson(safeFixturePath(fixtureRoot, artifactsByKind.get("invocation-result").path, "mechanical invocation result"));
1141
+ assertOnlyKeys(
1142
+ resultArtifact,
1143
+ new Set(["schema_version", "argv", "cwd", "exit_status", "signal", "stdout_bytes", "stderr_bytes"]),
1144
+ `entry ${rawCaptureId} invocation-result`,
1145
+ );
1146
+ if (resultArtifact.schema_version !== "planr.host_capability_mechanical_invocation.v1") {
1147
+ fail(`entry ${rawCaptureId} invocation-result schema_version is unsupported`);
1148
+ }
1149
+ if (stableJson(resultArtifact.argv) !== stableJson(raw.input.command)) {
1150
+ fail(`entry ${rawCaptureId} invocation-result argv must match raw input.command`);
1151
+ }
1152
+ if (!Number.isInteger(resultArtifact.exit_status)) {
1153
+ fail(`entry ${rawCaptureId} invocation-result exit_status is required`);
1154
+ }
1155
+ const stdoutText = readFileSync(safeFixturePath(fixtureRoot, artifactsByKind.get("invocation-stdout").path, "mechanical stdout"), "utf8");
1156
+ const stderrText = readFileSync(safeFixturePath(fixtureRoot, artifactsByKind.get("invocation-stderr").path, "mechanical stderr"), "utf8");
1157
+ if (Buffer.byteLength(stdoutText) !== resultArtifact.stdout_bytes) {
1158
+ fail(`entry ${rawCaptureId} invocation-result stdout_bytes must match stdout artifact`);
1159
+ }
1160
+ if (Buffer.byteLength(stderrText) !== resultArtifact.stderr_bytes) {
1161
+ fail(`entry ${rawCaptureId} invocation-result stderr_bytes must match stderr artifact`);
1162
+ }
1163
+ const version = parseVersionProbeStdout(raw.host_identity.host, stdoutText);
1164
+ if (!version) {
1165
+ fail(`entry ${rawCaptureId} mechanical_unavailable_probe could not parse host version from stdout artifact`);
1166
+ }
1167
+ if (raw.host_identity.version !== version || provenance.host_version !== version) {
1168
+ fail(`entry ${rawCaptureId} host_version must be derived from invocation stdout artifact`);
1169
+ }
1170
+ }
1171
+
1172
+ async function validateClaimedObservationSupport(fixtureRoot, rawCaptureId, raw, provenance) {
1173
+ const reasons = [
1174
+ ["raw result.availability_reason", raw.result.availability_reason],
1175
+ ["provenance availability_reason", provenance.availability_reason],
1176
+ ];
1177
+ for (const [label, reason] of reasons) {
1178
+ const text = assertNonEmptyString(reason, `entry ${rawCaptureId} ${label}`);
1179
+ if (claimsScreenshotDigestObservation(text) && !(await hasContentBoundScreenshotArtifact(fixtureRoot, rawCaptureId, raw))) {
1180
+ fail(`entry ${rawCaptureId} ${label} claims screenshot digest observations without a content-bound screenshot artifact`);
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ function claimsScreenshotDigestObservation(text) {
1186
+ return /\bscreenshot\b/i.test(text) && /\b(?:digest|hash|checksum|sha[- ]?256)\b/i.test(text);
1187
+ }
1188
+
1189
+ async function hasContentBoundScreenshotArtifact(fixtureRoot, rawCaptureId, raw) {
1190
+ for (const artifactRef of raw.result.artifact_refs) {
1191
+ if (!/\bscreenshot\b/i.test(artifactRef.kind)) {
1192
+ continue;
1193
+ }
1194
+ await validateContentBoundScreenshotArtifact(fixtureRoot, rawCaptureId, artifactRef);
1195
+ return true;
1196
+ }
1197
+ return false;
1198
+ }
1199
+
1200
+ async function validateContentBoundScreenshotArtifact(fixtureRoot, rawCaptureId, artifactRef) {
1201
+ if (artifactRef.kind !== "screenshot") {
1202
+ fail(`entry ${rawCaptureId} screenshot artifact ${artifactRef.id} kind must be screenshot`);
1203
+ }
1204
+ const artifactPath = safeFixturePath(fixtureRoot, artifactRef.path, "screenshot artifact path");
1205
+ await validateScreenshotWithCanonicalValidator(
1206
+ artifactPath,
1207
+ artifactRef.digest,
1208
+ `entry ${rawCaptureId} screenshot artifact ${artifactRef.id}`,
1209
+ );
1210
+ }
1211
+
1212
+ function sameFileRef(left, right) {
1213
+ return left?.path === right?.path && left?.digest === right?.digest;
1214
+ }
1215
+
1216
+ function validateOptionalStringList(values, label) {
1217
+ for (const [index, value] of values.entries()) {
1218
+ assertNonEmptyString(value, `${label}[${index}]`);
1219
+ }
1220
+ }
1221
+
1222
+ function isPath(segments, pattern) {
1223
+ if (segments.length !== pattern.length) {
1224
+ return false;
1225
+ }
1226
+ return pattern.every((part, index) => part === "*" || part === segments[index]);
1227
+ }
1228
+
1229
+ function assertNoUnboundDigests(value, label, isAllowed, segments = []) {
1230
+ if (typeof value === "string") {
1231
+ if (DIGEST_SUBSTRING_PATTERN.test(value) && !isAllowed(segments)) {
1232
+ fail(`${label}.${segments.join(".")} contains a digest without an artifact or manifest ref`);
1233
+ }
1234
+ return;
1235
+ }
1236
+ if (!value || typeof value !== "object") {
1237
+ return;
1238
+ }
1239
+ if (Array.isArray(value)) {
1240
+ value.forEach((entry) => assertNoUnboundDigests(entry, label, isAllowed, [...segments, "*"]));
1241
+ return;
1242
+ }
1243
+ for (const [key, entry] of Object.entries(value)) {
1244
+ assertNoUnboundDigests(entry, label, isAllowed, [...segments, key]);
1245
+ }
1246
+ }
1247
+
1248
+ function validatorExecutableName() {
1249
+ return process.platform === "win32" ? `${VALIDATOR_NAME}.exe` : VALIDATOR_NAME;
1250
+ }
1251
+
1252
+ function packagedValidatorPath() {
1253
+ const harnessPath = process.argv[1];
1254
+ if (!harnessPath) {
1255
+ fail("canonical Rust capability validator requires a harness executable path");
1256
+ }
1257
+ return path.resolve(path.dirname(harnessPath), validatorExecutableName());
1258
+ }
1259
+
1260
+ function resolveCanonicalValidator() {
1261
+ const configured = process.env.PLANR_HOST_CAPABILITY_VALIDATOR;
1262
+ if (configured) {
1263
+ if (!path.isAbsolute(configured)) {
1264
+ fail(`canonical Rust capability validator path must be absolute: ${configured}`);
1265
+ }
1266
+ if (!existsSync(configured)) {
1267
+ fail(`canonical Rust capability validator path points at a missing file: ${configured}`);
1268
+ }
1269
+ return configured;
1270
+ }
1271
+ const packagedSibling = packagedValidatorPath();
1272
+ if (existsSync(packagedSibling)) {
1273
+ return packagedSibling;
1274
+ }
1275
+ fail(
1276
+ `canonical Rust capability validator not found at packaged sibling ${packagedSibling}; set PLANR_HOST_CAPABILITY_VALIDATOR to an absolute built ${VALIDATOR_NAME} binary or install it alongside the harness executable`,
1277
+ );
1278
+ }
1279
+
1280
+ async function validateScreenshotWithCanonicalValidator(artifactPath, expectedDigest, label) {
1281
+ assertDigest(expectedDigest, `${label}.digest`);
1282
+ const validatorPath = resolveCanonicalValidator();
1283
+ const timeout = Number(process.env.PLANR_HOST_CAPABILITY_VALIDATOR_TIMEOUT_MS ?? "120000");
1284
+ const stdout = await spawnValidator(
1285
+ validatorPath,
1286
+ ["--validate-screenshot", artifactPath],
1287
+ timeout,
1288
+ `${label} canonical Rust screenshot validation`,
1289
+ );
1290
+ const result = parseValidatorJson(stdout, `${label} screenshot validator result`);
1291
+ assertValidatorResult(result, { inputDigest: expectedDigest, rawDocuments: 0, instances: 0 });
1292
+ }
1293
+
1294
+ function sleep(ms) {
1295
+ return new Promise((resolve) => setTimeout(resolve, ms));
1296
+ }
1297
+
1298
+ function killValidatorProcessTree(child, signal) {
1299
+ if (!child.pid) {
1300
+ return;
1301
+ }
1302
+ try {
1303
+ if (process.platform === "win32") {
1304
+ const force = signal === "SIGKILL" ? ["/f"] : [];
1305
+ spawnSync("taskkill", ["/pid", String(child.pid), "/t", ...force], {
1306
+ encoding: "utf8",
1307
+ stdio: "ignore",
1308
+ });
1309
+ return;
1310
+ }
1311
+ process.kill(-child.pid, signal);
1312
+ } catch (error) {
1313
+ if (error?.code !== "ESRCH") {
1314
+ throw error;
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ async function terminateValidatorProcessTree(child) {
1320
+ killValidatorProcessTree(child, "SIGTERM");
1321
+ await sleep(100);
1322
+ killValidatorProcessTree(child, "SIGKILL");
1323
+ }
1324
+
1325
+ async function spawnValidator(validatorPath, args, timeout, label) {
1326
+ const run = await new Promise((resolve) => {
1327
+ const child = spawn(validatorPath, args, {
1328
+ cwd: process.cwd(),
1329
+ detached: true,
1330
+ stdio: ["ignore", "pipe", "pipe"],
1331
+ });
1332
+ let stdout = "";
1333
+ let stderr = "";
1334
+ let timedOut = false;
1335
+ let spawnError = null;
1336
+ let directStatus = null;
1337
+ let directSignal = null;
1338
+ let cleanup = null;
1339
+ let resolved = false;
1340
+ const cleanupOnce = () => {
1341
+ cleanup ??= terminateValidatorProcessTree(child).catch((error) => {
1342
+ spawnError ??= error;
1343
+ });
1344
+ return cleanup;
1345
+ };
1346
+ const finish = async (status, signal) => {
1347
+ if (resolved) {
1348
+ return;
1349
+ }
1350
+ resolved = true;
1351
+ clearTimeout(timer);
1352
+ await (cleanup ?? Promise.resolve());
1353
+ resolve({
1354
+ error: spawnError,
1355
+ status: directStatus ?? status,
1356
+ signal: directSignal ?? signal,
1357
+ stdout,
1358
+ stderr,
1359
+ timedOut,
1360
+ });
1361
+ };
1362
+ const timer = setTimeout(() => {
1363
+ timedOut = true;
1364
+ cleanupOnce();
1365
+ }, timeout);
1366
+ child.stdout.setEncoding("utf8");
1367
+ child.stderr.setEncoding("utf8");
1368
+ child.stdout.on("data", (chunk) => {
1369
+ stdout += chunk;
1370
+ });
1371
+ child.stderr.on("data", (chunk) => {
1372
+ stderr += chunk;
1373
+ });
1374
+ child.on("error", (error) => {
1375
+ spawnError = error;
1376
+ clearTimeout(timer);
1377
+ cleanupOnce();
1378
+ setImmediate(() => {
1379
+ finish(null, null);
1380
+ });
1381
+ });
1382
+ child.on("exit", (status, signal) => {
1383
+ directStatus = status;
1384
+ directSignal = signal;
1385
+ clearTimeout(timer);
1386
+ cleanupOnce();
1387
+ });
1388
+ child.on("close", async (status, signal) => {
1389
+ await finish(status, signal);
1390
+ });
1391
+ });
1392
+ if (run.error) {
1393
+ fail(`${label} failed to execute: ${run.error.message}`);
1394
+ }
1395
+ if (run.timedOut) {
1396
+ fail(`${label} failed to execute: timed out after ${timeout}ms`);
1397
+ }
1398
+ if (run.status !== 0) {
1399
+ const detail = ((run.stderr ?? "").trim() || (run.stdout ?? "").trim());
1400
+ fail(`${label} failed: ${detail || `status ${run.status} signal ${run.signal ?? "none"}`}`);
1401
+ }
1402
+ const stderr = (run.stderr ?? "").trim();
1403
+ if (stderr.length > 0) {
1404
+ fail(`${label} wrote stderr: ${stderr}`);
1405
+ }
1406
+ return run.stdout ?? "";
1407
+ }
1408
+
1409
+ function parseValidatorJson(stdout, label) {
1410
+ const trimmed = stdout.trim();
1411
+ if (trimmed.length === 0) {
1412
+ fail(`${label} produced empty stdout`);
1413
+ }
1414
+ try {
1415
+ return assertObject(JSON.parse(trimmed), label);
1416
+ } catch (error) {
1417
+ fail(`${label} produced malformed JSON: ${error.message}`);
1418
+ }
1419
+ }
1420
+
1421
+ function assertExactKeys(value, allowed, label) {
1422
+ assertOnlyKeys(value, allowed, label);
1423
+ for (const key of allowed) {
1424
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
1425
+ fail(`${label}.${key} is required`);
1426
+ }
1427
+ }
1428
+ }
1429
+
1430
+ function assertValidatorIdentity(identity) {
1431
+ assertExactKeys(identity, VALIDATOR_IDENTITY_FIELDS, "validator identity");
1432
+ if (identity.schema_version !== VALIDATOR_IDENTITY_SCHEMA) {
1433
+ fail(`validator identity.schema_version must be ${VALIDATOR_IDENTITY_SCHEMA}`);
1434
+ }
1435
+ if (identity.validator !== VALIDATOR_NAME) {
1436
+ fail(`validator identity.validator must be ${VALIDATOR_NAME}`);
1437
+ }
1438
+ if (identity.validator_version !== VALIDATOR_VERSION) {
1439
+ fail(`validator identity.validator_version must be ${VALIDATOR_VERSION}`);
1440
+ }
1441
+ }
1442
+
1443
+ function assertValidatorResult(result, expected) {
1444
+ assertExactKeys(result, VALIDATOR_RESULT_FIELDS, "validator result");
1445
+ if (result.schema_version !== VALIDATOR_RESULT_SCHEMA) {
1446
+ fail(`validator result.schema_version must be ${VALIDATOR_RESULT_SCHEMA}`);
1447
+ }
1448
+ if (result.validator !== VALIDATOR_NAME) {
1449
+ fail(`validator result.validator must be ${VALIDATOR_NAME}`);
1450
+ }
1451
+ if (result.validator_version !== VALIDATOR_VERSION) {
1452
+ fail(`validator result.validator_version must be ${VALIDATOR_VERSION}`);
1453
+ }
1454
+ if (result.verdict !== "pass") {
1455
+ fail("validator result.verdict must be pass");
1456
+ }
1457
+ if (result.input_digest !== expected.inputDigest) {
1458
+ fail("validator result.input_digest must match submitted validation bundle");
1459
+ }
1460
+ if (result.validated_raw_documents !== expected.rawDocuments) {
1461
+ fail("validator result.validated_raw_documents must match submitted raw document count");
1462
+ }
1463
+ if (result.validated_instances !== expected.instances) {
1464
+ fail("validator result.validated_instances must match submitted capability instance count");
1465
+ }
1466
+ }
1467
+
1468
+ async function runCanonicalValidator(payload) {
1469
+ const rawDocuments = assertArray(payload.raw_documents, "validator payload.raw_documents").length;
1470
+ const instances = assertArray(payload.capability_instances, "validator payload.capability_instances").length;
1471
+ const validatorPath = resolveCanonicalValidator();
1472
+ const timeout = Number(process.env.PLANR_HOST_CAPABILITY_VALIDATOR_TIMEOUT_MS ?? "120000");
1473
+ const identity = parseValidatorJson(
1474
+ await spawnValidator(validatorPath, ["--identity"], timeout, "canonical Rust capability validator identity probe"),
1475
+ "validator identity",
1476
+ );
1477
+ assertValidatorIdentity(identity);
1478
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "planr-host-capability-validator-"));
1479
+ const inputPath = path.join(tempDir, "input.json");
1480
+ const inputBytes = `${JSON.stringify(payload)}\n`;
1481
+ const inputDigest = sha256Prefixed(inputBytes);
1482
+ writeFileSync(inputPath, inputBytes);
1483
+ try {
1484
+ const result = parseValidatorJson(
1485
+ await spawnValidator(validatorPath, ["--input", inputPath], timeout, "canonical Rust capability validation"),
1486
+ "validator result",
1487
+ );
1488
+ assertValidatorResult(result, { inputDigest, rawDocuments, instances });
1489
+ } finally {
1490
+ rmSync(tempDir, { recursive: true, force: true });
1491
+ }
1492
+ }
1493
+
1494
+ async function buildReplaySummary(fixtureRoot) {
1495
+ const { suite, experiments } = loadSuite(fixtureRoot);
1496
+ const rawById = loadRawCaptures(fixtureRoot, experiments);
1497
+ const expected = await validateExpectedManifest(fixtureRoot, suite, rawById);
1498
+ return {
1499
+ schema_version: SUMMARY_SCHEMA,
1500
+ verdict: "pass",
1501
+ suite_id: suite.suite_id,
1502
+ fixture_root: fixtureRoot,
1503
+ experiment_count: experiments.size,
1504
+ availability: Object.fromEntries(
1505
+ expected.capability_instances.map((entry) => [
1506
+ entry.raw_capture_id,
1507
+ {
1508
+ availability_status: entry.capability_instance.availability.status,
1509
+ trusted_adapter_enabled: entry.trusted_adapter_enabled,
1510
+ },
1511
+ ]),
1512
+ ),
1513
+ };
1514
+ }
1515
+
1516
+ async function replay(fixtureRoot) {
1517
+ const summary = await buildReplaySummary(fixtureRoot);
1518
+ console.log(JSON.stringify(summary));
1519
+ }
1520
+
1521
+ function copyDirectory(source, target) {
1522
+ mkdirSync(target, { recursive: true });
1523
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
1524
+ const from = path.join(source, entry.name);
1525
+ const to = path.join(target, entry.name);
1526
+ if (entry.isDirectory()) {
1527
+ copyDirectory(from, to);
1528
+ } else if (entry.isFile()) {
1529
+ copyFileSync(from, to);
1530
+ } else {
1531
+ fail(`import fixture contains unsupported filesystem entry ${from}`);
1532
+ }
1533
+ }
1534
+ }
1535
+
1536
+ function isSameOrInside(child, parent) {
1537
+ const relative = path.relative(parent, child);
1538
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
1539
+ }
1540
+
1541
+ function assertNoSymlinkPath(targetPath, label = "path") {
1542
+ const resolved = path.resolve(targetPath);
1543
+ let current = path.parse(resolved).root;
1544
+ for (const segment of path.relative(current, resolved).split(path.sep)) {
1545
+ if (!segment) continue;
1546
+ current = path.join(current, segment);
1547
+ if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
1548
+ fail(`${label} must not traverse symlink component ${current}`);
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ function validateOutputDestination(outDir, options = {}) {
1554
+ assertNonEmptyString(outDir, "capture --out-dir");
1555
+ const destination = path.resolve(outDir);
1556
+ const parent = path.dirname(destination);
1557
+ assertNoSymlinkPath(destination, "capture --out-dir");
1558
+ if (!existsSync(parent)) {
1559
+ fail(`capture --out-dir parent must already exist: ${parent}`);
1560
+ }
1561
+ if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) {
1562
+ fail("capture --out-dir must not be a symlink");
1563
+ }
1564
+ const parentReal = realpathSync(parent);
1565
+ const destinationReal = existsSync(destination) ? realpathSync(destination) : path.join(parentReal, path.basename(destination));
1566
+ const repoRoot = realpathSync(packageRoot());
1567
+ const protectedRuntimeRoots = [
1568
+ ["host capability runtime root", packagedRuntimeRoot()],
1569
+ ["host capability fixture root", packagedFixtureRoot()],
1570
+ ].filter(([, protectedRoot]) => existsSync(protectedRoot));
1571
+ for (const [label, protectedRoot] of [
1572
+ ["repository root", repoRoot],
1573
+ ...protectedRuntimeRoots.map(([label, protectedRoot]) => [label, realpathSync(protectedRoot)]),
1574
+ ]) {
1575
+ if (destinationReal === protectedRoot || isSameOrInside(protectedRoot, destinationReal)) {
1576
+ fail(`capture --out-dir must not target ${label} or one of its ancestors`);
1577
+ }
1578
+ }
1579
+ if (isSameOrInside(destinationReal, repoRoot)) {
1580
+ fail("capture --out-dir must not be inside the repository workspace");
1581
+ }
1582
+ if (options.importRoot) {
1583
+ assertNoSymlinkPath(options.importRoot, "--import-fixture-root");
1584
+ const importReal = realpathSync(options.importRoot);
1585
+ if (destinationReal === importReal || isSameOrInside(destinationReal, importReal) || isSameOrInside(importReal, destinationReal)) {
1586
+ fail("capture --out-dir must not overlap --import-fixture-root");
1587
+ }
1588
+ }
1589
+ if (existsSync(destination)) {
1590
+ if (!lstatSync(destination).isDirectory()) {
1591
+ fail("capture --out-dir must be an absent or empty directory");
1592
+ }
1593
+ if (readdirSync(destination).length !== 0) {
1594
+ fail("capture --out-dir must be empty before capture");
1595
+ }
1596
+ }
1597
+ return { destination, parent: parentReal };
1598
+ }
1599
+
1600
+ async function generateAndPublish(outDir, options, generator) {
1601
+ const destination = validateOutputDestination(outDir, options);
1602
+ const staging = mkdtempSync(path.join(destination.parent, ".planr-host-capability-"));
1603
+ try {
1604
+ await generator(staging);
1605
+ validateOutputDestination(outDir, options);
1606
+ if (existsSync(destination.destination)) {
1607
+ rmdirSync(destination.destination);
1608
+ }
1609
+ renameSync(staging, destination.destination);
1610
+ } catch (error) {
1611
+ rmSync(staging, { recursive: true, force: true });
1612
+ throw error;
1613
+ }
1614
+ }
1615
+
1616
+ function loadSchemaReferencesFromFiles(fixtureRoot) {
1617
+ const refs = {
1618
+ raw: { path: RAW_SCHEMA_REF_PATH },
1619
+ expected: { path: EXPECTED_SCHEMA_REF_PATH },
1620
+ provenance: { path: PROVENANCE_SCHEMA_REF_PATH },
1621
+ };
1622
+ for (const ref of Object.values(refs)) {
1623
+ const file = safeFixturePath(fixtureRoot, ref.path, "schema ref path");
1624
+ if (!existsSync(file)) {
1625
+ fail(`observed import bundle is missing schema file ${ref.path}`);
1626
+ }
1627
+ ref.digest = sha256File(file);
1628
+ ref.document = readJson(file);
1629
+ }
1630
+ return refs;
1631
+ }
1632
+
1633
+ function loadAnchoredSuite(suite = defaultSuite()) {
1634
+ return { suite, experiments: new Map(suite.experiments.map((experiment) => [experiment.id, experiment])) };
1635
+ }
1636
+
1637
+ function validateImportRoot(importRoot) {
1638
+ assertNonEmptyString(importRoot, "--import-fixture-root");
1639
+ assertNoSymlinkPath(importRoot, "--import-fixture-root");
1640
+ const resolved = path.resolve(importRoot);
1641
+ if (!existsSync(resolved) || !lstatSync(resolved).isDirectory()) {
1642
+ fail("--import-fixture-root must be an existing directory");
1643
+ }
1644
+ return resolved;
1645
+ }
1646
+
1647
+ function loadExternalEnvelope(importRoot) {
1648
+ const root = validateImportRoot(importRoot);
1649
+ const envelopePath = safeFixturePath(root, EXTERNAL_CAPTURE_ENVELOPE_PATH, "external envelope path");
1650
+ const envelope = assertObject(readJson(envelopePath), envelopePath);
1651
+ assertOnlyKeys(envelope, new Set(["schema_version", "producer", "suite_id", "captures"]), "external envelope");
1652
+ assertMajorOne(envelope.schema_version, EXTERNAL_CAPTURE_ENVELOPE_SCHEMA, "external envelope.schema_version");
1653
+ assertNonEmptyString(envelope.suite_id, "external envelope.suite_id");
1654
+ assertOnlyKeys(
1655
+ envelope.producer,
1656
+ new Set(["name", "version", "captured_at"]),
1657
+ "external envelope.producer",
1658
+ );
1659
+ assertNonEmptyString(envelope.producer.name, "external envelope.producer.name");
1660
+ assertNonEmptyString(envelope.producer.version, "external envelope.producer.version");
1661
+ assertTimestamp(envelope.producer.captured_at, "external envelope.producer.captured_at");
1662
+ return { envelope, envelopeDigest: sha256File(envelopePath), root };
1663
+ }
1664
+
1665
+ function loadExternalCaptures(importRoot, suite = defaultSuite()) {
1666
+ const { experiments } = loadAnchoredSuite(suite);
1667
+ const { envelope, envelopeDigest, root } = loadExternalEnvelope(importRoot);
1668
+ if (envelope.suite_id !== suite.suite_id) {
1669
+ fail("external envelope suite_id must match the harness-owned suite");
1670
+ }
1671
+ const rawById = new Map();
1672
+ const producer = {
1673
+ name: envelope.producer.name,
1674
+ version: envelope.producer.version,
1675
+ captured_at: envelope.producer.captured_at,
1676
+ envelope_digest: envelopeDigest,
1677
+ };
1678
+ const producedAt = parseUtcTimestamp(envelope.producer.captured_at, "external envelope.producer.captured_at");
1679
+ const now = Date.now();
1680
+ if (producedAt > now + EXTERNAL_CAPTURE_FUTURE_SKEW_MS) {
1681
+ fail("external envelope.producer.captured_at must not be in the future");
1682
+ }
1683
+ if (now - producedAt > EXTERNAL_CAPTURE_MAX_AGE_MS) {
1684
+ fail("external envelope.producer.captured_at is stale for fresh external import");
1685
+ }
1686
+ for (const [index, capture] of assertArray(envelope.captures, "external envelope.captures").entries()) {
1687
+ const raw = structuredClone(assertObject(capture, `external envelope.captures[${index}]`));
1688
+ delete raw.provenance_ref;
1689
+ const experimentId = assertExperimentId(raw.experiment_id, `external envelope.captures[${index}].experiment_id`);
1690
+ const anchored = experiments.get(experimentId);
1691
+ if (!anchored) {
1692
+ fail(`external envelope.captures[${index}] references unknown experiment ${experimentId}`);
1693
+ }
1694
+ if (rawById.has(experimentId)) {
1695
+ fail(`duplicate external capture for ${experimentId}`);
1696
+ }
1697
+ const { ended } = assertTimeRange(raw.started_at, raw.ended_at, `external envelope.captures[${index}]`);
1698
+ if (producedAt < ended) {
1699
+ fail("external envelope.producer.captured_at must not be earlier than included captures");
1700
+ }
1701
+ if (now - ended > EXTERNAL_CAPTURE_MAX_AGE_MS) {
1702
+ fail(`external capture ${anchored.id} ended_at is stale for fresh external import`);
1703
+ }
1704
+ const adapted = new Map([
1705
+ [
1706
+ anchored.id,
1707
+ {
1708
+ ...anchored,
1709
+ input_kind: raw.input?.input_kind,
1710
+ expected_result_status: raw.result?.final_status,
1711
+ },
1712
+ ],
1713
+ ]);
1714
+ validateRawCapture(root, raw, adapted, `external envelope.captures[${index}]`);
1715
+ if (raw.input.input_kind === "unprobed_placeholder") {
1716
+ fail(`external capture ${anchored.id} must be observed host output, not a placeholder`);
1717
+ }
1718
+ Object.defineProperty(raw, IMPORT_METADATA, {
1719
+ value: { producer },
1720
+ enumerable: false,
1721
+ });
1722
+ rawById.set(anchored.id, raw);
1723
+ }
1724
+ validateArtifactGraph(root, rawById);
1725
+ return { suite, captures: [...rawById.values()] };
1726
+ }
1727
+
1728
+ async function importFixtureCapture(outDir, importFixtureRoot, suite = defaultSuite()) {
1729
+ await generateAndPublish(
1730
+ outDir,
1731
+ { importRoot: importFixtureRoot },
1732
+ async (stagingRoot) => {
1733
+ const { captures } = loadExternalCaptures(importFixtureRoot, suite);
1734
+ writeSchemaReferences(stagingRoot);
1735
+ copyDirectory(path.join(importFixtureRoot, "artifacts"), path.join(stagingRoot, "artifacts"));
1736
+ for (const capture of captures) {
1737
+ const anchored = suite.experiments.find((experiment) => experiment.id === capture.experiment_id);
1738
+ writeJson(safeFixturePath(stagingRoot, `observed/${anchored.id}.json`, "observed capture path"), capture);
1739
+ }
1740
+ const allCaptures = completeCapturesWithUnavailablePlaceholders(suite, captures, stagingRoot);
1741
+ writeJson(path.join(stagingRoot, "experiment-suite.json"), suiteFromCaptures(suite, allCaptures));
1742
+ for (const capture of allCaptures) {
1743
+ if (!existsSync(path.join(stagingRoot, "observed", `${capture.experiment_id}.json`))) {
1744
+ const anchored = suite.experiments.find((experiment) => experiment.id === capture.experiment_id);
1745
+ writeJson(safeFixturePath(stagingRoot, `observed/${anchored.id}.json`, "observed capture path"), capture);
1746
+ }
1747
+ }
1748
+ const schemaRefs = loadSchemaReferencesFromFiles(stagingRoot);
1749
+ const manifestRef = writeManifestReference(stagingRoot, allCaptures);
1750
+ const provenanceRef = writeProvenanceReference(stagingRoot, allCaptures, schemaRefs);
1751
+ for (const capture of allCaptures) {
1752
+ const anchored = suite.experiments.find((experiment) => experiment.id === capture.experiment_id);
1753
+ const rawPath = safeFixturePath(stagingRoot, `observed/${anchored.id}.json`, "observed capture path");
1754
+ const raw = readJson(rawPath);
1755
+ raw.provenance_ref = provenanceRef;
1756
+ writeJson(rawPath, raw);
1757
+ }
1758
+ const expected = expectedFromCaptures(allCaptures, manifestRef, schemaRefs, provenanceRef);
1759
+ writeJson(path.join(stagingRoot, "expected", "normalized-manifest.json"), expected);
1760
+ writeJson(
1761
+ path.join(stagingRoot, "expected", "host-surface-matrix.json"),
1762
+ hostSurfaceMatrixFromExpected(
1763
+ expected,
1764
+ new Map(allCaptures.map((capture) => [capture.experiment_id, capture])),
1765
+ readJson(path.join(stagingRoot, PROVENANCE_REF_PATH)),
1766
+ ),
1767
+ );
1768
+ await buildReplaySummary(stagingRoot);
1769
+ },
1770
+ );
1771
+ await replay(outDir);
1772
+ }
1773
+
1774
+ function claimSourceForCapture(capture) {
1775
+ if (capture.input.input_kind === "unprobed_placeholder") return "capture_mode_placeholder";
1776
+ if (capture.input.input_kind === "mechanical_availability_probe") return "mechanical_unavailable_probe";
1777
+ return "observed_capture";
1778
+ }
1779
+
1780
+ function sourceKindForCapture(capture) {
1781
+ const claimSource = claimSourceForCapture(capture);
1782
+ return CLAIM_SOURCE_RULES[claimSource].sourceKind;
1783
+ }
1784
+
1785
+ function observationModeForCapture(capture) {
1786
+ const claimSource = claimSourceForCapture(capture);
1787
+ return CLAIM_SOURCE_RULES[claimSource].observationMode;
1788
+ }
1789
+
1790
+ function probeOutcomeForCapture(capture) {
1791
+ if (capture.result.final_status === "available") return "passed";
1792
+ if (capture.result.final_status === "degraded") return "inconclusive";
1793
+ return "unavailable";
1794
+ }
1795
+
1796
+ function completeCapturesWithUnavailablePlaceholders(suite, captures, fixtureRoot) {
1797
+ const byId = new Map(captures.map((capture) => [capture.experiment_id, capture]));
1798
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
1799
+ return suite.experiments.map((experiment) => {
1800
+ const imported = byId.get(experiment.id);
1801
+ if (imported) return imported;
1802
+ if (experiment.input_kind === "mechanical_availability_probe") {
1803
+ return mechanicalUnavailableProbeCapture(fixtureRoot, experiment, now);
1804
+ }
1805
+ return unavailableCapture(experiment, now);
1806
+ });
1807
+ }
1808
+
1809
+ function suiteFromCaptures(baseSuite, captures) {
1810
+ const byId = new Map(captures.map((capture) => [capture.experiment_id, capture]));
1811
+ return {
1812
+ ...baseSuite,
1813
+ experiments: baseSuite.experiments.map((experiment) => {
1814
+ const capture = byId.get(experiment.id);
1815
+ if (!capture) return experiment;
1816
+ return {
1817
+ ...experiment,
1818
+ input_kind: capture.input.input_kind,
1819
+ expected_result_status: capture.result.final_status,
1820
+ };
1821
+ }),
1822
+ };
1823
+ }
1824
+
1825
+ export async function capture(outDir, options = {}, suite = defaultSuite()) {
1826
+ if (options.importFixtureRoot) {
1827
+ await importFixtureCapture(outDir, options.importFixtureRoot, suite);
1828
+ return;
1829
+ }
1830
+
1831
+ await generateAndPublish(outDir, {}, async (stagingRoot) => {
1832
+ await captureInto(stagingRoot, suite);
1833
+ });
1834
+ await buildReplaySummary(outDir);
1835
+ }
1836
+
1837
+ async function captureInto(outDir, suite = defaultSuite()) {
1838
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
1839
+ const nodeVersion = process.version;
1840
+ const terminal = spawnSync(process.execPath, ["-e", "process.stdout.write('planr-host-experiment-v1\\n')"], {
1841
+ encoding: "utf8",
1842
+ });
1843
+ const terminalStdout = Buffer.from(terminal.stdout ?? "", "utf8");
1844
+ function fixtureLocalFunction(left, right) {
1845
+ return { result: "ok", value: left + right };
1846
+ }
1847
+ const localValue = Buffer.from(`${JSON.stringify(fixtureLocalFunction(40, 2))}\n`, "utf8");
1848
+ mkdirSync(path.join(outDir, "artifacts", "terminal"), { recursive: true });
1849
+ mkdirSync(path.join(outDir, "artifacts", "local-function"), { recursive: true });
1850
+ writeFileSync(path.join(outDir, "artifacts", "terminal", "stdout.txt"), terminalStdout);
1851
+ writeFileSync(path.join(outDir, "artifacts", "local-function", "result.json"), localValue);
1852
+ writeJson(path.join(outDir, "experiment-suite.json"), suite);
1853
+ const captures = suite.experiments.map((experiment) => {
1854
+ if (experiment.id === "exp-terminal") {
1855
+ return availableCapture({
1856
+ fixtureRoot: outDir,
1857
+ id: experiment.id,
1858
+ host: experiment.host,
1859
+ surface: experiment.surface,
1860
+ version: nodeVersion,
1861
+ toolName: experiment.expected_tool_name,
1862
+ eventSource: experiment.expected_event_source,
1863
+ input: { command: [process.execPath, "-e", "process.stdout.write('planr-host-experiment-v1\\n')"] },
1864
+ artifactPath: "artifacts/terminal/stdout.txt",
1865
+ artifactId: "artifact-terminal-stdout",
1866
+ artifactKind: "stdout",
1867
+ finalStatus: terminal.status === 0 ? "available" : "probe_failed",
1868
+ startedAt: now,
1869
+ endedAt: now,
1870
+ });
1871
+ }
1872
+ if (experiment.id === "exp-local-function") {
1873
+ return availableCapture({
1874
+ fixtureRoot: outDir,
1875
+ id: experiment.id,
1876
+ host: experiment.host,
1877
+ surface: experiment.surface,
1878
+ version: nodeVersion,
1879
+ toolName: experiment.expected_tool_name,
1880
+ eventSource: experiment.expected_event_source,
1881
+ input: { function: "fixtureLocalFunction", args: [40, 2] },
1882
+ artifactPath: "artifacts/local-function/result.json",
1883
+ artifactId: "artifact-local-function-result",
1884
+ artifactKind: "json-result",
1885
+ finalStatus: "available",
1886
+ startedAt: now,
1887
+ endedAt: now,
1888
+ });
1889
+ }
1890
+ if (experiment.input_kind === "mechanical_availability_probe") {
1891
+ return mechanicalUnavailableProbeCapture(outDir, experiment, now);
1892
+ }
1893
+ return unavailableCapture(experiment, now);
1894
+ });
1895
+ writeJson(path.join(outDir, "experiment-suite.json"), suiteFromCaptures(suite, captures));
1896
+ const schemaRefs = writeSchemaReferences(outDir);
1897
+ const manifestRef = writeManifestReference(outDir, captures);
1898
+ const provenanceRef = writeProvenanceReference(outDir, captures, schemaRefs);
1899
+ for (const capture of captures) {
1900
+ capture.provenance_ref = provenanceRef;
1901
+ writeJson(path.join(outDir, "observed", `${capture.experiment_id}.json`), capture);
1902
+ }
1903
+ const expected = expectedFromCaptures(captures, manifestRef, schemaRefs, provenanceRef);
1904
+ writeJson(path.join(outDir, "expected", "normalized-manifest.json"), expected);
1905
+ writeJson(
1906
+ path.join(outDir, "expected", "host-surface-matrix.json"),
1907
+ hostSurfaceMatrixFromExpected(
1908
+ expected,
1909
+ new Map(captures.map((capture) => [capture.experiment_id, capture])),
1910
+ readJson(path.join(outDir, PROVENANCE_REF_PATH)),
1911
+ ),
1912
+ );
1913
+ await replay(outDir);
1914
+ }
1915
+
1916
+ export function defaultSuite() {
1917
+ return {
1918
+ schema_version: SUITE_SCHEMA,
1919
+ suite_id: "codex-host-capability-phase1",
1920
+ docs_are_experiment_design_only: true,
1921
+ experiments: [
1922
+ experiment("exp-terminal", "terminal", "node:child_process.spawnSync", "node:child_process", "available"),
1923
+ experiment("exp-local-function", "local-function", "planr:local-function-fixture", "node:function-call", "available"),
1924
+ experiment(
1925
+ "exp-codex-app-server",
1926
+ "codex-app-server",
1927
+ "codex app-server",
1928
+ "process:codex",
1929
+ "unavailable",
1930
+ "unprobed_placeholder",
1931
+ ),
1932
+ experiment("exp-codex-exec", "codex-exec", "codex exec", "process:codex", "unavailable", "unprobed_placeholder"),
1933
+ experiment(
1934
+ "exp-codex-mcp-server",
1935
+ "codex-mcp-server",
1936
+ "codex mcp-server",
1937
+ "process:codex",
1938
+ "unavailable",
1939
+ "unprobed_placeholder",
1940
+ ),
1941
+ experiment("exp-mcp-browser", "mcp-browser", "mcp browser tool", "mcp:browser", "unavailable", "unprobed_placeholder"),
1942
+ experiment(
1943
+ "exp-built-in-browser",
1944
+ "built-in-browser",
1945
+ "browser tool",
1946
+ "codex:built-in-tool",
1947
+ "unavailable",
1948
+ "unprobed_placeholder",
1949
+ ),
1950
+ experiment("exp-chrome-cdp", "chrome-cdp", "chrome/cdp tool", "cdp:chrome", "unavailable", "unprobed_placeholder"),
1951
+ experiment(
1952
+ "exp-chrome-browser-client",
1953
+ "chrome-browser-client",
1954
+ "browser-client.mjs chrome Runtime.evaluate",
1955
+ "browser-client:chrome",
1956
+ "unavailable",
1957
+ "unprobed_placeholder",
1958
+ ),
1959
+ experiment(
1960
+ "exp-codex-hook-events",
1961
+ "codex-hook-events",
1962
+ "codex hook event stream",
1963
+ "codex:hook",
1964
+ "unavailable",
1965
+ "unprobed_placeholder",
1966
+ ),
1967
+ experiment(
1968
+ "exp-computer-use",
1969
+ "computer-use",
1970
+ "computer use tool",
1971
+ "codex:computer-use",
1972
+ "unavailable",
1973
+ "unprobed_placeholder",
1974
+ ),
1975
+ hostExperiment(
1976
+ "exp-claude-code-host-capture",
1977
+ "claude",
1978
+ "claude-code",
1979
+ "$HOME/.local/bin/claude --version",
1980
+ "process:claude-code",
1981
+ "unavailable",
1982
+ "mechanical_availability_probe",
1983
+ ),
1984
+ hostExperiment(
1985
+ "exp-cursor-agent-host-capture",
1986
+ "cursor",
1987
+ "cursor-agent",
1988
+ "$HOME/.local/bin/cursor-agent --version",
1989
+ "process:cursor-agent",
1990
+ "unavailable",
1991
+ "mechanical_availability_probe",
1992
+ ),
1993
+ hostExperiment(
1994
+ "exp-pi-cli-host-capture",
1995
+ "pi",
1996
+ "pi-cli",
1997
+ "$HOME/.local/bin/pi --version",
1998
+ "process:pi-cli",
1999
+ "unavailable",
2000
+ "mechanical_availability_probe",
2001
+ ),
2002
+ ],
2003
+ };
2004
+ }
2005
+
2006
+ function experiment(id, surface, toolName, eventSource, status, inputKind = "controlled_probe") {
2007
+ return hostExperiment(id, "codex", surface, toolName, eventSource, status, inputKind);
2008
+ }
2009
+
2010
+ function hostExperiment(id, host, surface, toolName, eventSource, status, inputKind = "controlled_probe") {
2011
+ return {
2012
+ id,
2013
+ host,
2014
+ surface,
2015
+ input_kind: inputKind,
2016
+ expected_tool_name: toolName,
2017
+ expected_event_source: eventSource,
2018
+ expected_result_status: status,
2019
+ };
2020
+ }
2021
+
2022
+ function availableCapture(input) {
2023
+ const artifactDigest = sha256File(path.join(input.fixtureRoot, input.artifactPath));
2024
+ return {
2025
+ schema_version: RAW_SCHEMA,
2026
+ payload_version: `${RAW_PAYLOAD_PREFIX}1.0.0`,
2027
+ experiment_id: input.id,
2028
+ host_identity: {
2029
+ host: input.host,
2030
+ surface: input.surface,
2031
+ version: input.version,
2032
+ adapter_version: ADAPTER_VERSION,
2033
+ },
2034
+ surface: input.surface,
2035
+ tool_name: input.toolName,
2036
+ event_source: input.eventSource,
2037
+ started_at: input.startedAt,
2038
+ ended_at: input.endedAt,
2039
+ input: { input_kind: "controlled_probe", ...input.input },
2040
+ events: [
2041
+ {
2042
+ sequence: 1,
2043
+ event_name: "started",
2044
+ payload_version: "host-event/1.0.0",
2045
+ tool_name: input.toolName,
2046
+ event_source: input.eventSource,
2047
+ payload: { input_kind: "controlled_probe" },
2048
+ },
2049
+ {
2050
+ sequence: 2,
2051
+ event_name: "final",
2052
+ final: true,
2053
+ payload_version: "host-event/1.0.0",
2054
+ tool_name: input.toolName,
2055
+ event_source: input.eventSource,
2056
+ payload: { final_status: input.finalStatus },
2057
+ },
2058
+ ],
2059
+ result: {
2060
+ final_status: input.finalStatus,
2061
+ availability_reason: "controlled fixture probe passed",
2062
+ permissions: {
2063
+ network: "not_used",
2064
+ filesystem: "fixture-controlled",
2065
+ environment: "node process environment visible to controlled probe",
2066
+ secrets: "not_requested",
2067
+ },
2068
+ sandbox: { mode: "developer-local", writable_roots: ["fixture_root"] },
2069
+ missing_fields: [],
2070
+ blind_spots: ["agent transcript and private profile data intentionally not captured"],
2071
+ artifact_refs: [
2072
+ {
2073
+ id: input.artifactId,
2074
+ kind: input.artifactKind,
2075
+ root_kind: "fixture_root",
2076
+ path: input.artifactPath,
2077
+ digest: artifactDigest,
2078
+ },
2079
+ ],
2080
+ artifact_digests: { [input.artifactId]: artifactDigest },
2081
+ },
2082
+ };
2083
+ }
2084
+
2085
+ function mechanicalUnavailableProbeCapture(fixtureRoot, experiment, now) {
2086
+ const argv = experiment.expected_tool_name.split(" ");
2087
+ const artifactDir = path.join("artifacts", experiment.id.replace(/^exp-/, ""));
2088
+ const probeTmp = path.join(fixtureRoot, artifactDir, "probe-tmp");
2089
+ mkdirSync(probeTmp, { recursive: true });
2090
+ const result = spawnSync(argv[0], argv.slice(1), {
2091
+ cwd: packageRoot(),
2092
+ env: { ...process.env, TMPDIR: probeTmp },
2093
+ encoding: "buffer",
2094
+ timeout: 5000,
2095
+ maxBuffer: 1024 * 1024,
2096
+ });
2097
+ rmSync(probeTmp, { recursive: true, force: true });
2098
+ const stdout = Buffer.from(result.stdout ?? "");
2099
+ const stderr = Buffer.from(result.stderr ?? "");
2100
+ const stdoutText = stdout.toString("utf8");
2101
+ const version = result.status === 0 ? parseVersionProbeStdout(experiment.host, stdoutText) : null;
2102
+ if (!Number.isInteger(result.status) || result.status !== 0 || result.signal !== null || !version) {
2103
+ rmSync(path.join(fixtureRoot, artifactDir), { recursive: true, force: true });
2104
+ const placeholder = unavailableCapture(
2105
+ { ...experiment, input_kind: "unprobed_placeholder" },
2106
+ now,
2107
+ );
2108
+ placeholder.result.availability_reason =
2109
+ `${experiment.host} ${experiment.surface} version probe was unavailable or unparsable; ` +
2110
+ "host capture downgraded to a missing-version placeholder";
2111
+ placeholder.result.experiment_plan = [
2112
+ `Repair or install the bounded read-only version probe ${argv.join(" ")}.`,
2113
+ "Re-run capture mode and require a successful, parseable mechanical invocation before recording host version provenance.",
2114
+ `Keep ${experiment.host} ${experiment.surface} host capture unavailable until a final event and artifact contract is observed.`,
2115
+ ];
2116
+ placeholder.result.notes = [
2117
+ "failed or unparsable version probes are not retained as mechanical_unavailable_probe provenance",
2118
+ ];
2119
+ return placeholder;
2120
+ }
2121
+ mkdirSync(path.join(fixtureRoot, artifactDir), { recursive: true });
2122
+ const stdoutPath = path.join(artifactDir, "stdout.txt").split(path.sep).join("/");
2123
+ const stderrPath = path.join(artifactDir, "stderr.txt").split(path.sep).join("/");
2124
+ const resultPath = path.join(artifactDir, "result.json").split(path.sep).join("/");
2125
+ const resultPayload = {
2126
+ schema_version: "planr.host_capability_mechanical_invocation.v1",
2127
+ argv,
2128
+ cwd: packageRoot(),
2129
+ exit_status: result.status,
2130
+ signal: result.signal,
2131
+ stdout_bytes: stdout.length,
2132
+ stderr_bytes: stderr.length,
2133
+ };
2134
+ writeFileSync(path.join(fixtureRoot, stdoutPath), stdout);
2135
+ writeFileSync(path.join(fixtureRoot, stderrPath), stderr);
2136
+ writeJson(path.join(fixtureRoot, resultPath), resultPayload);
2137
+ const artifactRefs = [
2138
+ artifactRef(fixtureRoot, `artifact-${experiment.id.replace(/^exp-/, "")}-stdout`, "invocation-stdout", stdoutPath),
2139
+ artifactRef(fixtureRoot, `artifact-${experiment.id.replace(/^exp-/, "")}-stderr`, "invocation-stderr", stderrPath),
2140
+ artifactRef(fixtureRoot, `artifact-${experiment.id.replace(/^exp-/, "")}-result`, "invocation-result", resultPath),
2141
+ ];
2142
+ const artifactDigests = Object.fromEntries(artifactRefs.map((ref) => [ref.id, ref.digest]));
2143
+ const hostVersion = version ?? "missing";
2144
+ return {
2145
+ schema_version: RAW_SCHEMA,
2146
+ payload_version: `${RAW_PAYLOAD_PREFIX}1.0.0`,
2147
+ experiment_id: experiment.id,
2148
+ host_identity: {
2149
+ host: experiment.host,
2150
+ surface: experiment.surface,
2151
+ version: hostVersion,
2152
+ adapter_version: ADAPTER_VERSION,
2153
+ },
2154
+ surface: experiment.surface,
2155
+ tool_name: experiment.expected_tool_name,
2156
+ event_source: experiment.expected_event_source,
2157
+ started_at: now,
2158
+ ended_at: now,
2159
+ input: {
2160
+ input_kind: experiment.input_kind,
2161
+ command: argv,
2162
+ probe: "read-only host version probe",
2163
+ },
2164
+ events: [
2165
+ {
2166
+ sequence: 1,
2167
+ event_name: "started",
2168
+ payload_version: "host-event/1.0.0",
2169
+ tool_name: experiment.expected_tool_name,
2170
+ event_source: experiment.expected_event_source,
2171
+ payload: { input_kind: experiment.input_kind },
2172
+ },
2173
+ {
2174
+ sequence: 2,
2175
+ event_name: "final",
2176
+ final: true,
2177
+ payload_version: "host-event/1.0.0",
2178
+ tool_name: experiment.expected_tool_name,
2179
+ event_source: experiment.expected_event_source,
2180
+ payload: { final_status: "unavailable", exit_code: result.status },
2181
+ },
2182
+ ],
2183
+ result: {
2184
+ final_status: "unavailable",
2185
+ availability_reason: version
2186
+ ? `${experiment.host} ${experiment.surface} mechanical version probe returned ${version}, but no host capture payload/event contract was observed`
2187
+ : `${experiment.host} ${experiment.surface} mechanical version probe did not produce a parseable version; host capture remains unavailable`,
2188
+ permissions: {
2189
+ network: "not_used",
2190
+ filesystem: "read_only_binary_version_probe",
2191
+ environment: "version probe only; no host session launched",
2192
+ secrets: "not_requested",
2193
+ },
2194
+ sandbox: { mode: "not_entered", writable_roots: [] },
2195
+ missing_fields: version
2196
+ ? ["live_event_stream", "final_result_payload", "artifact_contract"]
2197
+ : ["host_version", "live_event_stream", "final_result_payload", "artifact_contract"],
2198
+ blind_spots: [
2199
+ "installed binary version does not prove host capture payload support",
2200
+ "no paid/network host run was performed",
2201
+ ],
2202
+ artifact_refs: artifactRefs,
2203
+ artifact_digests: artifactDigests,
2204
+ experiment_plan: [
2205
+ `Run ${argv.join(" ")} as a bounded read-only version probe without network or paid model calls.`,
2206
+ "Capture a final event stream, permissions, sandbox state, and artifact contract only if the host exposes one.",
2207
+ `Keep ${experiment.host} ${experiment.surface} host capture unavailable until replay validates an observed payload contract.`,
2208
+ ],
2209
+ notes: [
2210
+ "mechanical_unavailable_probe records binary availability only and must not be promoted to observed_capture",
2211
+ ],
2212
+ },
2213
+ };
2214
+ }
2215
+
2216
+ function artifactRef(fixtureRoot, id, kind, artifactPath) {
2217
+ return {
2218
+ id,
2219
+ kind,
2220
+ root_kind: "fixture_root",
2221
+ path: artifactPath,
2222
+ digest: sha256File(path.join(fixtureRoot, artifactPath)),
2223
+ };
2224
+ }
2225
+
2226
+ function parseVersionProbeStdout(host, stdoutText) {
2227
+ const trimmed = stdoutText.trim();
2228
+ if (host === "claude") {
2229
+ return trimmed.match(/^([0-9]+(?:\.[0-9]+){2}) \(Claude Code\)$/)?.[1] ?? null;
2230
+ }
2231
+ if (host === "cursor") {
2232
+ return trimmed.match(/^([0-9]{4}\.[0-9]{2}\.[0-9]{2}-[0-9a-f]+)$/)?.[1] ?? null;
2233
+ }
2234
+ if (host === "pi") {
2235
+ return trimmed.match(/^([0-9]+(?:\.[0-9]+){2})$/)?.[1] ?? null;
2236
+ }
2237
+ return null;
2238
+ }
2239
+
2240
+ function unavailableCapture(experiment, now) {
2241
+ return {
2242
+ schema_version: RAW_SCHEMA,
2243
+ payload_version: `${RAW_PAYLOAD_PREFIX}1.0.0`,
2244
+ experiment_id: experiment.id,
2245
+ host_identity: {
2246
+ host: experiment.host,
2247
+ surface: experiment.surface,
2248
+ version: "missing",
2249
+ adapter_version: ADAPTER_VERSION,
2250
+ },
2251
+ surface: experiment.surface,
2252
+ tool_name: experiment.expected_tool_name,
2253
+ event_source: experiment.expected_event_source,
2254
+ started_at: now,
2255
+ ended_at: now,
2256
+ input: {
2257
+ input_kind: experiment.input_kind,
2258
+ probe: "capture-mode placeholder",
2259
+ replay_mode: "unprobed-placeholder",
2260
+ },
2261
+ events: [
2262
+ {
2263
+ sequence: 1,
2264
+ event_name: "final",
2265
+ final: true,
2266
+ payload_version: "host-event/1.0.0",
2267
+ tool_name: experiment.expected_tool_name,
2268
+ event_source: experiment.expected_event_source,
2269
+ payload: { final_status: "unavailable" },
2270
+ },
2271
+ ],
2272
+ result: {
2273
+ final_status: "unavailable",
2274
+ availability_reason: "unverified surface remains unavailable until live capture exists",
2275
+ permissions: {
2276
+ network: "not_probed",
2277
+ filesystem: "not_probed",
2278
+ environment: "not_probed",
2279
+ secrets: "not_requested",
2280
+ },
2281
+ sandbox: { mode: "not_entered", writable_roots: [] },
2282
+ missing_fields: ["host_version", "live_event_stream", "final_result_payload", "artifact_contract"],
2283
+ blind_spots: ["capture mode emitted an explicit placeholder, not an observed support claim"],
2284
+ artifact_refs: [],
2285
+ artifact_digests: {},
2286
+ experiment_plan: [
2287
+ "Implement a surface-specific controlled probe before treating capture output as observed evidence.",
2288
+ "Run this harness in capture mode on a host exposing the surface.",
2289
+ "Record raw events and artifacts before enabling any trusted adapter.",
2290
+ "Keep capability unavailable until replay validates the observed contract.",
2291
+ ],
2292
+ },
2293
+ };
2294
+ }
2295
+
2296
+ function writeManifestReference(outDir, captures) {
2297
+ const manifest = {
2298
+ schema_version: "planr.host_capability_manifest_reference.v1",
2299
+ manifest_ids: captures
2300
+ .map((capture) => `host-${capture.experiment_id.replace(/^exp-/, "")}-manifest`)
2301
+ .sort(),
2302
+ trusted_adapter_enabled: false,
2303
+ source: "capture-mode generated manifest reference",
2304
+ };
2305
+ writeJson(path.join(outDir, MANIFEST_REF_PATH), manifest);
2306
+ return { path: MANIFEST_REF_PATH, digest: sha256File(path.join(outDir, MANIFEST_REF_PATH)) };
2307
+ }
2308
+
2309
+ function writeSchemaReferences(outDir) {
2310
+ const sourceRoot = existsSync(path.join(packagedRuntimeRoot(), "v1"))
2311
+ ? path.join(packagedRuntimeRoot(), "v1")
2312
+ : path.join(packagedFixtureRoot(), "v1");
2313
+ const refs = {};
2314
+ for (const [key, relativePath] of [
2315
+ ["raw", RAW_SCHEMA_REF_PATH],
2316
+ ["expected", EXPECTED_SCHEMA_REF_PATH],
2317
+ ["provenance", PROVENANCE_SCHEMA_REF_PATH],
2318
+ ]) {
2319
+ const schema = readJson(path.join(sourceRoot, relativePath));
2320
+ writeJson(path.join(outDir, relativePath), schema);
2321
+ refs[key] = { path: relativePath, digest: sha256File(path.join(outDir, relativePath)) };
2322
+ }
2323
+ return refs;
2324
+ }
2325
+
2326
+ function writeProvenanceReference(outDir, captures, schemaRefs) {
2327
+ const record = {
2328
+ schema_version: "planr.host_capability_provenance.v1",
2329
+ schema_ref: schemaRefs.provenance.path,
2330
+ schema_digest: schemaRefs.provenance.digest,
2331
+ suite_id: "codex-host-capability-phase1",
2332
+ captures: captures.map((capture) => {
2333
+ const entry = {
2334
+ experiment_id: capture.experiment_id,
2335
+ source_kind: sourceKindForCapture(capture),
2336
+ host: capture.host_identity.host,
2337
+ surface: capture.surface,
2338
+ input_kind: capture.input.input_kind,
2339
+ observation_mode: observationModeForCapture(capture),
2340
+ tool_name: capture.tool_name,
2341
+ event_source: capture.event_source,
2342
+ host_version: capture.host_identity.version,
2343
+ adapter_version: capture.host_identity.adapter_version,
2344
+ claim_source: claimSourceForCapture(capture),
2345
+ availability_reason: capture.result.availability_reason,
2346
+ probe_checks: [
2347
+ {
2348
+ name: "final-event",
2349
+ outcome: probeOutcomeForCapture(capture),
2350
+ detail: "final event and artifact contract validated by replay",
2351
+ },
2352
+ ],
2353
+ missing_fields: capture.result.missing_fields,
2354
+ artifact_ids: capture.result.artifact_refs.map((artifactRef) => artifactRef.id).sort(),
2355
+ captured_at: capture.ended_at,
2356
+ };
2357
+ if (capture[IMPORT_METADATA]?.producer) {
2358
+ entry.external_producer = capture[IMPORT_METADATA].producer;
2359
+ }
2360
+ return entry;
2361
+ }),
2362
+ };
2363
+ writeJson(path.join(outDir, PROVENANCE_REF_PATH), record);
2364
+ return { path: PROVENANCE_REF_PATH, digest: sha256File(path.join(outDir, PROVENANCE_REF_PATH)) };
2365
+ }
2366
+
2367
+ function expectedFromCaptures(captures, manifestRef, schemaRefs, provenanceRef, provenanceById = new Map()) {
2368
+ const expectedSchemaRefs = Object.fromEntries(
2369
+ Object.entries(schemaRefs).map(([key, ref]) => [key, { path: ref.path, digest: ref.digest }]),
2370
+ );
2371
+ const expectedProvenanceRef = { path: provenanceRef.path, digest: provenanceRef.digest };
2372
+ return {
2373
+ schema_version: EXPECTED_SCHEMA,
2374
+ payload_version: `${EXPECTED_PAYLOAD_PREFIX}1.0.0`,
2375
+ suite_id: "codex-host-capability-phase1",
2376
+ schema_refs: expectedSchemaRefs,
2377
+ provenance_ref: expectedProvenanceRef,
2378
+ capability_instances: captures.map((capture) => {
2379
+ const provenance = provenanceById.get(capture.experiment_id);
2380
+ const claimSource = provenance?.claim_source ?? claimSourceForCapture(capture);
2381
+ const checks = provenance?.probe_checks ?? [
2382
+ {
2383
+ name: "final-event",
2384
+ outcome: capture.result.final_status === "available" ? "passed" : "unavailable",
2385
+ detail: "final event and artifact contract validated by replay",
2386
+ },
2387
+ ];
2388
+ return {
2389
+ raw_capture_id: capture.experiment_id,
2390
+ claim_source: claimSource,
2391
+ trusted_adapter_enabled: false,
2392
+ manifest_ref: manifestRef,
2393
+ provenance_ref: expectedProvenanceRef,
2394
+ capability_instance: {
2395
+ id: `host-${capture.experiment_id}`,
2396
+ schema_version: "evidence.contract.v1",
2397
+ manifest_id: `host-${capture.experiment_id.replace(/^exp-/, "")}-manifest`,
2398
+ manifest_digest: manifestRef.digest,
2399
+ host: capture.host_identity.host,
2400
+ surface: capture.surface,
2401
+ host_version: capture.host_identity.version,
2402
+ adapter_version: capture.host_identity.adapter_version,
2403
+ environment: environmentBinding(`capture-mode-${os.platform()}`, `env-${capture.experiment_id}`),
2404
+ permissions: capture.result.permissions,
2405
+ availability: {
2406
+ status: capture.result.final_status,
2407
+ reason: capture.result.availability_reason,
2408
+ },
2409
+ probe_result: {
2410
+ probe_execution_id: `probe-${capture.experiment_id}`,
2411
+ outcome: probeOutcomeForCapture(capture),
2412
+ observed_at: capture.ended_at,
2413
+ checks,
2414
+ },
2415
+ observed_payload_contract: {
2416
+ schema_ref: RAW_SCHEMA_REF_PATH,
2417
+ observation_types: [hostObservationType(capture.host_identity.host, capture.surface)],
2418
+ },
2419
+ limitations: capture.result.blind_spots,
2420
+ captured_at: capture.ended_at,
2421
+ },
2422
+ };
2423
+ }),
2424
+ };
2425
+ }
2426
+
2427
+ function environmentBinding(kind, id) {
2428
+ return {
2429
+ kind,
2430
+ id,
2431
+ digest: stableDigest({ kind, id }),
2432
+ };
2433
+ }
2434
+
2435
+ function hostObservationType(host, surface) {
2436
+ return `host.${host.replaceAll("-", "_")}.${surface.replaceAll("-", "_")}`;
2437
+ }
2438
+
2439
+ async function main() {
2440
+ const [command, ...args] = process.argv.slice(2);
2441
+ const getFlag = (name) => {
2442
+ const index = args.indexOf(name);
2443
+ return index >= 0 ? args[index + 1] : undefined;
2444
+ };
2445
+ try {
2446
+ if (command === "replay") {
2447
+ const fixtureRoot = getFlag("--fixture-root");
2448
+ if (!fixtureRoot) {
2449
+ usage();
2450
+ process.exitCode = 2;
2451
+ return;
2452
+ }
2453
+ await replay(fixtureRoot);
2454
+ return;
2455
+ }
2456
+ if (command === "capture") {
2457
+ const outDir = getFlag("--out-dir");
2458
+ const importFixtureRoot = getFlag("--import-fixture-root");
2459
+ if (!outDir) {
2460
+ usage();
2461
+ process.exitCode = 2;
2462
+ return;
2463
+ }
2464
+ await capture(outDir, { importFixtureRoot });
2465
+ return;
2466
+ }
2467
+ usage();
2468
+ process.exitCode = 2;
2469
+ } catch (error) {
2470
+ console.error(JSON.stringify({ schema_version: SUMMARY_SCHEMA, verdict: "fail", error: error.message }));
2471
+ process.exitCode = 1;
2472
+ }
2473
+ }
2474
+
2475
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
2476
+ main();
2477
+ }