@open-agent-toolkit/cli 0.2.30 → 0.2.32

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 (107) 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/provider-sync/commands.md +15 -10
  7. package/assets/docs/provider-sync/manifest-and-drift.md +23 -0
  8. package/assets/docs/provider-sync/providers.md +8 -2
  9. package/assets/docs/provider-sync/scope-and-surface.md +3 -1
  10. package/assets/docs/reference/file-locations.md +5 -0
  11. package/assets/docs/reference/troubleshooting.md +18 -0
  12. package/assets/docs/workflows/skills/explainer-kit-providers.md +16 -5
  13. package/assets/docs/workflows/skills/explainer-kit.md +98 -38
  14. package/assets/public-package-versions.json +4 -4
  15. package/assets/skills/create-agnostic-skill/references/docs/skills-guide.md +34 -22
  16. package/assets/skills/explainer-kit/SKILL.md +33 -8
  17. package/assets/skills/explainer-kit/briefs/project-recap.md +25 -7
  18. package/assets/skills/explainer-kit/recipes/project-recap.v2.json +72 -0
  19. package/assets/skills/explainer-kit/references/contracts.md +49 -17
  20. package/assets/skills/explainer-kit/references/destination-contract.md +141 -25
  21. package/assets/skills/explainer-kit/references/extension-contract.md +19 -10
  22. package/assets/skills/explainer-kit/references/visual-authoring.md +24 -0
  23. package/assets/skills/explainer-kit/references/visual-review.md +19 -5
  24. package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +241 -0
  25. package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +215 -0
  26. package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +34 -0
  27. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -1
  28. package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +81 -0
  29. package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +66 -0
  30. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +109 -3
  31. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +550 -17
  32. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +90 -8
  33. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +5 -0
  34. package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +538 -0
  35. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +129 -11
  36. package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +54 -0
  37. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +2 -1
  38. package/assets/skills/explainer-kit/scripts/lib/records.mjs +139 -22
  39. package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +353 -0
  40. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +237 -107
  41. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +1 -0
  42. package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +157 -0
  43. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +26 -6
  44. package/assets/skills/explainer-kit/scripts/run.mjs +1006 -144
  45. package/assets/skills/oat-agent-instructions-analyze/references/docs/provider-reference.md +14 -2
  46. package/assets/skills/oat-agent-instructions-apply/references/docs/provider-reference.md +14 -2
  47. package/assets/skills/oat-explainer-kit/SKILL.md +16 -3
  48. package/assets/skills/oat-explainer-kit/references/config-contract.md +13 -8
  49. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +50 -6
  50. package/assets/skills/oat-explainer-kit/references/migration.md +2 -1
  51. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +11 -0
  52. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +37 -15
  53. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +83 -0
  54. package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +91 -0
  55. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +66 -10
  56. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +60 -21
  57. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +52 -8
  58. package/assets/skills/oat-explainer-kit/scripts/run.mjs +271 -36
  59. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +2 -2
  60. package/assets/skills/oat-project-complete/SKILL.md +19 -3
  61. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +2 -2
  62. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +8 -0
  63. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +2 -2
  64. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +2 -2
  65. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +2 -2
  66. package/dist/commands/config/index.d.ts.map +1 -1
  67. package/dist/commands/config/index.js +18 -0
  68. package/dist/commands/init/index.d.ts +2 -2
  69. package/dist/commands/init/index.d.ts.map +1 -1
  70. package/dist/commands/init/index.js +22 -13
  71. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  72. package/dist/commands/project/archive/archive-utils.js +57 -7
  73. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +29 -0
  74. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +1 -0
  75. package/dist/commands/project/archive/explainer-terminal-evidence.js +37 -0
  76. package/dist/commands/remove/skill/remove-skill.d.ts +4 -1
  77. package/dist/commands/remove/skill/remove-skill.d.ts.map +1 -1
  78. package/dist/commands/remove/skill/remove-skill.js +44 -5
  79. package/dist/commands/shared/adopt-stray.d.ts +5 -0
  80. package/dist/commands/shared/adopt-stray.d.ts.map +1 -1
  81. package/dist/commands/shared/adopt-stray.js +25 -4
  82. package/dist/commands/shared/native-skill-disposition.d.ts +22 -0
  83. package/dist/commands/shared/native-skill-disposition.d.ts.map +1 -0
  84. package/dist/commands/shared/native-skill-disposition.js +60 -0
  85. package/dist/commands/status/index.d.ts +2 -2
  86. package/dist/commands/status/index.d.ts.map +1 -1
  87. package/dist/commands/status/index.js +22 -13
  88. package/dist/config/oat-config.d.ts +2 -0
  89. package/dist/config/oat-config.d.ts.map +1 -1
  90. package/dist/config/oat-config.js +4 -0
  91. package/dist/config/resolve.d.ts.map +1 -1
  92. package/dist/config/resolve.js +1 -0
  93. package/dist/engine/compute-plan.d.ts +3 -2
  94. package/dist/engine/compute-plan.d.ts.map +1 -1
  95. package/dist/engine/compute-plan.js +1 -1
  96. package/dist/engine/index.d.ts +1 -1
  97. package/dist/engine/index.d.ts.map +1 -1
  98. package/dist/engine/index.js +1 -1
  99. package/dist/providers/copilot/paths.d.ts.map +1 -1
  100. package/dist/providers/copilot/paths.js +6 -4
  101. package/package.json +2 -2
  102. package/dist/commands/shared/cursor-skill-disposition.d.ts +0 -17
  103. package/dist/commands/shared/cursor-skill-disposition.d.ts.map +0 -1
  104. package/dist/commands/shared/cursor-skill-disposition.js +0 -41
  105. /package/assets/skills/explainer-kit/recipes/{project-recap.json → project-recap.v1.json} +0 -0
  106. /package/assets/skills/explainer-kit/schemas/{publish-receipt.schema.json → publish-receipt.v1.schema.json} +0 -0
  107. /package/assets/skills/explainer-kit/schemas/{publish-request.schema.json → publish-request.v1.schema.json} +0 -0
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { lstat, readdir, realpath, rm } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
2
4
 
