@open-agent-toolkit/cli 0.2.29 → 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 (87) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +52 -1
  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/projects/artifacts.md +5 -0
  7. package/assets/docs/workflows/projects/index.md +1 -0
  8. package/assets/docs/workflows/projects/lifecycle.md +23 -0
  9. package/assets/docs/workflows/projects/retro.md +261 -0
  10. package/assets/docs/workflows/skills/explainer-kit-providers.md +16 -5
  11. package/assets/docs/workflows/skills/explainer-kit.md +98 -38
  12. package/assets/docs/workflows/skills/index.md +6 -0
  13. package/assets/public-package-versions.json +4 -4
  14. package/assets/skills/explainer-kit/SKILL.md +33 -8
  15. package/assets/skills/explainer-kit/briefs/project-recap.md +25 -7
  16. package/assets/skills/explainer-kit/recipes/project-recap.v2.json +72 -0
  17. package/assets/skills/explainer-kit/references/contracts.md +49 -17
  18. package/assets/skills/explainer-kit/references/destination-contract.md +141 -25
  19. package/assets/skills/explainer-kit/references/extension-contract.md +19 -10
  20. package/assets/skills/explainer-kit/references/visual-authoring.md +24 -0
  21. package/assets/skills/explainer-kit/references/visual-review.md +19 -5
  22. package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +241 -0
  23. package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +215 -0
  24. package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +34 -0
  25. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -1
  26. package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +81 -0
  27. package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +66 -0
  28. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +109 -3
  29. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +550 -17
  30. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +90 -8
  31. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +5 -0
  32. package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +538 -0
  33. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +129 -11
  34. package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +54 -0
  35. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +2 -1
  36. package/assets/skills/explainer-kit/scripts/lib/records.mjs +139 -22
  37. package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +353 -0
  38. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +237 -107
  39. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +1 -0
  40. package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +157 -0
  41. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +26 -6
  42. package/assets/skills/explainer-kit/scripts/run.mjs +1006 -144
  43. package/assets/skills/oat-explainer-kit/SKILL.md +16 -3
  44. package/assets/skills/oat-explainer-kit/references/config-contract.md +13 -8
  45. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +50 -6
  46. package/assets/skills/oat-explainer-kit/references/migration.md +2 -1
  47. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +11 -0
  48. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +37 -15
  49. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +83 -0
  50. package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +91 -0
  51. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +66 -10
  52. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +60 -21
  53. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +52 -8
  54. package/assets/skills/oat-explainer-kit/scripts/run.mjs +271 -36
  55. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +38 -38
  56. package/assets/skills/oat-project-complete/SKILL.md +60 -8
  57. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +38 -38
  58. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  59. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +28 -6
  60. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +38 -38
  61. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +38 -38
  62. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +38 -38
  63. package/assets/skills/oat-project-retro/SKILL.md +310 -0
  64. package/assets/skills/oat-project-retro/references/apply-procedure.md +212 -0
  65. package/assets/skills/oat-project-retro/references/evidence-and-lanes.md +91 -0
  66. package/assets/skills/oat-project-retro/references/retro-quality-bar.md +100 -0
  67. package/assets/skills/oat-project-retro-file/SKILL.md +387 -0
  68. package/assets/templates/project-retro.md +236 -0
  69. package/dist/commands/config/index.d.ts.map +1 -1
  70. package/dist/commands/config/index.js +99 -0
  71. package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
  72. package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
  73. package/dist/commands/init/tools/shared/skill-manifest.js +3 -0
  74. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  75. package/dist/commands/project/archive/archive-utils.js +57 -7
  76. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +29 -0
  77. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +1 -0
  78. package/dist/commands/project/archive/explainer-terminal-evidence.js +37 -0
  79. package/dist/config/oat-config.d.ts +14 -1
  80. package/dist/config/oat-config.d.ts.map +1 -1
  81. package/dist/config/oat-config.js +42 -2
  82. package/dist/config/resolve.d.ts.map +1 -1
  83. package/dist/config/resolve.js +9 -0
  84. package/package.json +2 -2
  85. /package/assets/skills/explainer-kit/recipes/{project-recap.json → project-recap.v1.json} +0 -0
  86. /package/assets/skills/explainer-kit/schemas/{publish-receipt.schema.json → publish-receipt.v1.schema.json} +0 -0
  87. /package/assets/skills/explainer-kit/schemas/{publish-request.schema.json → publish-request.v1.schema.json} +0 -0
@@ -2,11 +2,15 @@
2
2
 
3
3
  import { createHash } from 'node:crypto';
4
4
  import { lstat, readFile, realpath } from 'node:fs/promises';
