@open-agent-toolkit/cli 0.2.30 → 0.2.31

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 (72) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +41 -0
  3. package/assets/docs/contributing/code.md +19 -7
  4. package/assets/docs/contributing/explainer-kit-verification.md +9 -1
  5. package/assets/docs/contributing/skills.md +9 -0
  6. package/assets/docs/workflows/skills/explainer-kit-providers.md +16 -5
  7. package/assets/docs/workflows/skills/explainer-kit.md +98 -38
  8. package/assets/public-package-versions.json +4 -4
  9. package/assets/skills/explainer-kit/SKILL.md +33 -8
  10. package/assets/skills/explainer-kit/briefs/project-recap.md +25 -7
  11. package/assets/skills/explainer-kit/recipes/project-recap.v2.json +72 -0
  12. package/assets/skills/explainer-kit/references/contracts.md +49 -17
  13. package/assets/skills/explainer-kit/references/destination-contract.md +141 -25
  14. package/assets/skills/explainer-kit/references/extension-contract.md +19 -10
  15. package/assets/skills/explainer-kit/references/visual-authoring.md +24 -0
  16. package/assets/skills/explainer-kit/references/visual-review.md +19 -5
  17. package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +241 -0
  18. package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +215 -0
  19. package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +34 -0
  20. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -1
  21. package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +81 -0
  22. package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +66 -0
  23. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +109 -3
  24. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +550 -17
  25. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +90 -8
  26. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +5 -0
  27. package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +538 -0
  28. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +129 -11
  29. package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +54 -0
  30. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +2 -1
  31. package/assets/skills/explainer-kit/scripts/lib/records.mjs +139 -22
  32. package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +353 -0
  33. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +237 -107
  34. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +1 -0
  35. package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +157 -0
  36. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +26 -6
  37. package/assets/skills/explainer-kit/scripts/run.mjs +1006 -144
  38. package/assets/skills/oat-explainer-kit/SKILL.md +16 -3
  39. package/assets/skills/oat-explainer-kit/references/config-contract.md +13 -8
  40. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +50 -6
  41. package/assets/skills/oat-explainer-kit/references/migration.md +2 -1
  42. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +11 -0
  43. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +37 -15
  44. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +83 -0
  45. package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +91 -0
  46. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +66 -10
  47. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +60 -21
  48. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +52 -8
  49. package/assets/skills/oat-explainer-kit/scripts/run.mjs +271 -36
  50. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +2 -2
  51. package/assets/skills/oat-project-complete/SKILL.md +19 -3
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +2 -2
  53. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +8 -0
  54. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +2 -2
  55. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +2 -2
  56. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +2 -2
  57. package/dist/commands/config/index.d.ts.map +1 -1
  58. package/dist/commands/config/index.js +18 -0
  59. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  60. package/dist/commands/project/archive/archive-utils.js +57 -7
  61. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +29 -0
  62. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +1 -0
  63. package/dist/commands/project/archive/explainer-terminal-evidence.js +37 -0
  64. package/dist/config/oat-config.d.ts +2 -0
  65. package/dist/config/oat-config.d.ts.map +1 -1
  66. package/dist/config/oat-config.js +4 -0
  67. package/dist/config/resolve.d.ts.map +1 -1
  68. package/dist/config/resolve.js +1 -0
  69. package/package.json +2 -2
  70. /package/assets/skills/explainer-kit/recipes/{project-recap.json → project-recap.v1.json} +0 -0
  71. /package/assets/skills/explainer-kit/schemas/{publish-receipt.schema.json → publish-receipt.v1.schema.json} +0 -0
  72. /package/assets/skills/explainer-kit/schemas/{publish-request.schema.json → publish-request.v1.schema.json} +0 -0
@@ -1,6 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
 
4
+ import {
5
+ composePublicationTarget,
6
+ normalizePublishRoots,
7
+ } from './s3-roots.mjs';
4
8
  import { validatePortablePath } from './safe-paths.mjs';