3
5
  import { browserCaptureIdentity } from './browser-runtime.mjs';
4
6
  import { canonicalHash, validateContract } from './contracts.mjs';
@@ -17,6 +19,94 @@ const REVIEW_VIEWPORTS = Object.freeze(['mobile', 'tablet', 'desktop']);
17
19
  const SUCCESSFUL_OUTCOMES = new Set(['built-not-durable', 'built-durable']);
18
20
  const PARTIAL_REVIEW_OUTCOME = 'built-needs-review';
19
21
 
22
+ export function permissibleRunPackagePaths(
23
+ manifest,
24
+ { includeManifest = true, includeTerminalEvidence = false } = {},
25
+ ) {
26
+ if (!isObject(manifest) || !isObject(manifest.immutableHashes)) {
27
+ throw new TypeError(
28
+ 'Run package inventory requires a manifest with immutable hashes.',
29
+ );
30
+ }
31
+ const paths = new Set([
32
+ ...Object.keys(manifest.immutableHashes),
33
+ 'build-record.json',
34
+ ...(includeManifest ? ['manifest.json'] : []),
35
+ ...(includeTerminalEvidence ? ['terminal-evidence.json'] : []),
36
+ ]);
37
+ for (const path of paths) assertInventoryPath(path);
38
+ return [...paths].sort();
39
+ }
40
+
41
+ export async function enforceRunPackageInventory(
42
+ runRoot,
43
+ manifest,
44
+ {
45
+ includeManifest = true,
46
+ includeTerminalEvidence = false,
47
+ removeUnexpected = false,
48
+ } = {},
49
+ ) {
50
+ if (typeof runRoot !== 'string' || runRoot.length === 0) {
51
+ throw new TypeError('Run package inventory requires a run root.');
52
+ }
53
+ const rootStats = await lstat(runRoot);
54
+ const canonicalRoot = await realpath(runRoot);
55
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
56
+ throw new Error('Run package inventory root is not a real directory.');
57
+ }
58
+
59
+ const allowedFiles = new Set(
60
+ permissibleRunPackagePaths(manifest, {
61
+ includeManifest,
62
+ includeTerminalEvidence,
63
+ }),
64
+ );
65
+ const allowedDirectories = new Set();
66
+ for (const path of allowedFiles) {
67
+ let parent = dirname(path);
68
+ while (parent !== '.') {
69
+ allowedDirectories.add(parent);
70
+ parent = dirname(parent);
71
+ }
72
+ }
73
+
74
+ const presentFiles = new Set();
75
+ let unexpected = false;
76
+ async function inspect(relativeRoot = '') {
77
+ const entries = await readdir(join(canonicalRoot, relativeRoot));
78
+ for (const entry of entries) {
79
+ const relativePath = relativeRoot ? `${relativeRoot}/${entry}` : entry;
80
+ const absolutePath = join(canonicalRoot, relativePath);
81
+ const stats = await lstat(absolutePath);
82
+ const allowed =
83
+ (stats.isDirectory() && allowedDirectories.has(relativePath)) ||
84
+ (stats.isFile() && allowedFiles.has(relativePath));
85
+ if (stats.isSymbolicLink() || !allowed) {
86
+ unexpected = true;
87
+ if (removeUnexpected) {
88
+ await rm(absolutePath, { recursive: true, force: true });
89
+ }
90
+ continue;
91
+ }
92
+ if (stats.isDirectory()) {
93
+ await inspect(relativePath);
94
+ } else {
95
+ presentFiles.add(relativePath);
96
+ }
97
+ }
98
+ }
99
+ await inspect();
100
+
101
+ const missing = [...allowedFiles].some((path) => !presentFiles.has(path));
102
+ if (unexpected || missing) {
103
+ throw new Error(
104
+ 'Run package inventory does not match the exact permissible tree.',
105
+ );
106
+ }
107
+ return [...presentFiles].sort();
108
+ }
109
+
20
110
  export function requiredImmutablePackagePaths(manifest, { runMode } = {}) {
21
111
  if (!isObject(manifest)) {
22
112
  throw new TypeError(
@@ -93,6 +183,11 @@ export async function validateImmutablePackageEvidence(
93
183
  if (manifest?.recipe?.id !== 'project-recap') return;
94
184
 
95
185
  const recorded = Object.keys(manifest.immutableHashes ?? {});
186
+ if (recorded.some(isLegacyReviewMaterial)) {
187
+ throw new Error(
188
+ 'Legacy review-gate evidence is not part of the canonical package.',
189
+ );
190
+ }
96
191
  const successful = SUCCESSFUL_OUTCOMES.has(manifest.outcome);
97
192
  const retainsReviewMaterial = recorded.some(isReviewMaterial);
98
193
  if (manifest.outcome === PARTIAL_REVIEW_OUTCOME) return;
@@ -274,12 +369,17 @@ export async function validateImmutablePackageEvidence(
274
369
 
275
370
  const resultPath = `${root}/result.json`;
276
371
  const result = parseJson(await readVerified(resultPath), resultPath);
277
- const resultValidation = validateContract('visual-review-result', result, {
278
- visualReviewRequest: request,
279
- });
372
+ const resultValidation = validateContract(
373
+ 'visual-review-evidence',
374
+ result,
375
+ {
376
+ visualReviewRequest: request,
377
+ attempt,
378
+ },
379
+ );
280
380
  if (!resultValidation.valid) {
281
381
  throw new Error(
282
- `Immutable visual-review result ${resultPath} is invalid: ${resultValidation.errors
382
+ `Immutable visual-review evidence ${resultPath} is invalid: ${resultValidation.errors
283
383
  .map(({ code }) => code)
284
384
  .join(', ')}.`,
285
385
  );
@@ -309,18 +409,17 @@ function addVisualReviewAttemptPaths(required, manifest, attempt) {
309
409
  }
310
410
 
311
411
  function isReviewMaterial(path) {
312
- return (
313
- path.startsWith('qa/browser/') ||
314
- path.startsWith('qa/visual-review/') ||
315
- path.startsWith('qa/review-gate/')
316
- );
412
+ return path.startsWith('qa/browser/') || path.startsWith('qa/visual-review/');
413
+ }
414
+
415
+ function isLegacyReviewMaterial(path) {
416
+ return path.startsWith('qa/review-gate/');
317
417
  }
318
418
 
319
419
  function isAttemptTwoMaterial(path) {
320
420
  return (
321
421
  path === VISUAL_REVISION_PATH ||
322
- path.startsWith('qa/visual-review/attempt-2/') ||
323
- path.startsWith('qa/review-gate/attempt-2')
422
+ path.startsWith('qa/visual-review/attempt-2/')
324
423
  );
325
424
  }
326
425
 
@@ -328,6 +427,25 @@ function isObject(value) {
328
427
  return value !== null && typeof value === 'object' && !Array.isArray(value);
329
428
  }
330
429
 
430
+ function assertInventoryPath(path) {
431
+ if (
432
+ typeof path !== 'string' ||
433
+ path.length === 0 ||
434
+ path.includes('\\') ||
435
+ isAbsolute(path) ||
436
+ path
437
+ .split('/')
438
+ .some(
439
+ (segment) => segment === '' || segment === '.' || segment === '..',
440
+ ) ||
441
+ relative('.', path).startsWith(`..${sep}`)
442
+ ) {
443
+ throw new TypeError(
444
+ 'Run package inventory paths must be normalized relative paths.',
445
+ );
446
+ }
447
+ }
448
+
331
449
  function isPng(bytes) {
332
450
  return (
333
451
  bytes.length >= 8 &&
@@ -0,0 +1,54 @@
1
+ const SUCCESSFUL_STAGE_STATUSES = new Set(['passed', 'warned', 'skipped']);
2
+
3
+ export function assertManifestPublishable(manifest, { buildRecord } = {}) {
4
+ const outcome = manifest?.outcome;
5
+ const reviewFlagged = (manifest?.warnings ?? []).some((warning) =>
6
+ String(warning).startsWith('visual-review-required:'),
7
+ );
8
+ const durableClean = outcome === 'built-durable' && !reviewFlagged;
9
+ const inFlightClean =
10
+ outcome === 'incomplete' &&
11
+ !reviewFlagged &&
12
+ isCleanPublicationTransition(buildRecord);
13
+
14
+ if (!durableClean && !inFlightClean) {
15
+ throw publicationOutcomeError(
16
+ `Manifest outcome ${String(outcome ?? 'missing')} is not eligible for publication.`,
17
+ );
18
+ }
19
+ return manifest;
20
+ }
21
+
22
+ function isCleanPublicationTransition(buildRecord) {
23
+ if (
24
+ !buildRecord ||
25
+ buildRecord.outcome !== 'incomplete' ||
26
+ !Array.isArray(buildRecord.stages)
27
+ ) {
28
+ return false;
29
+ }
30
+ const publishIndex = buildRecord.stages.findIndex(
31
+ ({ id }) => id === 'publish',
32
+ );
33
+ if (
34
+ publishIndex < 0 ||
35
+ buildRecord.stages[publishIndex]?.status !== 'running'
36
+ ) {
37
+ return false;
38
+ }
39
+ return buildRecord.stages
40
+ .slice(0, publishIndex)
41
+ .every(
42
+ ({ status, warnings = [] }) =>
43
+ SUCCESSFUL_STAGE_STATUSES.has(status) &&
44
+ !warnings.some((warning) =>
45
+ String(warning).startsWith('visual-review-required:'),
46
+ ),
47
+ );
48
+ }
49
+
50
+ function publicationOutcomeError(message) {
51
+ const error = new Error(message);
52
+ error.code = 'E_PUBLISH_OUTCOME';
53
+ return error;
54
+ }
@@ -62,7 +62,8 @@ const AUTHORING_TYPES = new Set(['markdown', 'html']);
62
62
  const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
63
63
  const RECIPE_FILES = [
64
64
  'project-explainer.json',
65
- 'project-recap.json',
65
+ 'project-recap.v1.json',
66
+ 'project-recap.v2.json',
66
67
  'engineer-tour.json',
67
68
  'program-recap.json',
68
69
  ];
@@ -15,6 +15,11 @@ import {
15
15
  validateImmutablePackageEvidence,
16
16
  } from './package-coverage.mjs';
17
17
  import { resolveRootConfinedPath } from './safe-paths.mjs';
18
+ import {
19
+ assertTerminalEvidence,
20
+ createTerminalEvidence,
21
+ createVisualReviewEvidence,
22
+ } from './terminal-evidence.mjs';
18
23
 
19
24
  export {
20
25
  PACKAGE_COVERAGE_VERSION,
@@ -137,10 +142,10 @@ export async function updateBuildRecord(run, stage) {
137
142
  ...current,
138
143
  status: stage.status,
139
144
  outputPaths: stage.outputPaths ?? current.outputPaths,
140
- warnings: stage.warnings ?? current.warnings,
145
+ warnings: structuredClone(stage.warnings ?? current.warnings),
141
146
  };
142
147
  if (stage.error !== undefined) {
143
- next.error = stage.error;
148
+ next.error = localStageFailure(stage.id);
144
149
  }
145
150
  if (stage.status === 'running' && next.startedAt === undefined) {
146
151
  next.startedAt = timestamp;
@@ -202,9 +207,50 @@ export async function writeVisualReviewAttempt(run, { attempt, review } = {}) {
202
207
  'Visual review records must contain valid bound contracts.',
203
208
  );
204
209
  }
210
+ const retainedResult = createVisualReviewEvidence({
211
+ request: review.request,
212
+ attempt,
213
+ result: review.result,
214
+ });
215
+ return writeRetainedVisualReview(run, {
216
+ attempt,
217
+ request: review.request,
218
+ retainedResult,
219
+ });
220
+ }
221
+
222
+ export async function writeVisualReviewFailure(
223
+ run,
224
+ { attempt, request, kind = 'provider-failure' } = {},
225
+ ) {
226
+ assertRun(run);
227
+ if (
228
+ ![1, 2].includes(attempt) ||
229
+ !isObject(request) ||
230
+ !['provider-failure', 'pipeline-failure'].includes(kind)
231
+ ) {
232
+ throw new TypeError(
233
+ 'Visual review failures require a bound request, attempt, and local failure kind.',
234
+ );
235
+ }
236
+ const retainedResult = createVisualReviewEvidence({
237
+ request,
238
+ attempt,
239
+ failureKind: kind,
240
+ });
241
+ return writeRetainedVisualReview(run, {
242
+ attempt,
243
+ request,
244
+ retainedResult,
245
+ });
246
+ }
205
247
 
248
+ async function writeRetainedVisualReview(
249
+ run,
250
+ { attempt, request, retainedResult },
251
+ ) {
206
252
  const directory = `qa/visual-review/attempt-${attempt}`;
207
- const retainedRequest = structuredClone(review.request);
253
+ const retainedRequest = structuredClone(request);
208
254
  const paths = [];
209
255
  for (const artifact of retainedRequest.renderedArtifacts) {
210
256
  for (const evidence of artifact.evidence) {
@@ -228,36 +274,99 @@ export async function writeVisualReviewAttempt(run, { attempt, review } = {}) {
228
274
  const requestPath = `${directory}/request.json`;
229
275
  const resultPath = `${directory}/result.json`;
230
276
  await writeJsonAtomic(run.runRoot, requestPath, retainedRequest);
231
- await writeJsonAtomic(run.runRoot, resultPath, review.result);
277
+ await writeJsonAtomic(run.runRoot, resultPath, retainedResult);
232
278
  return [...paths, requestPath, resultPath];
233
279
  }
234
280
 
235
- export async function writeVisualReviewFailure(
281
+ export async function writeTerminalEvidence(run, input = {}) {
282
+ assertRun(run);
283
+ const allowed = new Set([
284
+ 'outcome',
285
+ 'manifest',
286
+ 'reasons',
287
+ 'evidenceDisposition',
288
+ ]);
289
+ const unsupported = Object.keys(input).filter((key) => !allowed.has(key));
290
+ if (unsupported.length > 0) {
291
+ throw new TypeError(
292
+ `Terminal evidence rejects legacy or diagnostic fields: ${unsupported.join(', ')}.`,
293
+ );
294
+ }
295
+ const { outcome, manifest, reasons, evidenceDisposition } = input;
296
+ if (
297
+ !['built-needs-review', 'failed'].includes(outcome) ||
298
+ !['retained', 'partial', 'superseded', 'unavailable'].includes(
299
+ evidenceDisposition,
300
+ ) ||
301
+ !Array.isArray(reasons) ||
302
+ reasons.some((reason) => !isObject(reason))
303
+ ) {
304
+ throw new TypeError('Terminal evidence has an invalid compact shape.');
305
+ }
306
+ if (
307
+ manifest !== undefined &&
308
+ (!isObject(manifest) ||
309
+ manifest.runId !== run.runId ||
310
+ manifest.slug !== run.slug)
311
+ ) {
312
+ throw new Error(
313
+ 'Terminal evidence manifest identity does not match the run.',
314
+ );
315
+ }
316
+ const path = 'terminal-evidence.json';
317
+ try {
318
+ await access(join(run.runRoot, path));
319
+ throw new Error('Terminal evidence is immutable once retained.');
320
+ } catch (caught) {
321
+ if (caught?.code !== 'ENOENT') throw caught;
322
+ }
323
+ const evidence = createTerminalEvidence({
324
+ runId: run.runId,
325
+ outcome,
326
+ manifest,
327
+ reasons,
328
+ evidenceDisposition,
329
+ });
330
+ await writeJsonAtomic(run.runRoot, path, evidence);
331
+ return path;
332
+ }
333
+
334
+ export async function supersedeTerminalEvidence(
236
335
  run,
237
- { attempt, error, evidence = [] } = {},
336
+ { manifest, supersededBy } = {},
238
337
  ) {
239
338
  assertRun(run);
240
339
  if (
241
- ![1, 2].includes(attempt) ||
242
- !(error instanceof Error) ||
243
- !Array.isArray(evidence)
340
+ !isObject(manifest) ||
341
+ manifest.runId !== run.runId ||
342
+ manifest.slug !== run.slug
244
343
  ) {
245
- throw new TypeError(
246
- 'Visual review failures require an attempt, Error, and evidence array.',
344
+ throw new Error(
345
+ 'Terminal evidence manifest identity does not match the run.',
247
346
  );
248
347
  }
249
- const path = `qa/review-gate/attempt-${attempt}-error.json`;
250
- await writeJsonAtomic(run.runRoot, path, {
251
- schemaVersion: 'explainer-kit.visual-review-error/v1',
252
- attempt,
253
- code: error.code ?? 'E_VISUAL_REVIEW',
254
- message: error.message,
255
- evidencePaths: evidence.flatMap(({ screenshotPath, metricsPath }) => [
256
- screenshotPath,
257
- metricsPath,
258
- ]),
348
+ const path = 'terminal-evidence.json';
349
+ const retained = JSON.parse(await readFile(join(run.runRoot, path), 'utf8'));
350
+ assertTerminalEvidence(retained, { manifest });
351
+ if (retained.evidenceDisposition === 'superseded') {
352
+ throw new Error('Terminal evidence is already superseded.');
353
+ }
354
+ const superseded = createTerminalEvidence({
355
+ runId: run.runId,
356
+ outcome: manifest.outcome,
357
+ manifest,
358
+ reasons: [
359
+ {
360
+ stage: 'finalization',
361
+ kind: 'superseded',
362
+ count: 1,
363
+ },
364
+ ],
365
+ evidenceDisposition: 'superseded',
366
+ supersededBy,
259
367
  });
260
- return [path];
368
+ await writeJsonAtomic(run.runRoot, path, superseded);
369
+ return path;
261
370
  }
262
371
 
263
372
  export async function writeVisualRevision(run, { artifactIds, changes } = {}) {
@@ -764,6 +873,14 @@ function assertValidContract(kind, value, context) {
764
873
  }
765
874
  }
766
875
 
876
+ function localStageFailure(stageId) {
877
+ return {
878
+ code: `E_${stageId.toUpperCase().replaceAll('-', '_')}`,
879
+ message: `The ${stageId} stage failed.`,
880
+ recovery: [`Correct the ${stageId} stage and start a new run.`],
881
+ };
882
+ }
883
+
767
884
  function assertRun(run) {
768
885
  if (
769
886
  !isObject(run) ||