5
- import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { assertBrowserProbeSession } from './lib/browser-runtime.mjs';
9
- import { catalogFromManifest, initiativeCatalogPath } from './lib/catalog.mjs';
9
+ import {
10
+ catalogFromManifest,
11
+ initiativeCatalogPath,
12
+ serializeInitiativeCatalog,
13
+ } from './lib/catalog.mjs';
10
14
  import {
11
15
  readContentApproval,
12
16
  resolveContentApproval,
@@ -20,7 +24,10 @@ import {
20
24
  import { processFactBase } from './lib/fact-base.mjs';
21
25
  import { writeJsonAtomic, writeTextAtomic } from './lib/fs-safe.mjs';
22
26
  import { validateHtmlSafety } from './lib/html-safety.mjs';
27
+ import { validateInternalReferences } from './lib/internal-references.mjs';
23
28
  import { parseMarkdown } from './lib/markdown.mjs';
29
+ import { enforceRunPackageInventory } from './lib/package-coverage.mjs';
30
+ import { assertManifestPublishable } from './lib/publication-policy.mjs';
24
31
  import {
25
32
  auditArtifactSet,
26
33
  checkGuidelines,
@@ -47,10 +54,12 @@ import {
47
54
  initializeRun,
48
55
  readSetPlanRecords,
49
56
  reopenBuildStages,
57
+ supersedeTerminalEvidence,
50
58
  updateBuildRecord,
51
59
  verifySetPlanResumeToken,
52
60
  writeManifestAtomic,
53
61
  writeSetPlanRecords,
62
+ writeTerminalEvidence,
54
63
  writeVisualReviewAttempt,
55
64
  writeVisualReviewFailure,
56
65
  writeVisualRevision,
@@ -58,6 +67,11 @@ import {
58
67
  import { artifactPath, renderArtifact } from './lib/render.mjs';
59
68
  import { resolveRootConfinedPath } from './lib/safe-paths.mjs';
60
69
  import { plannedArtifacts, planExplainerSet } from './lib/set-plan.mjs';
70
+ import {
71
+ createVisualReviewEvidence,
72
+ evidenceReason,
73
+ projectThrownReason,
74
+ } from './lib/terminal-evidence.mjs';
61
75
  import { resolveTheme } from './lib/theme.mjs';
62
76
  import { runVisualReview } from './lib/visual-review.mjs';
63
77
 
@@ -69,6 +83,7 @@ export {
69
83
 
70
84
  // Stages a rejected draft reruns once its content is corrected.
71
85
  const REOPENED_ON_REJECTION = Object.freeze(['render', 'qa']);
86
+ const LOCALLY_PROJECTED_ERRORS = new WeakSet();
72
87
 
73
88
  export async function runExplainer(request, options = {}) {
74
89
  const normalizedRequest = normalizeRunRequest(request);
@@ -124,6 +139,9 @@ export async function runExplainer(request, options = {}) {
124
139
  visualReviewPaths: [],
125
140
  visualReviewAttempt: 0,
126
141
  reviewGateBlocked: false,
142
+ reviewGateReason: null,
143
+ correctionAttempted: false,
144
+ publishReceiptPath: null,
127
145
  resumeToken: null,
128
146
  resumedApprovalStatus: null,
129
147
  };
@@ -143,7 +161,11 @@ export async function runExplainer(request, options = {}) {
143
161
  now,
144
162
  );
145
163
  state.factBase = processed.factBase;
146
- state.warnings.push(...processed.checks.warnings);
164
+ const factBaseWarnings =
165
+ processed.checks.warnings.length > 0
166
+ ? ['fact-base-freshness-warning']
167
+ : [];
168
+ state.warnings.push(...factBaseWarnings);
147
169
  state.inputHashes = inputHashes(processed.factBase);
148
170
  state.factBaseHash = canonicalHash(processed.factBase);
149
171
  await writeJsonAtomic(
@@ -158,8 +180,8 @@ export async function runExplainer(request, options = {}) {
158
180
  );
159
181
  return {
160
182
  outputPaths: ['source/fact-base.json', 'source/fact-base.md'],
161
- warnings: processed.checks.warnings,
162
- status: processed.checks.warnings.length > 0 ? 'warned' : 'passed',
183
+ warnings: factBaseWarnings,
184
+ status: factBaseWarnings.length > 0 ? 'warned' : 'passed',
163
185
  };
164
186
  });
165
187
  await prepareTheme(state);
@@ -229,13 +251,27 @@ export async function runExplainer(request, options = {}) {
229
251
  if (run.request.mode === 'interactive') {
230
252
  state.resumeToken = await createSetPlanResumeToken(run);
231
253
  }
254
+ const manifest = await inventoryManifestFor(state, now());
255
+ await enforceRetainedRunPackage(state, manifest, {
256
+ includeManifest: false,
257
+ failureStage: 'qa',
258
+ });
232
259
  return resultFor(state);
233
260
  }
234
261
 
235
262
  if (state.reviewGateBlocked) {
236
263
  await updateBuildRecord(run, { id: 'durability', status: 'skipped' });
237
264
  await updateBuildRecord(run, { id: 'publish', status: 'skipped' });
238
- await persistManifest(state, now());
265
+ const manifest = await persistManifest(state, now());
266
+ await enforceRetainedRunPackage(state, manifest, {
267
+ acceptCleanedUnexpected: true,
268
+ });
269
+ await writeTerminalEvidence(run, {
270
+ outcome: 'built-needs-review',
271
+ manifest,
272
+ reasons: terminalReasonsForReview(state),
273
+ evidenceDisposition: state.visualReview ? 'retained' : 'partial',
274
+ });
239
275
  return resultFor(state);
240
276
  }
241
277
 
@@ -245,17 +281,119 @@ export async function runExplainer(request, options = {}) {
245
281
  now,
246
282
  );
247
283
  if (!manifestFinalized) {
248
- await persistManifest(state, now());
284
+ const manifest = await persistManifest(state, now());
285
+ await enforceRetainedRunPackage(state, manifest, {
286
+ failureStage: 'qa',
287
+ });
288
+ } else {
289
+ const manifest = JSON.parse(
290
+ await readFile(state.run.manifestPath, 'utf8'),
291
+ );
292
+ await enforceRetainedRunPackage(state, manifest, {
293
+ failureStage: 'qa',
294
+ });
249
295
  }
250
296
  return resultFor(state);
251
297
  } catch (error) {
298
+ let manifest;
252
299
  if (state.theme && state.factBase) {
253
300
  await persistFailureManifest(state, error, now()).catch(() => {});
301
+ manifest = await readFile(state.run.manifestPath, 'utf8')
302
+ .then(JSON.parse)
303
+ .catch(() => undefined);
304
+ }
305
+ const reason =
306
+ localEvidenceReason(error) ??
307
+ projectThrownReason('finalization', 'pipeline-failure');
308
+ // The inventory verdict is reached *before* terminal evidence is written,
309
+ // so the single write below carries the complete reason set. Terminal
310
+ // evidence is immutable once retained (`records.mjs` refuses a second
311
+ // write unless the path is absent), so a re-record after the fact could
312
+ // never succeed; the previous shape swallowed that guaranteed failure in a
313
+ // `.catch(() => {})` under a comment claiming the durable evidence was
314
+ // updated. `includeTerminalEvidence` is correspondingly dropped here,
315
+ // because the file legitimately does not exist yet at this point.
316
+ let reasons = [reason];
317
+ if (manifest) {
318
+ try {
319
+ await enforceRetainedRunPackage(state, manifest);
320
+ } catch (inventoryError) {
321
+ // This used to be swallowed outright. It must not simply be reported
322
+ // either: a failed run routinely leaves partial outputs, and the first
323
+ // pass removes them and *then* throws, so the common case is a
324
+ // successful repair rather than a violated invariant. Re-running
325
+ // separates the two — if the tree is clean now, the repair worked and
326
+ // there is nothing to report; if it still fails, the violation is
327
+ // genuinely unremovable or a required file is missing, which is the
328
+ // case that previously went unreported at run time.
329
+ const repaired = await enforceRetainedRunPackage(state, manifest).then(
330
+ () => true,
331
+ () => false,
332
+ );
333
+ if (!repaired) {
334
+ reasons = mergeTerminalReasons(
335
+ reasons,
336
+ localEvidenceReason(inventoryError) ??
337
+ projectThrownReason('finalization', 'pipeline-failure'),
338
+ );
339
+ }
340
+ }
254
341
  }
255
- return resultFor(state, error);
342
+ await writeTerminalEvidence(run, {
343
+ outcome: 'failed',
344
+ manifest,
345
+ reasons,
346
+ evidenceDisposition: manifest ? 'retained' : 'unavailable',
347
+ });
348
+ return resultFor(state, { failed: true, reasons });
256
349
  }
257
350
  }
258
351
 
352
+ export async function supersedeExplainerRun({ runRoot, supersededBy } = {}) {
353
+ if (typeof runRoot !== 'string') {
354
+ throw new TypeError('Supersession requires a runRoot.');
355
+ }
356
+ const confinedRunRoot = await realpath(runRoot);
357
+ const manifest = JSON.parse(
358
+ await readFile(join(confinedRunRoot, 'manifest.json'), 'utf8'),
359
+ );
360
+ if (
361
+ manifest?.schemaVersion !== 'explainer-kit.manifest/v1' ||
362
+ typeof manifest.runId !== 'string' ||
363
+ typeof manifest.slug !== 'string' ||
364
+ !['built-needs-review', 'failed'].includes(manifest.outcome)
365
+ ) {
366
+ throw new Error(
367
+ 'Only a flagged or failed manifest can produce supersession evidence.',
368
+ );
369
+ }
370
+ if (
371
+ !supersededBy ||
372
+ typeof supersededBy !== 'object' ||
373
+ typeof supersededBy.runId !== 'string' ||
374
+ supersededBy.runId === manifest.runId ||
375
+ !/^sha256:[a-f0-9]{64}$/.test(supersededBy.manifestHash ?? '')
376
+ ) {
377
+ throw new Error(
378
+ 'Supersession requires a distinct replacement run ID and manifest hash.',
379
+ );
380
+ }
381
+ await supersedeTerminalEvidence(
382
+ {
383
+ runId: manifest.runId,
384
+ slug: manifest.slug,
385
+ runRoot: confinedRunRoot,
386
+ },
387
+ { manifest, supersededBy },
388
+ );
389
+ return {
390
+ runId: manifest.runId,
391
+ outcome: manifest.outcome,
392
+ terminalEvidencePath: join(confinedRunRoot, 'terminal-evidence.json'),
393
+ supersededBy: structuredClone(supersededBy),
394
+ };
395
+ }
396
+
259
397
  async function executeThemeStage(state, options) {
260
398
  await executeStage(state.run, 'theme', options, async () => {
261
399
  await writeJsonAtomic(
@@ -275,8 +413,9 @@ async function prepareTheme(state) {
275
413
  const resolved = await resolveTheme(state.run.request.theme);
276
414
  state.theme = resolved.theme;
277
415
  state.renderStrategy = resolved.renderStrategy;
278
- state.themeWarnings = resolved.warnings;
279
- state.warnings.push(...resolved.warnings);
416
+ state.themeWarnings =
417
+ resolved.warnings.length > 0 ? ['theme-selection-normalized'] : [];
418
+ state.warnings.push(...state.themeWarnings);
280
419
  }
281
420
 
282
421
  async function executeRenderStage(state, options) {
@@ -341,14 +480,10 @@ async function executeQaStage(state, options, now) {
341
480
  }
342
481
  const warning = reviewGateWarning(reviewError);
343
482
  state.reviewGateBlocked = true;
483
+ state.reviewGateReason =
484
+ localEvidenceReason(reviewError) ??
485
+ evidenceReason('browser-review', 'pipeline-failure');
344
486
  state.warnings.push(warning);
345
- state.visualReviewPaths.push(
346
- ...(await writeVisualReviewFailure(state.run, {
347
- attempt: state.visualReviewAttempt || 1,
348
- error: reviewError,
349
- evidence: state.browserEvidence,
350
- })),
351
- );
352
487
  return {
353
488
  outputPaths: [
354
489
  ...state.rendered.map(({ renderedPath }) => renderedPath),
@@ -412,10 +547,11 @@ function resolveBrowserProvider(request, recipe, options) {
412
547
  probe: async (...args) => {
413
548
  try {
414
549
  return await options.browserProbe(...args);
415
- } catch (error) {
416
- throw codedError(
417
- 'E_VISUAL_REVIEW',
418
- `Browser evidence callback failed: ${error?.message ?? String(error)}`,
550
+ } catch {
551
+ throw withEvidenceReason(
552
+ codedError('E_VISUAL_REVIEW', 'Browser evidence callback failed.'),
553
+ 'browser-review',
554
+ 'provider-failure',
419
555
  );
420
556
  }
421
557
  },
@@ -429,6 +565,7 @@ async function auditRenderedArtifacts(
429
565
  now,
430
566
  { browserProvider, htmlSafetyErrors, qaWarnings },
431
567
  ) {
568
+ await enforceInternalReferenceGate(state, options, now);
432
569
  for (const artifact of state.resolvedArtifacts.filter(
433
570
  ({ authoring }) => authoring === 'html',
434
571
  )) {
@@ -451,22 +588,34 @@ async function auditRenderedArtifacts(
451
588
  type: artifact.type,
452
589
  html: artifact.html,
453
590
  }));
454
- const report = await auditArtifactSet({
455
- artifacts: probeArtifacts,
456
- setPlan: state.setPlan,
457
- ...(options.denylist && { denylist: options.denylist }),
458
- ...(browserProvider?.session && {
459
- browserSession: browserProvider.session,
460
- }),
461
- ...(browserProvider?.probe && { browserProbe: browserProvider.probe }),
462
- ...(options.widths &&
463
- !requiresRecapVisualReview(state) && { widths: options.widths }),
464
- ...(browserProvider &&
465
- requiresRecapVisualReview(state) && {
466
- evidenceRoot: state.run.runRoot,
467
- requireBrowserEvidence: true,
591
+ let report;
592
+ try {
593
+ report = await auditArtifactSet({
594
+ artifacts: probeArtifacts,
595
+ setPlan: state.setPlan,
596
+ ...(options.denylist && { denylist: options.denylist }),
597
+ ...(browserProvider?.session && {
598
+ browserSession: browserProvider.session,
468
599
  }),
469
- });
600
+ ...(browserProvider?.probe && { browserProbe: browserProvider.probe }),
601
+ ...(options.widths &&
602
+ !requiresRecapVisualReview(state) && { widths: options.widths }),
603
+ ...(browserProvider &&
604
+ requiresRecapVisualReview(state) && {
605
+ evidenceRoot: state.run.runRoot,
606
+ requireBrowserEvidence: true,
607
+ }),
608
+ });
609
+ } catch (error) {
610
+ if (requiresRecapVisualReview(state)) {
611
+ throw withEvidenceReason(
612
+ codedError('E_VISUAL_REVIEW', 'Browser review failed.'),
613
+ 'browser-review',
614
+ 'provider-failure',
615
+ );
616
+ }
617
+ throw error;
618
+ }
470
619
  const hardIssues = report.issues.filter((issue) => isHardQaIssue(issue.code));
471
620
  const warningIssues = report.issues.filter(
472
621
  (issue) => !isHardQaIssue(issue.code),
@@ -508,28 +657,50 @@ async function auditRenderedArtifacts(
508
657
  } else if (visualCritic) {
509
658
  await reviewAndRetain(state, visualCritic, 1);
510
659
  if (state.visualReview.result.disposition === 'correct') {
660
+ if (state.correctionAttempted) {
661
+ throw codedError(
662
+ 'E_VISUAL_CORRECTION',
663
+ 'The one bounded artifact correction was already consumed by internal-reference validation.',
664
+ );
665
+ }
666
+ state.correctionAttempted = true;
511
667
  await applyVisualCorrection(state, options, now);
668
+ const correctedReferences = validateRenderedInternalReferences(state);
669
+ if (!correctedReferences.valid) {
670
+ throw internalReferenceError(correctedReferences.errors);
671
+ }
512
672
  const correctedArtifacts = state.rendered.map((artifact) => ({
513
673
  id: artifact.artifactId,
514
674
  type: artifact.type,
515
675
  html: artifact.html,
516
676
  }));
517
- const finalReport = await auditArtifactSet({
518
- artifacts: correctedArtifacts,
519
- setPlan: state.setPlan,
520
- ...(options.denylist && { denylist: options.denylist }),
521
- ...(browserProvider?.session && {
522
- browserSession: browserProvider.session,
523
- }),
524
- ...(browserProvider?.probe && { browserProbe: browserProvider.probe }),
525
- ...(options.widths &&
526
- !requiresRecapVisualReview(state) && { widths: options.widths }),
527
- ...(browserProvider &&
528
- requiresRecapVisualReview(state) && {
529
- evidenceRoot: state.run.runRoot,
530
- requireBrowserEvidence: true,
677
+ let finalReport;
678
+ try {
679
+ finalReport = await auditArtifactSet({
680
+ artifacts: correctedArtifacts,
681
+ setPlan: state.setPlan,
682
+ ...(options.denylist && { denylist: options.denylist }),
683
+ ...(browserProvider?.session && {
684
+ browserSession: browserProvider.session,
531
685
  }),
532
- });
686
+ ...(browserProvider?.probe && {
687
+ browserProbe: browserProvider.probe,
688
+ }),
689
+ ...(options.widths &&
690
+ !requiresRecapVisualReview(state) && { widths: options.widths }),
691
+ ...(browserProvider &&
692
+ requiresRecapVisualReview(state) && {
693
+ evidenceRoot: state.run.runRoot,
694
+ requireBrowserEvidence: true,
695
+ }),
696
+ });
697
+ } catch {
698
+ throw withEvidenceReason(
699
+ codedError('E_VISUAL_REVIEW', 'Corrected browser review failed.'),
700
+ 'browser-review',
701
+ 'provider-failure',
702
+ );
703
+ }
533
704
  const finalHardIssues = finalReport.issues.filter((issue) =>
534
705
  isHardQaIssue(issue.code),
535
706
  );
@@ -586,6 +757,162 @@ async function auditRenderedArtifacts(
586
757
  };
587
758
  }
588
759
 
760
+ async function enforceInternalReferenceGate(state, options, now) {
761
+ const validate = () => validateRenderedInternalReferences(state);
762
+ const initial = validate();
763
+ if (initial.valid) return;
764
+
765
+ const correctionAuthor = options.correctArtifact ?? options.author;
766
+ if (typeof correctionAuthor !== 'function') {
767
+ throw internalReferenceError(initial.errors);
768
+ }
769
+ const trust = authorTrustContext(options, now);
770
+ const artifactIds = [
771
+ ...new Set(
772
+ initial.errors
773
+ .map(({ artifactId }) => artifactId)
774
+ .filter((artifactId) => typeof artifactId === 'string'),
775
+ ),
776
+ ];
777
+ if (artifactIds.length === 0) {
778
+ throw internalReferenceError(initial.errors);
779
+ }
780
+ state.correctionAttempted = true;
781
+ for (const artifactId of artifactIds) {
782
+ const artifactIndex = state.resolvedArtifacts.findIndex(
783
+ ({ id }) => id === artifactId,
784
+ );
785
+ if (artifactIndex < 0) {
786
+ throw internalReferenceError(initial.errors);
787
+ }
788
+ const artifact = state.resolvedArtifacts[artifactIndex];
789
+ let item;
790
+ try {
791
+ item = await authorArtifact(
792
+ state,
793
+ artifact,
794
+ correctionAuthor,
795
+ trust,
796
+ canonicalArtifactLinks(
797
+ state.resolvedArtifacts,
798
+ artifact.id,
799
+ state.run.slug,
800
+ ),
801
+ {
802
+ attempt: 1,
803
+ reason: 'internal-reference-validation',
804
+ findings: structuredClone(
805
+ initial.errors.filter(
806
+ ({ artifactId: findingArtifactId }) =>
807
+ findingArtifactId === artifactId,
808
+ ),
809
+ ),
810
+ previousContentPath: state.contentPaths.get(artifactId),
811
+ },
812
+ );
813
+ } catch (error) {
814
+ throw withEvidenceReason(
815
+ codedError(
816
+ 'E_INTERNAL_REFERENCE',
817
+ `Internal-reference correction failed for ${artifactId}: ${safeMessage(error)}`,
818
+ ),
819
+ 'link-validation',
820
+ 'pipeline-failure',
821
+ );
822
+ }
823
+ await installCorrectedArtifact(state, artifactIndex, item);
824
+ }
825
+
826
+ const final = validate();
827
+ if (!final.valid) {
828
+ throw internalReferenceError(final.errors);
829
+ }
830
+ }
831
+
832
+ function validateRenderedInternalReferences(state) {
833
+ return validateInternalReferences({
834
+ artifacts: state.rendered.map((artifact) => ({
835
+ artifactId: artifact.artifactId,
836
+ renderedPath: artifact.renderedPath,
837
+ html: artifact.html,
838
+ })),
839
+ manifestPaths: state.artifacts.map(({ renderedPath }) => renderedPath),
840
+ });
841
+ }
842
+
843
+ async function installCorrectedArtifact(state, artifactIndex, item) {
844
+ const artifactId = item.artifact.id;
845
+ await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
846
+ await writeTextAtomic(state.run.runRoot, item.contentPath, item.content);
847
+ state.resolvedArtifacts[artifactIndex] = item.artifact;
848
+ state.authoredContent.set(artifactId, item.content);
849
+ state.contentPaths.set(artifactId, item.contentPath);
850
+ if (item.artifact.authoring === 'markdown') {
851
+ const links = expansionLinks(state.resolvedArtifacts);
852
+ const model = assertValidContentModel(
853
+ state.recipe,
854
+ markdownContentModel(
855
+ item.artifact,
856
+ state.run.slug,
857
+ item.content,
858
+ item.artifact.origin === 'floor' ? links : [],
859
+ ),
860
+ item.artifact,
861
+ );
862
+ const modelIndex = state.contentModels.findIndex(
863
+ ({ artifactId: id }) => id === artifactId,
864
+ );
865
+ state.contentModels[modelIndex] = model;
866
+ }
867
+ const rendered =
868
+ item.artifact.authoring === 'markdown'
869
+ ? await renderArtifact({
870
+ recipeArtifact: renderDescriptor(item.artifact),
871
+ content: state.contentModels.find(
872
+ ({ artifactId: id }) => id === artifactId,
873
+ ),
874
+ factBase: state.factBase,
875
+ theme: state.theme,
876
+ renderStrategy: state.renderStrategy,
877
+ ...(state.run.request.publicBaseUrl && {
878
+ publicBaseUrl: state.run.request.publicBaseUrl,
879
+ }),
880
+ })
881
+ : artisticRender(state, item.artifact);
882
+ await writeTextAtomic(
883
+ state.run.runRoot,
884
+ rendered.renderedPath,
885
+ rendered.html,
886
+ );
887
+ const renderedIndex = state.rendered.findIndex(
888
+ ({ artifactId: id }) => id === artifactId,
889
+ );
890
+ state.rendered[renderedIndex] = rendered;
891
+ state.artifacts[renderedIndex] = artifactRecord(state, rendered);
892
+ }
893
+
894
+ function internalReferenceError(errors) {
895
+ // `link-validation` is one of the eight closed evidence stages and is
896
+ // accepted by both evidence schemas, but the gate runs inside the `qa` build
897
+ // stage, and `evidenceStageForBuildStage` maps `qa -> browser-review`. Without
898
+ // an explicit local reason a broken internal link is durably recorded as a
899
+ // browser-review failure, which is the one thing the closed stage enum exists
900
+ // to prevent. Fail-closed behavior is unchanged; only attribution improves.
901
+ return withEvidenceReason(
902
+ codedError(
903
+ 'E_INTERNAL_REFERENCE',
904
+ errors
905
+ .map(
906
+ ({ code, renderedPath, reference, message }) =>
907
+ `${code}: ${renderedPath ?? 'site'}${reference ? ` references ${reference}` : ''}: ${message}`,
908
+ )
909
+ .join('; '),
910
+ ),
911
+ 'link-validation',
912
+ 'pipeline-failure',
913
+ );
914
+ }
915
+
589
916
  function resolveVisualCritic(options) {
590
917
  if (options.visualCritic === undefined) return null;
591
918
  if (typeof options.visualCritic !== 'function') {
@@ -616,13 +943,21 @@ function requiresRecapVisualReview(state) {
616
943
  async function reviewAndRetain(state, visualCritic, attempt) {
617
944
  state.visualReviewAttempt = attempt;
618
945
  try {
619
- state.visualReview = await runVisualReview({
946
+ const review = await runVisualReview({
620
947
  plan: state.setPlan,
621
948
  rendered: state.rendered,
622
949
  evidence: state.browserEvidence,
623
950
  visualCritic,
624
951
  runRoot: state.run.runRoot,
625
952
  });
953
+ state.visualReview = {
954
+ ...review,
955
+ evidence: createVisualReviewEvidence({
956
+ request: review.request,
957
+ attempt,
958
+ result: review.result,
959
+ }),
960
+ };
626
961
  state.visualReviewPaths.push(
627
962
  ...(await writeVisualReviewAttempt(state.run, {
628
963
  attempt,
@@ -630,7 +965,39 @@ async function reviewAndRetain(state, visualCritic, attempt) {
630
965
  })),
631
966
  );
632
967
  } catch (error) {
633
- throw codedError('E_VISUAL_REVIEW', error?.message ?? String(error));
968
+ if (error?.visualReviewRequest) {
969
+ const kind = ['provider-failure', 'pipeline-failure'].includes(
970
+ error.evidenceKind,
971
+ )
972
+ ? error.evidenceKind
973
+ : 'pipeline-failure';
974
+ const evidence = createVisualReviewEvidence({
975
+ request: error.visualReviewRequest,
976
+ attempt,
977
+ failureKind: kind,
978
+ });
979
+ state.visualReview = {
980
+ request: structuredClone(error.visualReviewRequest),
981
+ evidence,
982
+ };
983
+ state.visualReviewPaths.push(
984
+ ...(await writeVisualReviewFailure(state.run, {
985
+ attempt,
986
+ request: error.visualReviewRequest,
987
+ kind,
988
+ })),
989
+ );
990
+ throw withEvidenceReason(
991
+ codedError('E_VISUAL_REVIEW', 'Visual review failed.'),
992
+ 'visual-review',
993
+ kind,
994
+ );
995
+ }
996
+ throw withEvidenceReason(
997
+ codedError('E_VISUAL_REVIEW', 'Visual review pipeline failed.'),
998
+ 'visual-review',
999
+ 'pipeline-failure',
1000
+ );
634
1001
  }
635
1002
  }
636
1003
 
@@ -662,17 +1029,29 @@ async function applyVisualCorrection(state, options, now) {
662
1029
  const previousContent = state.authoredContent.get(artifactId);
663
1030
  let item;
664
1031
  try {
665
- item = await authorArtifact(state, artifact, correctionAuthor, trust, {
666
- attempt: 1,
667
- findings: structuredClone(
668
- findings.filter((finding) => finding.artifactId === artifactId),
1032
+ item = await authorArtifact(
1033
+ state,
1034
+ artifact,
1035
+ correctionAuthor,
1036
+ trust,
1037
+ canonicalArtifactLinks(
1038
+ state.resolvedArtifacts,
1039
+ artifact.id,
1040
+ state.run.slug,
669
1041
  ),
670
- previousContentPath: state.contentPaths.get(artifactId),
671
- });
672
- } catch (error) {
673
- throw codedError(
674
- 'E_VISUAL_CORRECTION',
675
- `Visual correction callback failed for ${artifactId}: ${error?.message ?? String(error)}`,
1042
+ {
1043
+ attempt: 1,
1044
+ findings: structuredClone(
1045
+ findings.filter((finding) => finding.artifactId === artifactId),
1046
+ ),
1047
+ previousContentPath: state.contentPaths.get(artifactId),
1048
+ },
1049
+ );
1050
+ } catch {
1051
+ throw withEvidenceReason(
1052
+ codedError('E_VISUAL_CORRECTION', 'Visual correction provider failed.'),
1053
+ 'visual-review',
1054
+ 'provider-failure',
676
1055
  );
677
1056
  }
678
1057
  if ((item.result.proposedArtifacts ?? []).length > 0) {
@@ -772,9 +1151,9 @@ function isReviewGateError(error) {
772
1151
 
773
1152
  function normalizeReviewGateError(state, error) {
774
1153
  if (!requiresRecapVisualReview(state)) return null;
775
- if (isReviewGateError(error)) return error;
776
- if (/^Browser (?:layout |theme |deck )?probe\b/.test(error?.message ?? '')) {
777
- return codedError('E_VISUAL_REVIEW', error.message);
1154
+ if (isReviewGateError(error)) {
1155
+ if (localEvidenceReason(error)) return error;
1156
+ return withEvidenceReason(error, 'visual-review', 'pipeline-failure');
778
1157
  }
779
1158
  return null;
780
1159
  }
@@ -783,8 +1162,10 @@ function reviewGateWarning(error) {
783
1162
  const reason =
784
1163
  error?.code === 'E_VISUAL_CORRECTION'
785
1164
  ? 'correction-failed'
786
- : 'review-chain-failed';
787
- return `visual-review-required:${reason}:${String(error?.message ?? 'unknown visual review failure')}`;
1165
+ : localEvidenceReason(error)?.stage === 'browser-review'
1166
+ ? 'browser-review-failed'
1167
+ : 'review-chain-failed';
1168
+ return `visual-review-required:${reason}`;
788
1169
  }
789
1170
 
790
1171
  function artisticRender(state, artifact) {
@@ -1124,23 +1505,22 @@ async function readJson(path) {
1124
1505
  export async function runExplainerCli(
1125
1506
  argv = process.argv.slice(2),
1126
1507
  io = console,
1508
+ run = runExplainer,
1127
1509
  ) {
1128
1510
  try {
1129
1511
  const parsed = await parseCli(argv);
1130
1512
  const request = JSON.parse(await readFile(parsed.requestPath, 'utf8'));
1131
- const result = await runExplainer(request, parsed.options);
1132
- io.log(JSON.stringify(result, null, 2));
1513
+ const result = await run(request, parsed.options);
1514
+ io.log(JSON.stringify(projectCliRunResult(result), null, 2));
1133
1515
  return result.outcome === 'failed' ? 1 : 0;
1134
1516
  } catch (error) {
1135
- io.log(
1517
+ io.error(
1136
1518
  JSON.stringify(
1137
1519
  {
1138
1520
  outcome: 'failed',
1139
- errors: [
1140
- {
1141
- code: error.code ?? 'E_INPUT_SCHEMA',
1142
- message: safeMessage(error),
1143
- },
1521
+ reasons: [
1522
+ localEvidenceReason(error) ??
1523
+ evidenceReason('planning', 'pipeline-failure'),
1144
1524
  ],
1145
1525
  },
1146
1526
  null,
@@ -1151,6 +1531,191 @@ export async function runExplainerCli(
1151
1531
  }
1152
1532
  }
1153
1533
 
1534
+ function projectCliRunResult(result) {
1535
+ if (!isObject(result)) {
1536
+ return {
1537
+ outcome: 'failed',
1538
+ reasons: [evidenceReason('finalization', 'pipeline-failure')],
1539
+ };
1540
+ }
1541
+ const projected = {};
1542
+ for (const key of [
1543
+ 'runId',
1544
+ 'runRoot',
1545
+ 'manifestPath',
1546
+ 'buildRecordPath',
1547
+ 'outcome',
1548
+ 'marking',
1549
+ ]) {
1550
+ if (typeof result[key] === 'string') projected[key] = result[key];
1551
+ }
1552
+ if (Array.isArray(result.warnings)) {
1553
+ projected.warnings = retainedWarnings(result.warnings);
1554
+ }
1555
+ if (isObject(result.discovery)) {
1556
+ projected.discovery = {
1557
+ ...(Number.isInteger(result.discovery.rounds) && {
1558
+ rounds: result.discovery.rounds,
1559
+ }),
1560
+ ...(Number.isInteger(result.discovery.findingCount) && {
1561
+ findingCount: result.discovery.findingCount,
1562
+ }),
1563
+ ...(['not-requested', 'two-empty-rounds', 'hard-maximum'].includes(
1564
+ result.discovery.reason,
1565
+ ) && { reason: result.discovery.reason }),
1566
+ };
1567
+ }
1568
+ if (isObject(result.approval)) {
1569
+ projected.approval = pickStringFields(result.approval, [
1570
+ 'status',
1571
+ 'path',
1572
+ 'marking',
1573
+ 'resumeToken',
1574
+ ]);
1575
+ }
1576
+ const reasons = projectEvidenceReasons(result.reasons);
1577
+ if (reasons.length > 0) projected.reasons = reasons;
1578
+ const visualReview = projectVisualReviewEvidence(result.visualReview);
1579
+ if (visualReview) projected.visualReview = visualReview;
1580
+ const publication = projectPublicationSummaryForCli(result.publication);
1581
+ if (publication) projected.publication = publication;
1582
+ return projected;
1583
+ }
1584
+
1585
+ function projectVisualReviewEvidence(value) {
1586
+ if (!isObject(value)) return null;
1587
+ const reasons = projectEvidenceReasons(value.reasons);
1588
+ if (
1589
+ value.schemaVersion !== 'explainer-kit.visual-review-evidence/v1' ||
1590
+ typeof value.requestHash !== 'string' ||
1591
+ ![1, 2].includes(value.attempt) ||
1592
+ !['pass', 'correct', 'failed'].includes(value.disposition) ||
1593
+ !Array.isArray(value.reasons) ||
1594
+ reasons.length !== value.reasons.length
1595
+ ) {
1596
+ return null;
1597
+ }
1598
+ return {
1599
+ schemaVersion: value.schemaVersion,
1600
+ requestHash: value.requestHash,
1601
+ attempt: value.attempt,
1602
+ disposition: value.disposition,
1603
+ reasons,
1604
+ };
1605
+ }
1606
+
1607
+ function projectPublicationSummaryForCli(value) {
1608
+ if (!isObject(value)) return null;
1609
+ if (
1610
+ value.schemaVersion !== 'explainer-kit.publish-summary/v1' &&
1611
+ value.schemaVersion !== 'explainer-kit.publish-summary/v2'
1612
+ ) {
1613
+ return null;
1614
+ }
1615
+ return {
1616
+ schemaVersion: value.schemaVersion,
1617
+ ...(typeof value.receiptSchemaVersion === 'string' && {
1618
+ receiptSchemaVersion: value.receiptSchemaVersion,
1619
+ }),
1620
+ ...(typeof value.publicAccess === 'string' && {
1621
+ publicAccess: value.publicAccess,
1622
+ }),
1623
+ ...(Array.isArray(value.artifacts) && {
1624
+ artifacts: value.artifacts.map((artifact) =>
1625
+ isObject(artifact)
1626
+ ? {
1627
+ ...pickStringFields(artifact, [
1628
+ 'relativePath',
1629
+ 'publicUrl',
1630
+ 's3Uri',
1631
+ 'hash',
1632
+ 'contentType',
1633
+ ]),
1634
+ ...(isObject(artifact.source) && {
1635
+ source: pickStringFields(artifact.source, [
1636
+ 'kind',
1637
+ 'artifactId',
1638
+ 'name',
1639
+ ]),
1640
+ }),
1641
+ ...(isObject(artifact.objectVerification) && {
1642
+ objectVerification: pickClosedVerification(
1643
+ artifact.objectVerification,
1644
+ ),
1645
+ }),
1646
+ ...(isObject(artifact.publicVerification) && {
1647
+ publicVerification: pickClosedVerification(
1648
+ artifact.publicVerification,
1649
+ ),
1650
+ }),
1651
+ }
1652
+ : {},
1653
+ ),
1654
+ }),
1655
+ };
1656
+ }
1657
+
1658
+ function pickClosedVerification(value) {
1659
+ return {
1660
+ ...pickStringFields(value, ['status', 'method', 'hash']),
1661
+ ...(Number.isInteger(value.httpStatus) && {
1662
+ httpStatus: value.httpStatus,
1663
+ }),
1664
+ };
1665
+ }
1666
+
1667
+ function projectEvidenceReasons(value) {
1668
+ if (!Array.isArray(value)) return [];
1669
+ return value.flatMap((reason) => {
1670
+ if (
1671
+ !isObject(reason) ||
1672
+ ![
1673
+ 'planning',
1674
+ 'authoring',
1675
+ 'rendering',
1676
+ 'link-validation',
1677
+ 'browser-review',
1678
+ 'visual-review',
1679
+ 'durability',
1680
+ 'finalization',
1681
+ ].includes(reason.stage) ||
1682
+ ![
1683
+ 'finding',
1684
+ 'provider-failure',
1685
+ 'pipeline-failure',
1686
+ 'superseded',
1687
+ ].includes(reason.kind) ||
1688
+ !Number.isInteger(reason.count) ||
1689
+ reason.count < 1 ||
1690
+ reason.count > 50
1691
+ ) {
1692
+ return [];
1693
+ }
1694
+ return [
1695
+ {
1696
+ stage: reason.stage,
1697
+ kind: reason.kind,
1698
+ ...(typeof reason.artifactId === 'string' && {
1699
+ artifactId: reason.artifactId,
1700
+ }),
1701
+ count: reason.count,
1702
+ },
1703
+ ];
1704
+ });
1705
+ }
1706
+
1707
+ function pickStringFields(value, keys) {
1708
+ return Object.fromEntries(
1709
+ keys.flatMap((key) =>
1710
+ typeof value[key] === 'string' ? [[key, value[key]]] : [],
1711
+ ),
1712
+ );
1713
+ }
1714
+
1715
+ function isObject(value) {
1716
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1717
+ }
1718
+
1154
1719
  async function executeStage(run, id, options, operation) {
1155
1720
  await updateBuildRecord(run, { id, status: 'running' });
1156
1721
  try {
@@ -1164,18 +1729,19 @@ async function executeStage(run, id, options, operation) {
1164
1729
  ...(result.warnings !== undefined && { warnings: result.warnings }),
1165
1730
  });
1166
1731
  } catch (error) {
1732
+ const projected =
1733
+ localEvidenceReason(error) ??
1734
+ evidenceReason(evidenceStageForBuildStage(id), 'pipeline-failure');
1167
1735
  await updateBuildRecord(run, {
1168
1736
  id,
1169
1737
  status: 'failed',
1170
- error: {
1171
- code: error.code ?? stageErrorCode(id),
1172
- message: safeMessage(error),
1173
- recovery: [
1174
- `Correct the ${id} inputs or implementation and start a new run.`,
1175
- ],
1176
- },
1738
+ error: true,
1177
1739
  });
1178
- throw error;
1740
+ throw withEvidenceReason(
1741
+ codedError(stageErrorCode(id), `The ${id} stage failed.`),
1742
+ projected.stage,
1743
+ projected.kind,
1744
+ );
1179
1745
  }
1180
1746
  }
1181
1747
 
@@ -1281,17 +1847,41 @@ async function executeDurabilityAndPublish(state, options, now) {
1281
1847
  'Commit durability was requested without a durability callback.',
1282
1848
  );
1283
1849
  }
1284
- await options.durability({
1285
- runRoot: state.run.runRoot,
1286
- manifestPath: state.run.manifestPath,
1287
- buildRecordPath: state.run.buildRecordPath,
1288
- });
1850
+ let providerFailed = false;
1851
+ try {
1852
+ await options.durability({
1853
+ runRoot: state.run.runRoot,
1854
+ manifestPath: state.run.manifestPath,
1855
+ buildRecordPath: state.run.buildRecordPath,
1856
+ });
1857
+ } catch {
1858
+ providerFailed = true;
1859
+ }
1860
+ let inventoryFailed = false;
1861
+ try {
1862
+ const manifest = JSON.parse(
1863
+ await readFile(state.run.manifestPath, 'utf8'),
1864
+ );
1865
+ await enforceRetainedRunPackage(state, manifest);
1866
+ } catch {
1867
+ inventoryFailed = true;
1868
+ }
1869
+ if (providerFailed || inventoryFailed) {
1870
+ await updateBuildRecord(state.run, {
1871
+ id: 'durability',
1872
+ status: 'failed',
1873
+ error: true,
1874
+ });
1875
+ throw withEvidenceReason(
1876
+ codedError('E_DURABILITY', 'Durability provider failed.'),
1877
+ 'durability',
1878
+ providerFailed ? 'provider-failure' : 'pipeline-failure',
1879
+ );
1880
+ }
1289
1881
  await updateBuildRecord(state.run, {
1290
1882
  id: 'durability',
1291
1883
  status: 'warned',
1292
- warnings: [
1293
- 'Commit durability requires caller-created evidence through record-durability.mjs.',
1294
- ],
1884
+ warnings: ['durability-evidence-required'],
1295
1885
  });
1296
1886
  await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
1297
1887
  return false;
@@ -1305,58 +1895,164 @@ async function executeDurabilityAndPublish(state, options, now) {
1305
1895
  'Publish durability was requested without an explicit publisher callback.',
1306
1896
  );
1307
1897
  }
1308
- await updateBuildRecord(state.run, {
1309
- id: 'publish',
1310
- status: 'warned',
1311
- warnings: [
1312
- 'Publishing requires separately retained verified receipt evidence.',
1313
- ],
1314
- });
1315
- await persistManifest(state, now());
1898
+ const finalizedAt = now();
1899
+ const finalizedManifest = await persistManifest(state, finalizedAt);
1900
+ let providerFailed = false;
1901
+ let publishedReceipt;
1316
1902
  try {
1317
- await options.publish({
1903
+ const buildRecord = JSON.parse(
1904
+ await readFile(state.run.buildRecordPath, 'utf8'),
1905
+ );
1906
+ assertManifestPublishable(finalizedManifest, { buildRecord });
1907
+ const receipt = await options.publish({
1318
1908
  request: structuredClone(state.run.request.durability.publish),
1319
1909
  runRoot: state.run.runRoot,
1320
1910
  manifestPath: state.run.manifestPath,
1321
1911
  });
1322
- } catch (error) {
1912
+ const schemaValidation = validateContract('publish-receipt', receipt);
1913
+ if (!schemaValidation.valid) {
1914
+ throw codedError(
1915
+ 'E_PUBLISH',
1916
+ `Publisher returned an invalid receipt: ${schemaValidation.errors[0].message}`,
1917
+ );
1918
+ }
1919
+ const crossRecordValidation = validateContract(
1920
+ 'publish-receipt',
1921
+ receipt,
1922
+ publicationValidationContext(receipt, finalizedManifest),
1923
+ );
1924
+ if (!crossRecordValidation.valid) {
1925
+ throw codedError(
1926
+ 'E_PUBLISH',
1927
+ `Publisher returned an invalid receipt: ${crossRecordValidation.errors[0].message}`,
1928
+ );
1929
+ }
1930
+ publishedReceipt = receipt;
1931
+ } catch {
1932
+ providerFailed = true;
1933
+ }
1934
+ // Everything below the provider boundary is local work. Folding it into the
1935
+ // try above classified a failed local write, build-record update or manifest
1936
+ // rewrite as `provider-failure`, blaming the destination for a defect on this
1937
+ // machine.
1938
+ let pipelineFailed = false;
1939
+ if (!providerFailed) {
1940
+ try {
1941
+ state.publishReceiptPath = 'publish-receipt.json';
1942
+ await writeJsonAtomic(
1943
+ state.run.runRoot,
1944
+ state.publishReceiptPath,
1945
+ publishedReceipt,
1946
+ );
1947
+ state.publication = publicationSummary(publishedReceipt);
1948
+ await updateBuildRecord(state.run, {
1949
+ id: 'publish',
1950
+ status: 'warned',
1951
+ warnings: ['publish-receipt-evidence-required'],
1952
+ });
1953
+ await persistManifest(state, finalizedAt);
1954
+ } catch {
1955
+ pipelineFailed = true;
1956
+ }
1957
+ }
1958
+ let inventoryFailed = false;
1959
+ try {
1960
+ const manifest = JSON.parse(await readFile(state.run.manifestPath, 'utf8'));
1961
+ await enforceRetainedRunPackage(state, manifest);
1962
+ } catch {
1963
+ inventoryFailed = true;
1964
+ }
1965
+ if (providerFailed || pipelineFailed || inventoryFailed) {
1323
1966
  await updateBuildRecord(state.run, {
1324
1967
  id: 'publish',
1325
1968
  status: 'failed',
1326
- error: {
1327
- code: error.code ?? 'E_PUBLISH',
1328
- message: safeMessage(error),
1329
- recovery: ['Correct the publish failure and start a new run.'],
1330
- },
1969
+ error: true,
1331
1970
  });
1332
- throw error;
1971
+ throw withEvidenceReason(
1972
+ codedError(
1973
+ 'E_PUBLISH',
1974
+ providerFailed
1975
+ ? 'Publication provider failed.'
1976
+ : 'Publication failed after upload while finalizing local records.',
1977
+ ),
1978
+ 'durability',
1979
+ providerFailed ? 'provider-failure' : 'pipeline-failure',
1980
+ );
1333
1981
  }
1334
1982
  return true;
1335
1983
  }
1336
1984
 
1337
1985
  async function persistManifest(state, createdAt) {
1338
1986
  const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
1339
- const manifest = manifestFor(
1340
- state,
1341
- record,
1342
- createdAt,
1343
- await immutableHashesFor(state),
1344
- );
1345
- await writeManifestAtomic(state.run, manifest);
1987
+ const immutableHashes = await immutableHashesFor(state);
1988
+ let manifest = manifestFor(state, record, createdAt, immutableHashes);
1346
1989
  const publicBaseUrl =
1347
1990
  state.run.request.publicBaseUrl ??
1348
1991
  state.run.request.durability?.publish?.publicBaseUrl;
1349
1992
  if (publicBaseUrl) {
1993
+ // Must match the catalog the connector builds and uploads byte for byte,
1994
+ // so the verification policy is resolved from the same request fields.
1995
+ const publicAccess =
1996
+ state.run.request.publicAccess ??
1997
+ state.run.request.durability?.publish?.publicAccess;
1998
+ const catalogPath = initiativeCatalogPath(manifest.slug);
1350
1999
  await writeJsonAtomic(
1351
2000
  state.run.runRoot,
1352
- initiativeCatalogPath(manifest.slug),
1353
- catalogFromManifest(manifest, publicBaseUrl),
2001
+ catalogPath,
2002
+ catalogFromManifest(manifest, publicBaseUrl, { publicAccess }),
1354
2003
  );
2004
+ manifest = manifestFor(state, record, createdAt, {
2005
+ ...immutableHashes,
2006
+ [catalogPath]: hashBytes(
2007
+ await readFile(join(state.run.runRoot, catalogPath)),
2008
+ ),
2009
+ });
1355
2010
  }
2011
+ await writeManifestAtomic(state.run, manifest);
1356
2012
  return manifest;
1357
2013
  }
1358
2014
 
1359
- async function persistFailureManifest(state, error, createdAt) {
2015
+ async function inventoryManifestFor(state, createdAt) {
2016
+ const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
2017
+ return manifestFor(state, record, createdAt, await immutableHashesFor(state));
2018
+ }
2019
+
2020
+ async function enforceRetainedRunPackage(
2021
+ state,
2022
+ manifest,
2023
+ {
2024
+ acceptCleanedUnexpected = false,
2025
+ failureStage,
2026
+ includeManifest = true,
2027
+ includeTerminalEvidence = false,
2028
+ } = {},
2029
+ ) {
2030
+ try {
2031
+ return await enforceRunPackageInventory(state.run.runRoot, manifest, {
2032
+ includeManifest,
2033
+ includeTerminalEvidence,
2034
+ removeUnexpected: true,
2035
+ });
2036
+ } catch (error) {
2037
+ if (acceptCleanedUnexpected) {
2038
+ await enforceRunPackageInventory(state.run.runRoot, manifest, {
2039
+ includeManifest,
2040
+ includeTerminalEvidence,
2041
+ });
2042
+ return;
2043
+ }
2044
+ if (failureStage) {
2045
+ await updateBuildRecord(state.run, {
2046
+ id: failureStage,
2047
+ status: 'failed',
2048
+ error: true,
2049
+ });
2050
+ }
2051
+ throw error;
2052
+ }
2053
+ }
2054
+
2055
+ async function persistFailureManifest(state, _error, createdAt) {
1360
2056
  const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
1361
2057
  const recordedIds = new Set(state.artifacts.map(({ id }) => id));
1362
2058
  state.artifacts.push(
@@ -1369,8 +2065,8 @@ async function persistFailureManifest(state, error, createdAt) {
1369
2065
  status: 'failed',
1370
2066
  rebuildable: false,
1371
2067
  failure: {
1372
- code: error.code ?? 'E_RENDER',
1373
- message: safeMessage(error),
2068
+ code: 'E_ARTIFACT_BUILD',
2069
+ message: 'Artifact construction failed.',
1374
2070
  recovery: ['Correct the failed stage and start a new run.'],
1375
2071
  },
1376
2072
  })),
@@ -1412,7 +2108,7 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
1412
2108
  path: 'build-record.json',
1413
2109
  hash: canonicalHash(buildRecord),
1414
2110
  },
1415
- warnings: [...new Set(state.warnings)],
2111
+ warnings: retainedWarnings(state.warnings),
1416
2112
  };
1417
2113
  }
1418
2114
 
@@ -1469,8 +2165,20 @@ async function createAuthoredContent(state, options, now) {
1469
2165
  errors: [],
1470
2166
  };
1471
2167
  const authored = [];
2168
+ const artifactLinkTables = new Map(
2169
+ artifacts.map((artifact) => [
2170
+ artifact.id,
2171
+ canonicalArtifactLinks(artifacts, artifact.id, state.run.slug),
2172
+ ]),
2173
+ );
1472
2174
  for (const artifact of artifacts) {
1473
- const item = await authorArtifact(state, artifact, author, trust);
2175
+ const item = await authorArtifact(
2176
+ state,
2177
+ artifact,
2178
+ author,
2179
+ trust,
2180
+ artifactLinkTables.get(artifact.id),
2181
+ );
1474
2182
  if ((item.result.proposedArtifacts ?? []).length > 0) {
1475
2183
  throw codedError(
1476
2184
  'E_AUTHOR_RESULT',
@@ -1583,6 +2291,7 @@ async function authorArtifact(
1583
2291
  artifact,
1584
2292
  author,
1585
2293
  trust,
2294
+ artifactLinks,
1586
2295
  correctionContext,
1587
2296
  ) {
1588
2297
  const [brief, visualAuthoringGuidance, shellContent] = await Promise.all([
@@ -1613,7 +2322,7 @@ async function authorArtifact(
1613
2322
  );
1614
2323
  }
1615
2324
  const authorRequest = {
1616
- schemaVersion: 'explainer-kit.author-request/v2',
2325
+ schemaVersion: 'explainer-kit.author-request/v3',
1617
2326
  artifactId: artifact.id,
1618
2327
  artifactType: artifact.type,
1619
2328
  authoring: artifact.authoring,
@@ -1624,6 +2333,7 @@ async function authorArtifact(
1624
2333
  theme: structuredClone(state.theme),
1625
2334
  setContext: structuredClone(state.setPlan),
1626
2335
  plannedArtifact: structuredClone(artifact.plannedArtifact),
2336
+ artifactLinks: structuredClone(artifactLinks),
1627
2337
  ...(graphSemantics.length > 0 && {
1628
2338
  graphSemantics: structuredClone(graphSemantics),
1629
2339
  }),
@@ -1633,7 +2343,7 @@ async function authorArtifact(
1633
2343
  }),
1634
2344
  };
1635
2345
  const requestValidation = validateContract(
1636
- 'author-request/v2',
2346
+ 'author-request/v3',
1637
2347
  authorRequest,
1638
2348
  );
1639
2349
  if (!requestValidation.valid) {
@@ -1643,13 +2353,22 @@ async function authorArtifact(
1643
2353
  );
1644
2354
  }
1645
2355
 
1646
- const result =
1647
- correctionContext === undefined
1648
- ? await author(structuredClone(authorRequest))
1649
- : await author(
1650
- structuredClone(authorRequest),
1651
- structuredClone(correctionContext),
1652
- );
2356
+ let result;
2357
+ try {
2358
+ result =
2359
+ correctionContext === undefined
2360
+ ? await author(structuredClone(authorRequest))
2361
+ : await author(
2362
+ structuredClone(authorRequest),
2363
+ structuredClone(correctionContext),
2364
+ );
2365
+ } catch {
2366
+ throw withEvidenceReason(
2367
+ codedError('E_AUTHOR', 'Artifact author provider failed.'),
2368
+ 'authoring',
2369
+ 'provider-failure',
2370
+ );
2371
+ }
1653
2372
  const resultValidation = validateContract('author-result/v2', result);
1654
2373
  if (!resultValidation.valid) {
1655
2374
  throw codedError(
@@ -1718,6 +2437,34 @@ async function authorArtifact(
1718
2437
  };
1719
2438
  }
1720
2439
 
2440
+ function canonicalArtifactLinks(artifacts, currentArtifactId, slug) {
2441
+ const paths = new Map(
2442
+ artifacts.map((artifact) => [
2443
+ artifact.id,
2444
+ artifactPath(renderDescriptor(artifact), slug),
2445
+ ]),
2446
+ );
2447
+ const currentPath = paths.get(currentArtifactId);
2448
+ if (!currentPath) {
2449
+ throw codedError(
2450
+ 'E_AUTHOR_REQUEST',
2451
+ `Cannot construct canonical links for unknown artifact ${currentArtifactId}.`,
2452
+ );
2453
+ }
2454
+ return artifacts.map((artifact) => {
2455
+ const sitePath = paths.get(artifact.id);
2456
+ const href =
2457
+ posix.relative(posix.dirname(currentPath), sitePath) ||
2458
+ posix.basename(sitePath);
2459
+ return {
2460
+ artifactId: artifact.id,
2461
+ artifactType: artifact.type,
2462
+ sitePath,
2463
+ href,
2464
+ };
2465
+ });
2466
+ }
2467
+
1721
2468
  function diagramAnalyses(markdown) {
1722
2469
  const ast = parseMarkdown(markdown);
1723
2470
  const diagrams = [];
@@ -2004,6 +2751,7 @@ async function immutableHashesFor(state) {
2004
2751
  metricsPath,
2005
2752
  ]),
2006
2753
  ...state.visualReviewPaths,
2754
+ ...(state.publishReceiptPath ? [state.publishReceiptPath] : []),
2007
2755
  ...(state.theme ? ['theme.resolved.json'] : []),
2008
2756
  ...state.artifacts
2009
2757
  .filter(
@@ -2050,13 +2798,22 @@ function assertValidRequest(request) {
2050
2798
  }
2051
2799
  }
2052
2800
 
2053
- function resultFor(state, error) {
2801
+ function terminalReasonsForReview(state) {
2802
+ const visualReasons = state.visualReview?.evidence?.reasons ?? [];
2803
+ if (visualReasons.length > 0) return structuredClone(visualReasons);
2804
+ return [
2805
+ state.reviewGateReason ??
2806
+ evidenceReason('visual-review', 'pipeline-failure'),
2807
+ ];
2808
+ }
2809
+
2810
+ function resultFor(state, failure = { failed: false }) {
2054
2811
  return {
2055
2812
  runId: state.run.runId,
2056
2813
  runRoot: state.run.runRoot,
2057
2814
  manifestPath: state.run.manifestPath,
2058
2815
  buildRecordPath: state.run.buildRecordPath,
2059
- outcome: error
2816
+ outcome: failure.failed
2060
2817
  ? 'failed'
2061
2818
  : state.approval?.canResume === false
2062
2819
  ? 'incomplete'
@@ -2066,8 +2823,13 @@ function resultFor(state, error) {
2066
2823
  ...(state.approval?.record?.marking && {
2067
2824
  marking: state.approval.record.marking,
2068
2825
  }),
2069
- warnings: [...new Set(state.warnings)],
2070
- discovery: state.discovery,
2826
+ warnings: retainedWarnings(state.warnings),
2827
+ discovery: {
2828
+ rounds: state.discovery.rounds,
2829
+ findingCount: state.discovery.findings.length,
2830
+ reason: state.discovery.reason,
2831
+ },
2832
+ ...(state.publication && { publication: state.publication }),
2071
2833
  ...(state.approval && {
2072
2834
  approval: {
2073
2835
  status: state.approval.status,
@@ -2079,14 +2841,60 @@ function resultFor(state, error) {
2079
2841
  },
2080
2842
  }),
2081
2843
  ...(state.visualReview && {
2082
- visualReview: structuredClone(state.visualReview.result),
2844
+ visualReview: structuredClone(state.visualReview.evidence),
2083
2845
  }),
2084
- ...(error && {
2085
- errors: [{ code: error.code ?? 'E_RUN', message: safeMessage(error) }],
2846
+ ...(failure.failed && {
2847
+ reasons: structuredClone(failure.reasons),
2086
2848
  }),
2087
2849
  };
2088
2850
  }
2089
2851
 
2852
+ function publicationSummary(receipt) {
2853
+ if (receipt.schemaVersion === 'explainer-kit.publish-receipt/v2') {
2854
+ return {
2855
+ schemaVersion: 'explainer-kit.publish-summary/v2',
2856
+ receiptSchemaVersion: receipt.schemaVersion,
2857
+ publicAccess: receipt.publicAccess,
2858
+ artifacts: receipt.artifacts.map((artifact) => structuredClone(artifact)),
2859
+ };
2860
+ }
2861
+ return {
2862
+ schemaVersion: 'explainer-kit.publish-summary/v1',
2863
+ receiptSchemaVersion: receipt.schemaVersion,
2864
+ publicAccess: 'public',
2865
+ artifacts: receipt.artifacts.map(({ relativePath, publicUrl }) => ({
2866
+ relativePath,
2867
+ publicUrl,
2868
+ })),
2869
+ };
2870
+ }
2871
+
2872
+ function publicationValidationContext(receipt, manifest) {
2873
+ if (receipt.schemaVersion !== 'explainer-kit.publish-receipt/v2') {
2874
+ return { manifest };
2875
+ }
2876
+ let catalog;
2877
+ try {
2878
+ // `publicAccess` is a receipt/v2 field; a v1 receipt returns above, and the
2879
+ // connector resolves v1 publish requests to 'public' for the same reason.
2880
+ catalog = catalogFromManifest(manifest, receipt.roots.publicBaseUrl, {
2881
+ publicAccess: receipt.publicAccess,
2882
+ });
2883
+ } catch (error) {
2884
+ throw codedError(
2885
+ 'E_PUBLISH',
2886
+ `Publisher returned invalid publication roots: ${safeMessage(error)}`,
2887
+ );
2888
+ }
2889
+ return {
2890
+ manifest,
2891
+ catalogArtifact: {
2892
+ relativePath: initiativeCatalogPath(manifest.slug),
2893
+ hash: hashBytes(Buffer.from(serializeInitiativeCatalog(catalog))),
2894
+ },
2895
+ };
2896
+ }
2897
+
2090
2898
  async function parseCli(argv) {
2091
2899
  let requestPath;
2092
2900
  const options = {};
@@ -2155,17 +2963,71 @@ function codedError(code, message) {
2155
2963
  return error;
2156
2964
  }
2157
2965
 
2966
+ /**
2967
+ * Terminal evidence reasons are unique by (stage, kind), so a second reason for
2968
+ * a pair that is already present folds into its count rather than appending a
2969
+ * duplicate the closed contract would reject.
2970
+ */
2971
+ function mergeTerminalReasons(reasons, addition) {
2972
+ const existing = reasons.find(
2973
+ ({ stage, kind }) => stage === addition.stage && kind === addition.kind,
2974
+ );
2975
+ if (!existing) return [...reasons, addition];
2976
+ return reasons.map((entry) =>
2977
+ entry === existing
2978
+ ? { ...entry, count: (entry.count ?? 1) + (addition.count ?? 1) }
2979
+ : entry,
2980
+ );
2981
+ }
2982
+
2983
+ function withEvidenceReason(error, stage, kind) {
2984
+ error.evidenceReason = evidenceReason(stage, kind);
2985
+ LOCALLY_PROJECTED_ERRORS.add(error);
2986
+ return error;
2987
+ }
2988
+
2989
+ function localEvidenceReason(error) {
2990
+ return error !== null &&
2991
+ (typeof error === 'object' || typeof error === 'function') &&
2992
+ LOCALLY_PROJECTED_ERRORS.has(error)
2993
+ ? error.evidenceReason
2994
+ : null;
2995
+ }
2996
+
2997
+ function evidenceStageForBuildStage(stage) {
2998
+ return (
2999
+ {
3000
+ validate: 'planning',
3001
+ 'fact-base': 'planning',
3002
+ content: 'authoring',
3003
+ theme: 'authoring',
3004
+ render: 'rendering',
3005
+ qa: 'browser-review',
3006
+ durability: 'durability',
3007
+ publish: 'durability',
3008
+ }[stage] ?? 'finalization'
3009
+ );
3010
+ }
3011
+
2158
3012
  function stageErrorCode(stage) {
2159
3013
  return `E_${stage.toUpperCase().replaceAll('-', '_')}`;
2160
3014
  }
2161
3015
 
2162
- function safeMessage(error) {
2163
- return (error instanceof Error ? error.message : String(error))
2164
- .replaceAll(
2165
- /(?:aws_secret_access_key|aws_session_token|password|private_key)\s*[:=]\s*\S+/gi,
2166
- '[redacted]',
3016
+ function safeMessage(_error) {
3017
+ return 'Operation failed.';
3018
+ }
3019
+
3020
+ function retainedWarnings(warnings) {
3021
+ return [...new Set(warnings.filter(isLocalWarningCode))];
3022
+ }
3023
+
3024
+ function isLocalWarningCode(value) {
3025
+ return (
3026
+ typeof value === 'string' &&
3027
+ /^(?:fact-base-freshness-warning|theme-selection-normalized|durability-evidence-required|publish-receipt-evidence-required|(?:expansion|guideline|render|qa)-[a-z0-9-]+|visual-review-required:[a-z0-9-]+|stage-reopened:[a-z0-9-]+:[0-9TZ:.-]+|missing-(?:theme-token|required-anchor):[a-z0-9-]+)$/.test(
3028
+ value,
2167
3029
  )
2168
- .slice(0, 2000);
3030
+ );
2169
3031
  }
2170
3032
 
2171
3033
  if (