5
9
  import {
6
10
  parseCanonicalGithubBlobUrl,
@@ -14,17 +18,24 @@ const SCHEMA_FILES = {
14
18
  manifest: 'manifest.schema.json',
15
19
  'build-record': 'build-record.schema.json',
16
20
  'durability-evidence': 'durability-evidence.schema.json',
17
- 'publish-request': 'publish-request.schema.json',
18
- 'publish-receipt': 'publish-receipt.schema.json',
21
+ 'publish-request/v1': 'publish-request.v1.schema.json',
22
+ 'publish-request/v2': 'publish-request.v2.schema.json',
23
+ 'publish-receipt/v1': 'publish-receipt.v1.schema.json',
24
+ 'publish-receipt/v2': 'publish-receipt.v2.schema.json',
19
25
  'author-request/v2': 'author-request.v2.schema.json',
26
+ 'author-request/v3': 'author-request.v3.schema.json',
20
27
  'author-result/v2': 'author-result.v2.schema.json',
21
28
  'set-plan': 'set-plan.v1.schema.json',
22
29
  'visual-review-request': 'visual-review-request.v1.schema.json',
23
30
  'visual-review-result': 'visual-review-result.v1.schema.json',
31
+ 'visual-review-evidence': 'visual-review-evidence.v1.schema.json',
32
+ 'terminal-evidence': 'terminal-evidence.v1.schema.json',
24
33
  };
25
34
  const DEFAULT_SCHEMA_KEYS = {
26
- 'author-request': 'author-request/v2',
35
+ 'author-request': 'author-request/v3',
27
36
  'author-result': 'author-result/v2',
37
+ 'publish-request': 'publish-request/v2',
38
+ 'publish-receipt': 'publish-receipt/v2',
28
39
  };
29
40
 
30
41
  const SCHEMAS = Object.fromEntries(
@@ -38,6 +49,34 @@ const SCHEMAS = Object.fromEntries(
38
49
  const SCHEMAS_BY_ID = new Map(
39
50
  Object.values(SCHEMAS).map((schema) => [schema.$id, schema]),
40
51
  );
52
+ // `validateContract` accepts a schema `$id` as its `kind`, but every downstream
53
+ // gate matches on the short registry form. `'explainer-kit.publish-request/v1'`
54
+ // does not start with `'publish-request'`, so an `$id`-form call skipped the
55
+ // default-deny publication-root gate entirely and answered `valid: true` for a
56
+ // credential-bearing root. Normalize once, here, so no individual gate has to
57
+ // remember to handle both spellings.
58
+ const KIND_BY_SCHEMA_ID = new Map(
59
+ Object.entries(SCHEMAS).map(([kind, schema]) => [schema.$id, kind]),
60
+ );
61
+
62
+ function canonicalContractKind(kind) {
63
+ return KIND_BY_SCHEMA_ID.get(kind) ?? kind;
64
+ }
65
+
66
+ const LEGACY_PUBLISH_RECEIPT = 'explainer-kit.publish-receipt/v1';
67
+
68
+ /**
69
+ * True for every publish-receipt shape except the retained v1 replay form.
70
+ *
71
+ * Stated as "not v1" rather than "=== v2" on purpose: keying verification to an
72
+ * exact version string means a future `publish-receipt/v3` silently skips
73
+ * sentinel verification and per-artifact validation, which is the same
74
+ * fail-open shape the publication-root gate was rewritten to eliminate. v1 is
75
+ * the only version that legitimately lacks these facts.
76
+ */
77
+ export function isVerifiablePublishReceipt(value) {
78
+ return value?.schemaVersion !== LEGACY_PUBLISH_RECEIPT;
79
+ }
41
80
  const RAW_SECRET_KEYS = new Set([
42
81
  'accesskey',
43
82
  'accesskeyid',
@@ -78,11 +117,15 @@ export function validateContract(kind, value, context = {}) {
78
117
  }
79
118
 
80
119
  const errors = [];
120
+ // Every gate below matches on the short registry form, so an `$id`-form kind
121
+ // is normalized before dispatch rather than in each gate.
122
+ const canonicalKind = canonicalContractKind(kind);
81
123
  findRawSecrets(value, '$', errors);
82
124
  validateSchema(schema, value, '$', schema, errors);
83
- validateContractPaths(kind, value, errors);
84
- validateCrossRecord(kind, value, context, errors);
85
- validateSourceBacklinks(kind, value, errors);
125
+ validateContractPaths(canonicalKind, value, errors);
126
+ validatePublicationRoots(canonicalKind, value, errors);
127
+ validateCrossRecord(canonicalKind, value, context, errors);
128
+ validateSourceBacklinks(canonicalKind, value, errors);
86
129
  return { valid: errors.length === 0, errors };
87
130
  }
88
131
 
@@ -438,6 +481,107 @@ function validateContractPaths(kind, value, errors) {
438
481
  }
439
482
  }
440
483
 
484
+ function validatePublicationRoots(kind, value, errors, path = '$') {
485
+ if (!isObject(value)) return;
486
+
487
+ if (kind === 'run-request') {
488
+ const publish = value.durability?.publish;
489
+ if (isObject(publish)) {
490
+ validatePublicationRoots(
491
+ 'publish-request',
492
+ publish,
493
+ errors,
494
+ `${path}.durability.publish`,
495
+ );
496
+ }
497
+ return;
498
+ }
499
+
500
+ // Default-deny: every publish-request shape is validated, regardless of its
501
+ // declared schemaVersion. Keying this gate to an exact version string is what
502
+ // let `publish-request/v1` reach `initializeRun` with credential-bearing roots,
503
+ // and would let any future version reintroduce the same bypass.
504
+ if (kind.startsWith('publish-request')) {
505
+ try {
506
+ normalizePublishRoots(value.s3Uri, value.publicBaseUrl);
507
+ } catch {
508
+ add(
509
+ errors,
510
+ path,
511
+ 'publish-roots',
512
+ 'Publish request roots must be credential-free and canonical.',
513
+ );
514
+ }
515
+ return;
516
+ }
517
+
518
+ if (kind.startsWith('publish-receipt')) {
519
+ // Same default-deny rule as the request branch above: every receipt shape
520
+ // has its roots screened, whatever version it declares. Pinning this to the
521
+ // exact v2 string let a `publish-receipt/v1` carry credential-bearing and
522
+ // internal-address roots straight through to `publish-receipt.json`, a
523
+ // retained hash-covered member of the run package, and on into the
524
+ // `publish-summary/v1` projection.
525
+ const receiptV2 = isVerifiablePublishReceipt(value);
526
+ let roots;
527
+ try {
528
+ roots = normalizePublishRoots(
529
+ value.roots?.s3Uri,
530
+ value.roots?.publicBaseUrl,
531
+ );
532
+ // The stricter *canonical-form* assertion stays v2-only: v1 is retained
533
+ // purely for replay and may legitimately carry non-canonical historical
534
+ // forms such as trailing slashes. Credential and address screening above
535
+ // applies to both, because no replay need justifies either.
536
+ if (
537
+ receiptV2 &&
538
+ (roots.s3Uri !== value.roots.s3Uri ||
539
+ roots.publicBaseUrl !== value.roots.publicBaseUrl)
540
+ ) {
541
+ throw new Error('noncanonical');
542
+ }
543
+ } catch {
544
+ add(
545
+ errors,
546
+ `${path}.roots`,
547
+ 'publish-roots',
548
+ 'Publish receipt roots must be credential-free and canonical.',
549
+ );
550
+ return;
551
+ }
552
+
553
+ if (!receiptV2) return;
554
+
555
+ for (const [index, artifact] of (Array.isArray(value.artifacts)
556
+ ? value.artifacts
557
+ : []
558
+ ).entries()) {
559
+ if (!isObject(artifact) || typeof artifact.relativePath !== 'string') {
560
+ continue;
561
+ }
562
+ const publishPath = artifact.relativePath.startsWith('site/')
563
+ ? artifact.relativePath.slice('site/'.length)
564
+ : artifact.relativePath;
565
+ try {
566
+ const expected = composePublicationTarget(publishPath, roots);
567
+ if (
568
+ artifact.s3Uri !== expected.s3Uri ||
569
+ artifact.publicUrl !== expected.publicUrl
570
+ ) {
571
+ throw new Error('destination mismatch');
572
+ }
573
+ } catch {
574
+ add(
575
+ errors,
576
+ `${path}.artifacts[${index}]`,
577
+ 'receipt-artifact-destination',
578
+ 'Publish receipt artifact destinations must be canonical children of the validated roots.',
579
+ );
580
+ }
581
+ }
582
+ }
583
+ }
584
+
441
585
  function addLexicalPathErrors(value, path, errors, allowAbsolute) {
442
586
  if (value === undefined) {
443
587
  return;
@@ -547,12 +691,20 @@ function validateCrossRecord(kind, value, context, errors) {
547
691
  }
548
692
 
549
693
  if (
550
- ['author-request', 'author-request/v2'].includes(kind) ||
551
- value.schemaVersion === 'explainer-kit.author-request/v2'
694
+ ['author-request', 'author-request/v2', 'author-request/v3'].includes(
695
+ kind,
696
+ ) ||
697
+ [
698
+ 'explainer-kit.author-request/v2',
699
+ 'explainer-kit.author-request/v3',
700
+ ].includes(value.schemaVersion)
552
701
  ) {
553
702
  validateAuthorSetContext(value, errors);
554
703
  validateVisualAuthoringGuidance(value, errors);
555
704
  validateAuthorGraphSemantics(value, errors);
705
+ if (value.schemaVersion === 'explainer-kit.author-request/v3') {
706
+ validateAuthorArtifactLinks(value, errors);
707
+ }
556
708
  }
557
709
 
558
710
  if (kind === 'visual-review-request') {
@@ -787,6 +939,19 @@ function validateCrossRecord(kind, value, context, errors) {
787
939
  }
788
940
  }
789
941
 
942
+ if (kind === 'terminal-evidence') {
943
+ validateTerminalEvidence(value, context.manifest, errors);
944
+ }
945
+
946
+ if (kind === 'visual-review-evidence') {
947
+ validateVisualReviewEvidence(
948
+ value,
949
+ context.visualReviewRequest,
950
+ context.attempt,
951
+ errors,
952
+ );
953
+ }
954
+
790
955
  if (kind === 'manifest') {
791
956
  const paths = [];
792
957
  for (const artifact of Array.isArray(value.artifacts)
@@ -972,6 +1137,34 @@ function validateCrossRecord(kind, value, context, errors) {
972
1137
  }
973
1138
  }
974
1139
 
1140
+ if (kind.startsWith('publish-receipt') && isVerifiablePublishReceipt(value)) {
1141
+ const sentinel = value.sentinel;
1142
+ if (
1143
+ sentinel?.objectVerification?.status !== 'verified' ||
1144
+ sentinel.objectVerification.hash === undefined
1145
+ ) {
1146
+ add(
1147
+ errors,
1148
+ '$.sentinel.objectVerification',
1149
+ 'receipt-object-verification',
1150
+ 'Publish receipt sentinel requires exact authenticated object verification.',
1151
+ );
1152
+ }
1153
+ const validPublic =
1154
+ value.publicAccess === 'public'
1155
+ ? sentinel?.publicVerification?.status === 'verified' &&
1156
+ sentinel.publicVerification.hash === sentinel.objectVerification?.hash
1157
+ : sentinel?.publicVerification?.status === 'skipped-protected';
1158
+ if (!validPublic) {
1159
+ add(
1160
+ errors,
1161
+ '$.sentinel.publicVerification',
1162
+ 'receipt-public-verification',
1163
+ 'Publish receipt sentinel public verification must match the declared public-access mode.',
1164
+ );
1165
+ }
1166
+ }
1167
+
975
1168
  if (
976
1169
  kind === 'publish-receipt' &&
977
1170
  isObject(context.manifest) &&
@@ -984,24 +1177,25 @@ function validateCrossRecord(kind, value, context, errors) {
984
1177
  artifact.status === 'built' &&
985
1178
  typeof artifact.renderedPath === 'string'
986
1179
  ) {
987
- expected.set(artifact.renderedPath, artifact.hash);
1180
+ expected.set(artifact.renderedPath, {
1181
+ hash: artifact.hash,
1182
+ source: { kind: 'manifest', artifactId: artifact.id },
1183
+ });
988
1184
  }
989
1185
  }
990
1186
  if (
991
1187
  isObject(context.catalogArtifact) &&
992
1188
  typeof context.catalogArtifact.relativePath === 'string'
993
1189
  ) {
994
- expected.set(
995
- context.catalogArtifact.relativePath,
996
- context.catalogArtifact.hash,
997
- );
1190
+ expected.set(context.catalogArtifact.relativePath, {
1191
+ hash: context.catalogArtifact.hash,
1192
+ source: { kind: 'auxiliary', name: 'catalog' },
1193
+ });
998
1194
  }
999
1195
  const received = new Set();
1000
1196
  for (const artifact of value.artifacts) {
1001
- if (
1002
- isObject(artifact) &&
1003
- expected.get(artifact.relativePath) !== artifact.hash
1004
- ) {
1197
+ const expectedArtifact = expected.get(artifact?.relativePath);
1198
+ if (isObject(artifact) && expectedArtifact?.hash !== artifact.hash) {
1005
1199
  add(
1006
1200
  errors,
1007
1201
  '$.artifacts',
@@ -1009,6 +1203,14 @@ function validateCrossRecord(kind, value, context, errors) {
1009
1203
  'Publish receipt artifact does not match the manifest.',
1010
1204
  );
1011
1205
  }
1206
+ if (isVerifiablePublishReceipt(value) && isObject(artifact)) {
1207
+ validatePublishReceiptV2Artifact(
1208
+ value,
1209
+ artifact,
1210
+ expectedArtifact,
1211
+ errors,
1212
+ );
1213
+ }
1012
1214
  if (received.has(artifact?.relativePath)) {
1013
1215
  add(
1014
1216
  errors,
@@ -1033,6 +1235,259 @@ function validateCrossRecord(kind, value, context, errors) {
1033
1235
  }
1034
1236
  }
1035
1237
 
1238
+ function validateTerminalEvidence(value, manifest, errors) {
1239
+ const reasons = Array.isArray(value.reasons) ? value.reasons : [];
1240
+ const artifactIds = isObject(manifest)
1241
+ ? new Set(
1242
+ (Array.isArray(manifest.artifacts) ? manifest.artifacts : []).map(
1243
+ ({ id }) => id,
1244
+ ),
1245
+ )
1246
+ : null;
1247
+ validateReasonSet(reasons, artifactIds, '$.reasons', errors);
1248
+
1249
+ if (isObject(manifest)) {
1250
+ if (value.runId !== manifest.runId) {
1251
+ add(
1252
+ errors,
1253
+ '$.runId',
1254
+ 'terminal-run-mismatch',
1255
+ 'Terminal evidence run identity must match its manifest.',
1256
+ );
1257
+ }
1258
+ if (value.outcome !== manifest.outcome) {
1259
+ add(
1260
+ errors,
1261
+ '$.outcome',
1262
+ 'terminal-outcome-mismatch',
1263
+ 'Terminal evidence outcome must match its manifest.',
1264
+ );
1265
+ }
1266
+ if (value.manifestHash !== canonicalHash(manifest)) {
1267
+ add(
1268
+ errors,
1269
+ '$.manifestHash',
1270
+ 'terminal-manifest-mismatch',
1271
+ 'Terminal evidence manifest hash must match its manifest.',
1272
+ );
1273
+ }
1274
+ }
1275
+
1276
+ if (value.evidenceDisposition === 'superseded') {
1277
+ const reason = reasons[0];
1278
+ if (
1279
+ reasons.length !== 1 ||
1280
+ reason?.stage !== 'finalization' ||
1281
+ reason?.kind !== 'superseded' ||
1282
+ reason?.count !== 1 ||
1283
+ reason?.artifactId !== undefined
1284
+ ) {
1285
+ add(
1286
+ errors,
1287
+ '$.reasons',
1288
+ 'supersession-reason',
1289
+ 'Superseded evidence requires exactly one count-1 finalization/superseded reason without an artifact ID.',
1290
+ );
1291
+ }
1292
+ if (
1293
+ !isObject(value.supersededBy) ||
1294
+ value.supersededBy.runId === value.runId
1295
+ ) {
1296
+ add(
1297
+ errors,
1298
+ '$.supersededBy',
1299
+ 'supersession-binding',
1300
+ 'Superseded evidence requires a distinct replacement run identity.',
1301
+ );
1302
+ }
1303
+ if (typeof value.manifestHash !== 'string') {
1304
+ add(
1305
+ errors,
1306
+ '$.manifestHash',
1307
+ 'supersession-manifest',
1308
+ 'Superseded evidence requires the original manifest hash.',
1309
+ );
1310
+ }
1311
+ return;
1312
+ }
1313
+
1314
+ if (value.supersededBy !== undefined) {
1315
+ add(
1316
+ errors,
1317
+ '$.supersededBy',
1318
+ 'unexpected-supersession',
1319
+ 'Only superseded evidence may identify a replacement run.',
1320
+ );
1321
+ }
1322
+ if (reasons.some((reason) => reason?.kind === 'superseded')) {
1323
+ add(
1324
+ errors,
1325
+ '$.reasons',
1326
+ 'unexpected-supersession',
1327
+ 'Only superseded evidence may contain a superseded reason.',
1328
+ );
1329
+ }
1330
+ const allowedKinds =
1331
+ value.outcome === 'failed'
1332
+ ? new Set(['provider-failure', 'pipeline-failure'])
1333
+ : new Set(['finding', 'provider-failure', 'pipeline-failure']);
1334
+ if (!reasons.some((reason) => allowedKinds.has(reason?.kind))) {
1335
+ add(
1336
+ errors,
1337
+ '$.reasons',
1338
+ 'terminal-outcome-reasons',
1339
+ 'Terminal evidence reasons do not satisfy the terminal outcome.',
1340
+ );
1341
+ }
1342
+ }
1343
+
1344
+ function validateVisualReviewEvidence(value, request, attempt, errors) {
1345
+ if (!isObject(request)) {
1346
+ add(
1347
+ errors,
1348
+ '$',
1349
+ 'review-request-required',
1350
+ 'Retained visual evidence requires its adjacent reviewed request.',
1351
+ );
1352
+ return;
1353
+ }
1354
+ const requestValidation = validateContract('visual-review-request', request);
1355
+ if (!requestValidation.valid) {
1356
+ add(
1357
+ errors,
1358
+ '$',
1359
+ 'invalid-review-request',
1360
+ 'Retained visual evidence cannot bind to an invalid reviewed request.',
1361
+ );
1362
+ }
1363
+ if (value.requestHash !== request.requestHash) {
1364
+ add(
1365
+ errors,
1366
+ '$.requestHash',
1367
+ 'review-binding-mismatch',
1368
+ 'Retained visual evidence must bind the adjacent request hash.',
1369
+ );
1370
+ }
1371
+ if (![1, 2].includes(attempt) || value.attempt !== attempt) {
1372
+ add(
1373
+ errors,
1374
+ '$.attempt',
1375
+ 'review-attempt-mismatch',
1376
+ 'Retained visual evidence attempt must match its attempt directory.',
1377
+ );
1378
+ }
1379
+
1380
+ const reasons = Array.isArray(value.reasons) ? value.reasons : [];
1381
+ const artifactIds = new Set(
1382
+ (Array.isArray(request.renderedArtifacts)
1383
+ ? request.renderedArtifacts
1384
+ : []
1385
+ ).map(({ artifactId }) => artifactId),
1386
+ );
1387
+ validateReasonSet(reasons, artifactIds, '$.reasons', errors);
1388
+ if (reasons.some((reason) => reason?.stage !== 'visual-review')) {
1389
+ add(
1390
+ errors,
1391
+ '$.reasons',
1392
+ 'visual-reason-stage',
1393
+ 'Retained visual evidence reasons must use the visual-review stage.',
1394
+ );
1395
+ }
1396
+ const validDisposition =
1397
+ (value.disposition === 'pass' && reasons.length === 0) ||
1398
+ (value.disposition === 'correct' &&
1399
+ reasons.length > 0 &&
1400
+ reasons.every((reason) => reason?.kind === 'finding')) ||
1401
+ (value.disposition === 'failed' &&
1402
+ reasons.length > 0 &&
1403
+ reasons.every((reason) =>
1404
+ ['provider-failure', 'pipeline-failure'].includes(reason?.kind),
1405
+ ));
1406
+ if (!validDisposition) {
1407
+ add(
1408
+ errors,
1409
+ '$.reasons',
1410
+ 'visual-disposition-reasons',
1411
+ 'Retained visual evidence reasons do not satisfy its disposition.',
1412
+ );
1413
+ }
1414
+ }
1415
+
1416
+ function validateReasonSet(reasons, artifactIds, path, errors) {
1417
+ let total = 0;
1418
+ const tuples = new Set();
1419
+ for (const [index, reason] of reasons.entries()) {
1420
+ if (!isObject(reason)) continue;
1421
+ if (Number.isInteger(reason.count)) total += reason.count;
1422
+ const tuple = `${reason.stage}\0${reason.kind}\0${reason.artifactId ?? ''}`;
1423
+ if (tuples.has(tuple)) {
1424
+ add(
1425
+ errors,
1426
+ `${path}[${index}]`,
1427
+ 'duplicate-reason',
1428
+ 'Retained reason tuples must be unique.',
1429
+ );
1430
+ }
1431
+ tuples.add(tuple);
1432
+ if (
1433
+ reason.artifactId !== undefined &&
1434
+ artifactIds &&
1435
+ !artifactIds.has(reason.artifactId)
1436
+ ) {
1437
+ add(
1438
+ errors,
1439
+ `${path}[${index}].artifactId`,
1440
+ 'foreign-artifact',
1441
+ 'Retained reason artifact IDs must belong to the bound run.',
1442
+ );
1443
+ }
1444
+ }
1445
+ if (total > 50) {
1446
+ add(
1447
+ errors,
1448
+ path,
1449
+ 'reason-total',
1450
+ 'Retained reason counts must total at most 50.',
1451
+ );
1452
+ }
1453
+ }
1454
+
1455
+ function validatePublishReceiptV2Artifact(receipt, artifact, expected, errors) {
1456
+ if (!expected || !deepEqual(artifact.source, expected.source)) {
1457
+ add(
1458
+ errors,
1459
+ '$.artifacts',
1460
+ 'receipt-artifact-source',
1461
+ 'Publish receipt source identity does not match the manifest or generated auxiliary object.',
1462
+ );
1463
+ }
1464
+ if (
1465
+ artifact.objectVerification?.status !== 'verified' ||
1466
+ artifact.objectVerification?.hash !== artifact.hash
1467
+ ) {
1468
+ add(
1469
+ errors,
1470
+ '$.artifacts',
1471
+ 'receipt-object-verification',
1472
+ 'Publish receipt object verification must prove the exact artifact hash.',
1473
+ );
1474
+ }
1475
+ const publicVerification = artifact.publicVerification;
1476
+ const validPublic =
1477
+ receipt.publicAccess === 'public'
1478
+ ? publicVerification?.status === 'verified' &&
1479
+ publicVerification?.hash === artifact.hash
1480
+ : publicVerification?.status === 'skipped-protected';
1481
+ if (!validPublic) {
1482
+ add(
1483
+ errors,
1484
+ '$.artifacts',
1485
+ 'receipt-public-verification',
1486
+ 'Publish receipt public verification must match the declared public-access mode.',
1487
+ );
1488
+ }
1489
+ }
1490
+
1036
1491
  export function visualReviewRequestPayload(request) {
1037
1492
  const {
1038
1493
  requestId: _requestId,
@@ -1175,6 +1630,84 @@ function validateAuthorSetContext(value, errors) {
1175
1630
  }
1176
1631
  }
1177
1632
 
1633
+ function validateAuthorArtifactLinks(value, errors) {
1634
+ if (!Array.isArray(value.artifactLinks) || !isObject(value.setContext)) {
1635
+ return;
1636
+ }
1637
+ const planned = Array.isArray(value.setContext.portfolio)
1638
+ ? value.setContext.portfolio
1639
+ : [];
1640
+ const linksById = new Map();
1641
+ for (const [index, link] of value.artifactLinks.entries()) {
1642
+ if (!isObject(link)) continue;
1643
+ if (linksById.has(link.artifactId)) {
1644
+ add(
1645
+ errors,
1646
+ `$.artifactLinks[${index}].artifactId`,
1647
+ 'artifact-link-parity',
1648
+ 'Canonical artifact link IDs must be unique.',
1649
+ );
1650
+ }
1651
+ linksById.set(link.artifactId, link);
1652
+ }
1653
+ if (
1654
+ linksById.size !== planned.length ||
1655
+ planned.some(
1656
+ ({ artifactId, artifactType }) =>
1657
+ linksById.get(artifactId)?.artifactType !== artifactType,
1658
+ )
1659
+ ) {
1660
+ add(
1661
+ errors,
1662
+ '$.artifactLinks',
1663
+ 'artifact-link-parity',
1664
+ 'Canonical artifact links must exactly cover the planned portfolio.',
1665
+ );
1666
+ return;
1667
+ }
1668
+
1669
+ const current = linksById.get(value.artifactId);
1670
+ if (!current || typeof current.sitePath !== 'string') return;
1671
+ const base = new URL(current.sitePath, 'https://explainer.invalid/');
1672
+ for (const [index, link] of value.artifactLinks.entries()) {
1673
+ if (
1674
+ !isObject(link) ||
1675
+ typeof link.href !== 'string' ||
1676
+ typeof link.sitePath !== 'string'
1677
+ ) {
1678
+ continue;
1679
+ }
1680
+ let resolved;
1681
+ try {
1682
+ resolved = new URL(link.href, base);
1683
+ } catch {
1684
+ add(
1685
+ errors,
1686
+ `$.artifactLinks[${index}].href`,
1687
+ 'artifact-link-resolution',
1688
+ 'Canonical artifact href must be a valid relative reference.',
1689
+ );
1690
+ continue;
1691
+ }
1692
+ if (
1693
+ resolved.origin !== base.origin ||
1694
+ resolved.protocol !== 'https:' ||
1695
+ resolved.username ||
1696
+ resolved.password ||
1697
+ resolved.search ||
1698
+ resolved.hash ||
1699
+ resolved.pathname !== `/${link.sitePath}`
1700
+ ) {
1701
+ add(
1702
+ errors,
1703
+ `$.artifactLinks[${index}].href`,
1704
+ 'artifact-link-resolution',
1705
+ 'Canonical artifact href must resolve exactly to its declared site path.',
1706
+ );
1707
+ }
1708
+ }
1709
+ }
1710
+
1178
1711
  function validateVisualAuthoringGuidance(value, errors) {
1179
1712
  if (typeof value.visualAuthoringGuidance !== 'string') {
1180
1713
  return;