@open-agent-toolkit/cli 0.2.25 → 0.2.27

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/NOTICES.md +156 -0
  2. package/assets/docs/cli-utilities/configuration.md +42 -11
  3. package/assets/docs/contributing/explainer-kit-verification.md +125 -0
  4. package/assets/docs/contributing/index.md +1 -0
  5. package/assets/docs/reference/troubleshooting.md +47 -0
  6. package/assets/docs/workflows/projects/artifacts.md +24 -6
  7. package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
  8. package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
  9. package/assets/docs/workflows/skills/explainer-kit.md +121 -69
  10. package/assets/docs/workflows/skills/index.md +1 -0
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/explainer-kit/SKILL.md +18 -3
  13. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
  14. package/assets/skills/explainer-kit/references/contracts.md +167 -20
  15. package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
  16. package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
  17. package/assets/skills/explainer-kit/references/visual-review.md +57 -0
  18. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
  19. package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
  20. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
  21. package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
  22. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
  23. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
  24. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
  25. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
  26. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
  27. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
  28. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
  29. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
  30. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
  31. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
  32. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
  33. package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
  34. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
  35. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
  36. package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
  37. package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
  38. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
  39. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
  40. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
  41. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
  42. package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
  43. package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
  44. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  45. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  46. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  47. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  48. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  49. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  50. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  51. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  53. package/assets/skills/oat-project-implement/SKILL.md +9 -11
  54. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
  55. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  56. package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
  57. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  58. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  59. package/dist/commands/config/index.d.ts.map +1 -1
  60. package/dist/commands/config/index.js +27 -3
  61. package/dist/commands/project/archive/archive-utils.d.ts +1 -0
  62. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  63. package/dist/commands/project/archive/archive-utils.js +109 -42
  64. package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
  65. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
  66. package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
  67. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
  68. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
  69. package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
  70. package/dist/commands/project/archive/push-runner.d.ts +2 -1
  71. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  72. package/dist/commands/project/archive/push-runner.js +5 -1
  73. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  74. package/dist/commands/project/dispatch-ceiling/index.js +90 -0
  75. package/dist/config/dispatch-notices.d.ts +8 -0
  76. package/dist/config/dispatch-notices.d.ts.map +1 -0
  77. package/dist/config/dispatch-notices.js +79 -0
  78. package/dist/config/dispatch-policy-options.d.ts +2 -0
  79. package/dist/config/dispatch-policy-options.d.ts.map +1 -1
  80. package/dist/config/dispatch-policy-options.js +14 -2
  81. package/dist/providers/identity/dispatch-report.d.ts +17 -0
  82. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  83. package/dist/providers/identity/dispatch-report.js +30 -0
  84. package/dist/release/public-package-contract.d.ts +6 -0
  85. package/dist/release/public-package-contract.d.ts.map +1 -1
  86. package/dist/release/public-package-contract.js +75 -0
  87. package/package.json +2 -2
@@ -1,15 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createHash } from 'node:crypto';
4
- import { readFile } from 'node:fs/promises';
5
- import { join, resolve } from 'node:path';
4
+ import { lstat, readFile, realpath } from 'node:fs/promises';
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
- import { resolveContentApproval } from './lib/content-approval.mjs';
8
+ import { assertBrowserProbeSession } from './lib/browser-runtime.mjs';
9
+ import { catalogFromManifest, initiativeCatalogPath } from './lib/catalog.mjs';
10
+ import {
11
+ readContentApproval,
12
+ resolveContentApproval,
13
+ } from './lib/content-approval.mjs';
9
14
  import { canonicalHash, validateContract } from './lib/contracts.mjs';
15
+ import {
16
+ assertAuthoredGraphSemantics,
17
+ graphSemanticsForArtisticAuthor,
18
+ parseDiagram,
19
+ } from './lib/diagram.mjs';
10
20
  import { processFactBase } from './lib/fact-base.mjs';
11
21
  import { writeJsonAtomic, writeTextAtomic } from './lib/fs-safe.mjs';
12
22
  import { validateHtmlSafety } from './lib/html-safety.mjs';
23
+ import { parseMarkdown } from './lib/markdown.mjs';
13
24
  import {
14
25
  auditArtifactSet,
15
26
  checkGuidelines,
@@ -19,32 +30,63 @@ import {
19
30
  renderWarningIds,
20
31
  } from './lib/qa.mjs';
21
32
  import {
22
- evaluateExpansionProposals,
23
33
  loadRecipe,
24
34
  recipeExpansion,
25
35
  recipeFloor,
26
36
  recipeRequiredNarrative,
37
+ resolveDiagramRenderingRoute,
38
+ selectRecipeAuthoring,
27
39
  shouldStopDiscovery,
28
40
  validateContentModel,
41
+ validatePlannedPortfolio,
29
42
  validateSourceBindings,
30
43
  } from './lib/recipes.mjs';
31
44
  import {
45
+ canonicalPersistedRunRequest,
46
+ createSetPlanResumeToken,
32
47
  initializeRun,
48
+ readSetPlanRecords,
33
49
  reopenBuildStages,
34
50
  updateBuildRecord,
51
+ verifySetPlanResumeToken,
35
52
  writeManifestAtomic,
53
+ writeSetPlanRecords,
54
+ writeVisualReviewAttempt,
55
+ writeVisualReviewFailure,
56
+ writeVisualRevision,
36
57
  } from './lib/records.mjs';
37
58
  import { artifactPath, renderArtifact } from './lib/render.mjs';
59
+ import { resolveRootConfinedPath } from './lib/safe-paths.mjs';
60
+ import { plannedArtifacts, planExplainerSet } from './lib/set-plan.mjs';
38
61
  import { resolveTheme } from './lib/theme.mjs';
62
+ import { runVisualReview } from './lib/visual-review.mjs';
63
+
64
+ export {
65
+ assertBrowserProbeSession,
66
+ createBrowserProbeSession,
67
+ createFixtureBrowserProbeSession,
68
+ } from './lib/browser-runtime.mjs';
39
69
 
40
70
  // Stages a rejected draft reruns once its content is corrected.
41
71
  const REOPENED_ON_REJECTION = Object.freeze(['render', 'qa']);
42
72
 
43
73
  export async function runExplainer(request, options = {}) {
44
- assertValidRequest(request);
45
- const recipe = loadRecipe(request.recipe.id, request.recipe.version);
46
- const resumed = await loadResumableRun(request);
47
- const run = resumed ?? (await initializeRun(request));
74
+ const normalizedRequest = normalizeRunRequest(request);
75
+ assertValidRequest(normalizedRequest);
76
+ const recipe = selectRecipeAuthoring(
77
+ loadRecipe(normalizedRequest.recipe.id, normalizedRequest.recipe.version),
78
+ normalizedRequest.recapMode,
79
+ );
80
+ const browserProvider = resolveBrowserProvider(
81
+ normalizedRequest,
82
+ recipe,
83
+ options,
84
+ );
85
+ const resumed = await loadResumableRun(
86
+ normalizedRequest,
87
+ options.reviewedSource?.resumeToken,
88
+ );
89
+ const run = resumed ?? (await initializeRun(normalizedRequest));
48
90
  const now = options.now ?? (() => new Date().toISOString());
49
91
  const state = {
50
92
  run,
@@ -52,6 +94,8 @@ export async function runExplainer(request, options = {}) {
52
94
  factBase: null,
53
95
  factBaseHash: null,
54
96
  inputHashes: {},
97
+ setPlan: null,
98
+ setPlanPaths: [],
55
99
  contentModels: [],
56
100
  contentPaths: new Map(),
57
101
  authorResultPaths: [],
@@ -74,6 +118,13 @@ export async function runExplainer(request, options = {}) {
74
118
  reopenedWarnings: {},
75
119
  discovery: { rounds: 0, findings: [], reason: 'not-requested' },
76
120
  approval: null,
121
+ browserProvider,
122
+ browserEvidence: [],
123
+ visualReview: null,
124
+ visualReviewPaths: [],
125
+ visualReviewAttempt: 0,
126
+ reviewGateBlocked: false,
127
+ resumeToken: null,
77
128
  resumedApprovalStatus: null,
78
129
  };
79
130
 
@@ -114,9 +165,28 @@ export async function runExplainer(request, options = {}) {
114
165
  await prepareTheme(state);
115
166
  await executeStage(run, 'content', options, async () => {
116
167
  state.discovery = await runDiscovery(recipe, state.factBase, options);
168
+ const planned = await planExplainerSet({
169
+ recipe,
170
+ factBase: state.factBase,
171
+ discovery: state.discovery,
172
+ planSet: options.planSet,
173
+ });
174
+ state.setPlan = planned.plan;
175
+ const portfolioValidation = validatePlannedPortfolio(
176
+ recipe,
177
+ state.setPlan.portfolio,
178
+ );
179
+ if (!portfolioValidation.valid) {
180
+ throw codedError(
181
+ 'E_SET_PLAN',
182
+ `Invalid planned portfolio: ${portfolioValidation.errors.join('; ')}`,
183
+ );
184
+ }
185
+ state.setPlanPaths = await writeSetPlanRecords(run, planned);
117
186
  await createAuthoredContent(state, options, now);
118
187
  return {
119
188
  outputPaths: [
189
+ ...state.setPlanPaths,
120
190
  ...state.contentPaths.values(),
121
191
  ...state.authorResultPaths,
122
192
  ],
@@ -145,7 +215,7 @@ export async function runExplainer(request, options = {}) {
145
215
  );
146
216
  }
147
217
  await executeRenderStage(state, options);
148
- await executeQaStage(state, options);
218
+ await executeQaStage(state, options, now);
149
219
  }
150
220
 
151
221
  state.approval = await resolveContentApproval(
@@ -156,11 +226,27 @@ export async function runExplainer(request, options = {}) {
156
226
  approvalArtifacts(state),
157
227
  );
158
228
  if (!state.approval.canResume) {
229
+ if (run.request.mode === 'interactive') {
230
+ state.resumeToken = await createSetPlanResumeToken(run);
231
+ }
159
232
  return resultFor(state);
160
233
  }
161
234
 
162
- await executeDurabilityAndPublish(state, options, now);
163
- await persistManifest(state, now());
235
+ if (state.reviewGateBlocked) {
236
+ await updateBuildRecord(run, { id: 'durability', status: 'skipped' });
237
+ await updateBuildRecord(run, { id: 'publish', status: 'skipped' });
238
+ await persistManifest(state, now());
239
+ return resultFor(state);
240
+ }
241
+
242
+ const manifestFinalized = await executeDurabilityAndPublish(
243
+ state,
244
+ options,
245
+ now,
246
+ );
247
+ if (!manifestFinalized) {
248
+ await persistManifest(state, now());
249
+ }
164
250
  return resultFor(state);
165
251
  } catch (error) {
166
252
  if (state.theme && state.factBase) {
@@ -205,6 +291,7 @@ async function executeRenderStage(state, options) {
205
291
  content: state.contentModels.find(
206
292
  ({ artifactId }) => artifactId === artifact.id,
207
293
  ),
294
+ factBase: state.factBase,
208
295
  theme: state.theme,
209
296
  renderStrategy: state.renderStrategy,
210
297
  ...(state.run.request.publicBaseUrl && {
@@ -237,37 +324,110 @@ async function executeRenderStage(state, options) {
237
324
  });
238
325
  }
239
326
 
240
- async function executeQaStage(state, options) {
327
+ async function executeQaStage(state, options, now) {
241
328
  await executeStage(state.run, 'qa', options, async () => {
242
329
  const htmlSafetyErrors = [];
243
330
  const qaWarnings = [];
244
- return auditRenderedArtifacts(state, options, {
245
- browserProbe: resolveBrowserProbe(options),
246
- htmlSafetyErrors,
247
- qaWarnings,
248
- });
331
+ try {
332
+ return await auditRenderedArtifacts(state, options, now, {
333
+ browserProvider: state.browserProvider,
334
+ htmlSafetyErrors,
335
+ qaWarnings,
336
+ });
337
+ } catch (error) {
338
+ const reviewError = normalizeReviewGateError(state, error);
339
+ if (!reviewError) {
340
+ throw error;
341
+ }
342
+ const warning = reviewGateWarning(reviewError);
343
+ state.reviewGateBlocked = true;
344
+ 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
+ return {
353
+ outputPaths: [
354
+ ...state.rendered.map(({ renderedPath }) => renderedPath),
355
+ ...state.visualReviewPaths,
356
+ ],
357
+ warnings: [warning],
358
+ status: 'warned',
359
+ };
360
+ }
249
361
  });
250
362
  }
251
363
 
252
364
  /**
253
- * Render QA drives a caller-supplied probe only. The core never launches a
254
- * headless runtime itself; agents review rendered output in their own browser.
365
+ * Render QA drives a caller-supplied session only. The core never launches a
366
+ * headless runtime itself. Production recap evidence requires a session whose
367
+ * identity was derived and branded by createBrowserProbeSession().
255
368
  */
256
- function resolveBrowserProbe(options) {
369
+ function resolveBrowserProvider(request, recipe, options) {
370
+ if (
371
+ options.browserSession !== undefined &&
372
+ options.browserProbe !== undefined
373
+ ) {
374
+ throw codedError(
375
+ 'E_BROWSER_PROBE',
376
+ 'Supply either options.browserSession or the legacy non-retaining options.browserProbe callback, not both.',
377
+ );
378
+ }
379
+ const productionRecap =
380
+ recipe.id === 'project-recap' && request.mode === 'unattended';
381
+ if (options.browserSession !== undefined) {
382
+ try {
383
+ return {
384
+ session: assertBrowserProbeSession(options.browserSession, {
385
+ allowFixture: true,
386
+ }),
387
+ probe: null,
388
+ productionRecap,
389
+ };
390
+ } catch (error) {
391
+ throw codedError(
392
+ 'E_BROWSER_PROBE',
393
+ error?.message ?? 'Browser session validation failed.',
394
+ );
395
+ }
396
+ }
257
397
  if (options.browserProbe === undefined) return null;
398
+ if (productionRecap) {
399
+ throw codedError(
400
+ 'E_BROWSER_PROBE',
401
+ 'Unattended project recaps require a trusted launched-Chromium browserSession; bare callbacks and caller-asserted runtime metadata are not accepted.',
402
+ );
403
+ }
258
404
  if (typeof options.browserProbe !== 'function') {
259
405
  throw codedError(
260
406
  'E_BROWSER_PROBE',
261
407
  'options.browserProbe must be a function when supplied.',
262
408
  );
263
409
  }
264
- return options.browserProbe;
410
+ return {
411
+ session: null,
412
+ probe: async (...args) => {
413
+ try {
414
+ return await options.browserProbe(...args);
415
+ } catch (error) {
416
+ throw codedError(
417
+ 'E_VISUAL_REVIEW',
418
+ `Browser evidence callback failed: ${error?.message ?? String(error)}`,
419
+ );
420
+ }
421
+ },
422
+ productionRecap,
423
+ };
265
424
  }
266
425
 
267
426
  async function auditRenderedArtifacts(
268
427
  state,
269
428
  options,
270
- { browserProbe, htmlSafetyErrors, qaWarnings },
429
+ now,
430
+ { browserProvider, htmlSafetyErrors, qaWarnings },
271
431
  ) {
272
432
  for (const artifact of state.resolvedArtifacts.filter(
273
433
  ({ authoring }) => authoring === 'html',
@@ -293,9 +453,19 @@ async function auditRenderedArtifacts(
293
453
  }));
294
454
  const report = await auditArtifactSet({
295
455
  artifacts: probeArtifacts,
456
+ setPlan: state.setPlan,
296
457
  ...(options.denylist && { denylist: options.denylist }),
297
- ...(browserProbe && { browserProbe }),
298
- ...(options.widths && { widths: options.widths }),
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,
468
+ }),
299
469
  });
300
470
  const hardIssues = report.issues.filter((issue) => isHardQaIssue(issue.code));
301
471
  const warningIssues = report.issues.filter(
@@ -309,7 +479,7 @@ async function auditRenderedArtifacts(
309
479
  .filter(({ code }) => renderQaWarningIds([{ code }]).length === 0)
310
480
  .map(({ code }) => `qa-${code}`),
311
481
  );
312
- if (!browserProbe) {
482
+ if (!browserProvider) {
313
483
  qaWarnings.push(RENDER_QA_WARNING_IDS.skippedNoProbe);
314
484
  }
315
485
  const guidelines = checkGuidelines({
@@ -326,18 +496,264 @@ async function auditRenderedArtifacts(
326
496
  errors.map(({ code, message }) => `${code}: ${message}`).join('; '),
327
497
  );
328
498
  }
499
+ state.browserEvidence = report.browser?.evidence ?? [];
500
+ const visualCritic = resolveVisualCritic(options);
501
+ const reviewRequired = requiresRecapVisualReview(state);
502
+ const fixtureBrowserSession =
503
+ browserProvider?.session?.runtime.kind === 'fixture';
504
+ if (reviewRequired && !browserProvider) {
505
+ qaWarnings.push('visual-review-required:browser-probe-missing');
506
+ } else if (reviewRequired && !visualCritic) {
507
+ qaWarnings.push('visual-review-required:visual-critic-missing');
508
+ } else if (visualCritic) {
509
+ await reviewAndRetain(state, visualCritic, 1);
510
+ if (state.visualReview.result.disposition === 'correct') {
511
+ await applyVisualCorrection(state, options, now);
512
+ const correctedArtifacts = state.rendered.map((artifact) => ({
513
+ id: artifact.artifactId,
514
+ type: artifact.type,
515
+ html: artifact.html,
516
+ }));
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,
531
+ }),
532
+ });
533
+ const finalHardIssues = finalReport.issues.filter((issue) =>
534
+ isHardQaIssue(issue.code),
535
+ );
536
+ if (finalHardIssues.length > 0) {
537
+ throw codedError(
538
+ 'E_VISUAL_CORRECTION',
539
+ finalHardIssues
540
+ .map(({ code, message }) => `${code}: ${message}`)
541
+ .join('; '),
542
+ );
543
+ }
544
+ const finalWarnings = finalReport.issues.filter(
545
+ (issue) => !isHardQaIssue(issue.code),
546
+ );
547
+ qaWarnings.push(
548
+ ...renderQaWarningIds(finalWarnings),
549
+ ...finalWarnings
550
+ .filter(({ code }) => renderQaWarningIds([{ code }]).length === 0)
551
+ .map(({ code }) => `qa-${code}`),
552
+ );
553
+ state.browserEvidence = finalReport.browser?.evidence ?? [];
554
+ await reviewAndRetain(state, visualCritic, 2);
555
+ }
556
+ }
557
+ if (reviewRequired && fixtureBrowserSession) {
558
+ qaWarnings.push('visual-review-required:fixture-browser-session');
559
+ }
560
+ if (
561
+ reviewRequired &&
562
+ state.visualReview &&
563
+ state.visualReview.result.disposition !== 'pass'
564
+ ) {
565
+ qaWarnings.push(
566
+ state.visualReview.result.disposition === 'fail'
567
+ ? 'visual-review-required:critic-failed'
568
+ : 'visual-review-required:correction-unresolved',
569
+ );
570
+ }
571
+ state.reviewGateBlocked = qaWarnings.some((warning) =>
572
+ warning.startsWith('visual-review-required:'),
573
+ );
329
574
  state.warnings.push(...qaWarnings);
330
575
  const warnings = [
331
576
  ...(state.reopenedWarnings.qa ?? []),
332
577
  ...new Set(qaWarnings),
333
578
  ];
334
579
  return {
335
- outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
580
+ outputPaths: [
581
+ ...state.rendered.map(({ renderedPath }) => renderedPath),
582
+ ...state.visualReviewPaths,
583
+ ],
336
584
  warnings,
337
585
  status: warnings.length > 0 ? 'warned' : 'passed',
338
586
  };
339
587
  }
340
588
 
589
+ function resolveVisualCritic(options) {
590
+ if (options.visualCritic === undefined) return null;
591
+ if (typeof options.visualCritic !== 'function') {
592
+ throw codedError(
593
+ 'E_VISUAL_REVIEW',
594
+ 'options.visualCritic must be a function when supplied.',
595
+ );
596
+ }
597
+ if (
598
+ options.visualCritic === options.author ||
599
+ options.visualCritic === options.critic
600
+ ) {
601
+ throw codedError(
602
+ 'E_VISUAL_REVIEW',
603
+ 'The visual critic must be distinct from the artifact author and fact critic.',
604
+ );
605
+ }
606
+ return options.visualCritic;
607
+ }
608
+
609
+ function requiresRecapVisualReview(state) {
610
+ return (
611
+ state.recipe.id === 'project-recap' &&
612
+ state.run.request.mode === 'unattended'
613
+ );
614
+ }
615
+
616
+ async function reviewAndRetain(state, visualCritic, attempt) {
617
+ state.visualReviewAttempt = attempt;
618
+ try {
619
+ state.visualReview = await runVisualReview({
620
+ plan: state.setPlan,
621
+ rendered: state.rendered,
622
+ evidence: state.browserEvidence,
623
+ visualCritic,
624
+ runRoot: state.run.runRoot,
625
+ });
626
+ state.visualReviewPaths.push(
627
+ ...(await writeVisualReviewAttempt(state.run, {
628
+ attempt,
629
+ review: state.visualReview,
630
+ })),
631
+ );
632
+ } catch (error) {
633
+ throw codedError('E_VISUAL_REVIEW', error?.message ?? String(error));
634
+ }
635
+ }
636
+
637
+ async function applyVisualCorrection(state, options, now) {
638
+ const findings = state.visualReview.result.findings;
639
+ const artifactIds = [
640
+ ...new Set(findings.map(({ artifactId }) => artifactId)),
641
+ ];
642
+ const correctionAuthor = options.correctArtifact ?? options.author;
643
+ if (typeof correctionAuthor !== 'function') {
644
+ throw codedError(
645
+ 'E_VISUAL_CORRECTION',
646
+ 'A correct disposition requires an artifact correction callback.',
647
+ );
648
+ }
649
+ const trust = authorTrustContext(options, now);
650
+ const changes = [];
651
+ for (const artifactId of artifactIds) {
652
+ const artifactIndex = state.resolvedArtifacts.findIndex(
653
+ ({ id }) => id === artifactId,
654
+ );
655
+ if (artifactIndex < 0) {
656
+ throw codedError(
657
+ 'E_VISUAL_CORRECTION',
658
+ `Visual correction references unknown artifact ${artifactId}.`,
659
+ );
660
+ }
661
+ const artifact = state.resolvedArtifacts[artifactIndex];
662
+ const previousContent = state.authoredContent.get(artifactId);
663
+ let item;
664
+ try {
665
+ item = await authorArtifact(state, artifact, correctionAuthor, trust, {
666
+ attempt: 1,
667
+ findings: structuredClone(
668
+ findings.filter((finding) => finding.artifactId === artifactId),
669
+ ),
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)}`,
676
+ );
677
+ }
678
+ if ((item.result.proposedArtifacts ?? []).length > 0) {
679
+ throw codedError(
680
+ 'E_VISUAL_CORRECTION',
681
+ 'Visual correction cannot change the validated artifact portfolio.',
682
+ );
683
+ }
684
+ if (item.artifact.authoring === 'html') {
685
+ const safety = validateHtmlSafety({
686
+ html: item.content,
687
+ shell: item.artifact.shellContent,
688
+ shellName: item.artifact.shell ?? item.artifact.template,
689
+ });
690
+ if (!safety.valid) {
691
+ throw codedError(
692
+ 'E_QA',
693
+ `Corrected artifact ${artifactId} failed DOM safety validation.`,
694
+ );
695
+ }
696
+ }
697
+ await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
698
+ await writeTextAtomic(state.run.runRoot, item.contentPath, item.content);
699
+ state.resolvedArtifacts[artifactIndex] = item.artifact;
700
+ state.authoredContent.set(artifactId, item.content);
701
+ state.contentPaths.set(artifactId, item.contentPath);
702
+ if (item.artifact.authoring === 'markdown') {
703
+ const links = expansionLinks(state.resolvedArtifacts);
704
+ const model = assertValidContentModel(
705
+ state.recipe,
706
+ markdownContentModel(
707
+ item.artifact,
708
+ state.run.slug,
709
+ item.content,
710
+ item.artifact.origin === 'floor' ? links : [],
711
+ ),
712
+ item.artifact,
713
+ );
714
+ const modelIndex = state.contentModels.findIndex(
715
+ ({ artifactId: id }) => id === artifactId,
716
+ );
717
+ state.contentModels[modelIndex] = model;
718
+ }
719
+ const rendered =
720
+ item.artifact.authoring === 'markdown'
721
+ ? await renderArtifact({
722
+ recipeArtifact: renderDescriptor(item.artifact),
723
+ content: state.contentModels.find(
724
+ ({ artifactId: id }) => id === artifactId,
725
+ ),
726
+ factBase: state.factBase,
727
+ theme: state.theme,
728
+ renderStrategy: state.renderStrategy,
729
+ ...(state.run.request.publicBaseUrl && {
730
+ publicBaseUrl: state.run.request.publicBaseUrl,
731
+ }),
732
+ })
733
+ : artisticRender(state, item.artifact);
734
+ await writeTextAtomic(
735
+ state.run.runRoot,
736
+ rendered.renderedPath,
737
+ rendered.html,
738
+ );
739
+ const renderedIndex = state.rendered.findIndex(
740
+ ({ artifactId: id }) => id === artifactId,
741
+ );
742
+ state.rendered[renderedIndex] = rendered;
743
+ state.artifacts[renderedIndex] = artifactRecord(state, rendered);
744
+ changes.push({
745
+ artifactId,
746
+ contentPath: item.contentPath,
747
+ authorResultPath: item.resultPath,
748
+ previousHash: hashBytes(previousContent),
749
+ revisedHash: hashBytes(item.content),
750
+ });
751
+ }
752
+ state.visualReviewPaths.push(
753
+ ...(await writeVisualRevision(state.run, { artifactIds, changes })),
754
+ );
755
+ }
756
+
341
757
  function isHardQaIssue(code) {
342
758
  return (
343
759
  [
@@ -350,6 +766,27 @@ function isHardQaIssue(code) {
350
766
  );
351
767
  }
352
768
 
769
+ function isReviewGateError(error) {
770
+ return ['E_VISUAL_REVIEW', 'E_VISUAL_CORRECTION'].includes(error?.code);
771
+ }
772
+
773
+ function normalizeReviewGateError(state, error) {
774
+ 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);
778
+ }
779
+ return null;
780
+ }
781
+
782
+ function reviewGateWarning(error) {
783
+ const reason =
784
+ error?.code === 'E_VISUAL_CORRECTION'
785
+ ? 'correction-failed'
786
+ : 'review-chain-failed';
787
+ return `visual-review-required:${reason}:${String(error?.message ?? 'unknown visual review failure')}`;
788
+ }
789
+
353
790
  function artisticRender(state, artifact) {
354
791
  const renderedPath = artifactPath(renderDescriptor(artifact), state.run.slug);
355
792
  const publicBaseUrl = state.run.request.publicBaseUrl?.replace(/\/+$/g, '');
@@ -366,27 +803,67 @@ function artisticRender(state, artifact) {
366
803
  };
367
804
  }
368
805
 
369
- async function loadResumableRun(request) {
806
+ function normalizeRunRequest(request) {
370
807
  const normalized = structuredClone(request);
371
- normalized.theme = {
372
- ...(normalized.theme ?? {}),
373
- renderStrategy: normalized.theme?.renderStrategy ?? 'default-only',
374
- };
375
- const runRoot = join(resolve(normalized.outputRoot), normalized.slug);
808
+ if (
809
+ normalized.recipe?.id === 'project-recap' &&
810
+ normalized.recapMode === undefined
811
+ ) {
812
+ normalized.recapMode = 'artistic';
813
+ }
814
+ return normalized;
815
+ }
816
+
817
+ async function loadResumableRun(request, resumeToken) {
818
+ const normalized = structuredClone(request);
819
+ let canonicalOutputRoot;
820
+ try {
821
+ canonicalOutputRoot = await realpath(resolve(normalized.outputRoot));
822
+ } catch (error) {
823
+ if (error?.code === 'ENOENT') return null;
824
+ throw error;
825
+ }
826
+ const runRoot = join(canonicalOutputRoot, normalized.slug);
827
+ let runRootStats;
828
+ try {
829
+ runRootStats = await lstat(runRoot);
830
+ } catch (error) {
831
+ if (error?.code === 'ENOENT') return null;
832
+ throw error;
833
+ }
834
+ if (runRootStats.isSymbolicLink() || !runRootStats.isDirectory()) {
835
+ throw codedError(
836
+ 'E_APPROVAL_RESUME',
837
+ 'The resumable run root must be a real directory, not a symbolic link.',
838
+ );
839
+ }
840
+ const canonicalRunRoot = await realpath(runRoot);
841
+ if (
842
+ canonicalRunRoot !== runRoot ||
843
+ !isStrictDescendant(canonicalOutputRoot, canonicalRunRoot)
844
+ ) {
845
+ throw codedError(
846
+ 'E_APPROVAL_RESUME',
847
+ 'The resumable run root escapes the configured output root.',
848
+ );
849
+ }
376
850
  let approval;
377
851
  let record;
378
- let persistedRequest;
379
852
  try {
380
- [approval, record, persistedRequest] = await Promise.all([
853
+ [approval, record] = await Promise.all([
381
854
  readJson(join(runRoot, 'source/content-approval.json')),
382
855
  readJson(join(runRoot, 'build-record.json')),
383
- readJson(join(runRoot, 'run-request.json')),
384
856
  ]);
385
857
  } catch (error) {
386
858
  if (error?.code === 'ENOENT') return null;
387
859
  throw error;
388
860
  }
389
-
861
+ if (typeof record.runId !== 'string') {
862
+ throw codedError(
863
+ 'E_APPROVAL_RESUME',
864
+ 'The resumable run has no valid retained run identity.',
865
+ );
866
+ }
390
867
  const approvalUnresolved = ['pending', 'rejected'].includes(approval.status);
391
868
  const completedBeforeApproval = ['content', 'theme', 'render', 'qa'].every(
392
869
  (id) =>
@@ -401,41 +878,112 @@ async function loadResumableRun(request) {
401
878
  ) {
402
879
  return null;
403
880
  }
881
+ const resumableRun = {
882
+ runId: record.runId,
883
+ slug: normalized.slug,
884
+ outputRoot: canonicalOutputRoot,
885
+ runRoot: canonicalRunRoot,
886
+ requestPath: join(canonicalRunRoot, 'run-request.json'),
887
+ buildRecordPath: join(canonicalRunRoot, 'build-record.json'),
888
+ manifestPath: join(canonicalRunRoot, 'manifest.json'),
889
+ };
890
+ await verifySetPlanResumeToken(resumableRun, resumeToken);
891
+
892
+ let persistedRequest;
893
+ try {
894
+ persistedRequest = await readJson(resumableRun.requestPath);
895
+ } catch (error) {
896
+ if (error?.code === 'ENOENT') return null;
897
+ throw error;
898
+ }
899
+ if (
900
+ typeof persistedRequest.outputRoot !== 'string' ||
901
+ (isAbsolute(persistedRequest.outputRoot) &&
902
+ persistedRequest.outputRoot !== canonicalOutputRoot)
903
+ ) {
904
+ throw codedError(
905
+ 'E_APPROVAL_RESUME',
906
+ 'The resumable run does not match the original canonical output root.',
907
+ );
908
+ }
909
+
910
+ const currentPersistedRequest = canonicalPersistedRunRequest(normalized, {
911
+ outputRoot: canonicalOutputRoot,
912
+ });
404
913
  if (
405
- persistedRequest.slug !== normalized.slug ||
406
- persistedRequest.recipe?.id !== normalized.recipe.id ||
407
- persistedRequest.recipe?.version !== normalized.recipe.version ||
408
- persistedRequest.mode !== normalized.mode ||
409
- canonicalHash(persistedRequest.factBase) !==
410
- canonicalHash(normalized.factBase)
914
+ canonicalHash(persistedRequest) !== canonicalHash(currentPersistedRequest)
411
915
  ) {
412
916
  throw codedError(
413
917
  'E_APPROVAL_RESUME',
414
- 'The resumable run does not match the current request identity.',
918
+ 'The resumable run does not match the complete canonical request.',
415
919
  );
416
920
  }
417
921
 
418
- const canonicalRunRoot = join(persistedRequest.outputRoot, normalized.slug);
922
+ const resumedRequest = structuredClone(currentPersistedRequest);
923
+ if (
924
+ normalized.theme?.artDirection !== undefined &&
925
+ resumedRequest.theme.artDirection === undefined
926
+ ) {
927
+ resumedRequest.theme.artDirection = normalized.theme.artDirection;
928
+ }
419
929
  return {
420
- runId: record.runId,
421
- slug: normalized.slug,
422
- outputRoot: persistedRequest.outputRoot,
423
- runRoot: canonicalRunRoot,
424
- requestPath: join(canonicalRunRoot, 'run-request.json'),
425
- buildRecordPath: join(canonicalRunRoot, 'build-record.json'),
426
- manifestPath: join(canonicalRunRoot, 'manifest.json'),
427
- request: normalized,
930
+ ...resumableRun,
931
+ request: resumedRequest,
428
932
  };
429
933
  }
430
934
 
935
+ function isStrictDescendant(root, candidate) {
936
+ const pathFromRoot = relative(root, candidate);
937
+ return (
938
+ pathFromRoot.length > 0 &&
939
+ pathFromRoot !== '..' &&
940
+ !pathFromRoot.startsWith(`..${sep}`) &&
941
+ !isAbsolute(pathFromRoot)
942
+ );
943
+ }
944
+
431
945
  async function hydrateResumableState(state) {
432
946
  const [factBase, approval, theme, record] = await Promise.all([
433
947
  readJson(join(state.run.runRoot, 'source/fact-base.json')),
434
- readJson(join(state.run.runRoot, 'source/content-approval.json')),
948
+ readContentApproval(state.run).catch((error) => {
949
+ throw codedError(
950
+ 'E_APPROVAL_RESUME',
951
+ `Retained content approval is invalid: ${error.message}`,
952
+ );
953
+ }),
435
954
  readJson(join(state.run.runRoot, 'theme.resolved.json')),
436
955
  readJson(state.run.buildRecordPath),
437
956
  ]);
957
+ for (const [kind, value] of [
958
+ ['fact-base', factBase],
959
+ ['theme', theme],
960
+ ['build-record', record],
961
+ ]) {
962
+ const validation = validateContract(kind, value);
963
+ if (!validation.valid) {
964
+ throw codedError(
965
+ 'E_APPROVAL_RESUME',
966
+ `Retained ${kind} record is invalid during approval resume.`,
967
+ );
968
+ }
969
+ }
970
+ const retainedPlan = await readSetPlanRecords(state.run, {
971
+ factBase,
972
+ recipe: state.recipe,
973
+ });
974
+ const portfolioValidation = validatePlannedPortfolio(
975
+ state.recipe,
976
+ retainedPlan.plan.portfolio,
977
+ );
978
+ if (!portfolioValidation.valid) {
979
+ throw codedError(
980
+ 'E_APPROVAL_RESUME',
981
+ `Retained set-plan portfolio is invalid: ${portfolioValidation.errors.join('; ')}`,
982
+ );
983
+ }
438
984
  state.factBase = factBase;
985
+ state.setPlan = retainedPlan.plan;
986
+ state.setPlanPaths = retainedPlan.paths;
439
987
  state.theme = theme;
440
988
  state.themeWarnings = [];
441
989
  state.resumedApprovalStatus = approval.status;
@@ -452,14 +1000,41 @@ async function hydrateResumableState(state) {
452
1000
  state.inputHashes = inputHashes(state.factBase);
453
1001
  state.factBaseHash = canonicalHash(state.factBase);
454
1002
  state.contentModels = [];
455
- const persistedArtifacts = Array.isArray(approval.artifacts)
456
- ? approval.artifacts
457
- : recipeFloor(state.recipe).map((artifact) => ({
458
- artifactId: artifact.id,
459
- origin: 'floor',
460
- authoring: artifact.authoring ?? 'markdown',
461
- contentPath: `source/content/${artifact.id}.md`,
462
- }));
1003
+ const persistedArtifacts = validateResumedArtifactBindings(state, approval);
1004
+ for (const persisted of persistedArtifacts) {
1005
+ const authorPath = await resolveRootConfinedPath(
1006
+ state.run.runRoot,
1007
+ persisted.authorResultPath,
1008
+ );
1009
+ if (!authorPath.valid) {
1010
+ throw codedError(
1011
+ 'E_APPROVAL_RESUME',
1012
+ `Retained author path for ${persisted.artifactId} is not confined to the run.`,
1013
+ );
1014
+ }
1015
+ const authorResult = await readJson(authorPath.absolutePath);
1016
+ const validation = validateContract('author-result/v2', authorResult);
1017
+ if (!validation.valid || authorResult.artifactId !== persisted.artifactId) {
1018
+ throw codedError(
1019
+ 'E_APPROVAL_RESUME',
1020
+ `Retained author result identity for ${persisted.artifactId} is invalid.`,
1021
+ );
1022
+ }
1023
+ }
1024
+ const confinedContent = new Map();
1025
+ for (const persisted of persistedArtifacts) {
1026
+ const contentPath = await resolveRootConfinedPath(
1027
+ state.run.runRoot,
1028
+ persisted.contentPath,
1029
+ );
1030
+ if (!contentPath.valid) {
1031
+ throw codedError(
1032
+ 'E_APPROVAL_RESUME',
1033
+ `Retained content path for ${persisted.artifactId} is not confined to the run.`,
1034
+ );
1035
+ }
1036
+ confinedContent.set(persisted.artifactId, contentPath.absolutePath);
1037
+ }
463
1038
  for (const persisted of persistedArtifacts) {
464
1039
  const artifact = resolvedArtifactFromApproval(state.recipe, persisted);
465
1040
  if (artifact.authoring === 'html') {
@@ -468,15 +1043,13 @@ async function hydrateResumableState(state) {
468
1043
  );
469
1044
  }
470
1045
  const content = await readFile(
471
- join(state.run.runRoot, persisted.contentPath),
1046
+ confinedContent.get(persisted.artifactId),
472
1047
  'utf8',
473
1048
  );
474
1049
  state.resolvedArtifacts.push(artifact);
475
1050
  state.authoredContent.set(artifact.id, content);
476
1051
  state.contentPaths.set(artifact.id, persisted.contentPath);
477
- if (persisted.authorResultPath) {
478
- state.authorResultPaths.push(persisted.authorResultPath);
479
- }
1052
+ state.authorResultPaths.push(persisted.authorResultPath);
480
1053
  }
481
1054
  const links = expansionLinks(state.resolvedArtifacts);
482
1055
  for (const artifact of state.resolvedArtifacts) {
@@ -513,6 +1086,7 @@ async function hydrateRenderedState(state) {
513
1086
  content: state.contentModels.find(
514
1087
  ({ artifactId }) => artifactId === artifact.id,
515
1088
  ),
1089
+ factBase: state.factBase,
516
1090
  theme: state.theme,
517
1091
  renderStrategy: state.renderStrategy,
518
1092
  ...(state.run.request.publicBaseUrl && {
@@ -641,7 +1215,8 @@ async function buildFactBase(binding, options, now) {
641
1215
  return {
642
1216
  source: {
643
1217
  ...source,
644
- hash: hashBytes(serialized),
1218
+ ...(options.sourceProvenance?.[source.id] ?? {}),
1219
+ hash: raw.sourceHash ?? hashBytes(serialized),
645
1220
  observedAt: raw.observedAt ?? now(),
646
1221
  },
647
1222
  claims: raw.claims,
@@ -694,7 +1269,7 @@ async function executeDurabilityAndPublish(state, options, now) {
694
1269
  if (strategy === 'none') {
695
1270
  await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
696
1271
  await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
697
- return;
1272
+ return false;
698
1273
  }
699
1274
 
700
1275
  if (strategy === 'commit') {
@@ -719,30 +1294,44 @@ async function executeDurabilityAndPublish(state, options, now) {
719
1294
  ],
720
1295
  });
721
1296
  await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
722
- return;
1297
+ return false;
723
1298
  }
724
1299
 
725
1300
  await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
726
1301
  await updateBuildRecord(state.run, { id: 'publish', status: 'running' });
727
- await persistManifest(state, now());
728
1302
  if (typeof options.publish !== 'function') {
729
1303
  throw codedError(
730
1304
  'E_PUBLISH',
731
1305
  'Publish durability was requested without an explicit publisher callback.',
732
1306
  );
733
1307
  }
734
- await options.publish({
735
- request: structuredClone(state.run.request.durability.publish),
736
- runRoot: state.run.runRoot,
737
- manifestPath: state.run.manifestPath,
738
- });
739
1308
  await updateBuildRecord(state.run, {
740
1309
  id: 'publish',
741
1310
  status: 'warned',
742
1311
  warnings: [
743
- 'Publishing completed; verified receipt evidence must be recorded separately.',
1312
+ 'Publishing requires separately retained verified receipt evidence.',
744
1313
  ],
745
1314
  });
1315
+ await persistManifest(state, now());
1316
+ try {
1317
+ await options.publish({
1318
+ request: structuredClone(state.run.request.durability.publish),
1319
+ runRoot: state.run.runRoot,
1320
+ manifestPath: state.run.manifestPath,
1321
+ });
1322
+ } catch (error) {
1323
+ await updateBuildRecord(state.run, {
1324
+ id: 'publish',
1325
+ 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
+ },
1331
+ });
1332
+ throw error;
1333
+ }
1334
+ return true;
746
1335
  }
747
1336
 
748
1337
  async function persistManifest(state, createdAt) {
@@ -754,6 +1343,16 @@ async function persistManifest(state, createdAt) {
754
1343
  await immutableHashesFor(state),
755
1344
  );
756
1345
  await writeManifestAtomic(state.run, manifest);
1346
+ const publicBaseUrl =
1347
+ state.run.request.publicBaseUrl ??
1348
+ state.run.request.durability?.publish?.publicBaseUrl;
1349
+ if (publicBaseUrl) {
1350
+ await writeJsonAtomic(
1351
+ state.run.runRoot,
1352
+ initiativeCatalogPath(manifest.slug),
1353
+ catalogFromManifest(manifest, publicBaseUrl),
1354
+ );
1355
+ }
757
1356
  return manifest;
758
1357
  }
759
1358
 
@@ -796,6 +1395,7 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
796
1395
  factBasePath: 'source/fact-base.json',
797
1396
  factBaseHash: state.factBaseHash,
798
1397
  inputHashes: state.inputHashes,
1398
+ backlinks: manifestSourceBacklinks(state.factBase),
799
1399
  ...(state.authorResultPaths.length > 0 && {
800
1400
  authorResultPaths: state.authorResultPaths,
801
1401
  }),
@@ -816,6 +1416,33 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
816
1416
  };
817
1417
  }
818
1418
 
1419
+ function manifestSourceBacklinks(factBase) {
1420
+ const backlinks = [
1421
+ ...(factBase?.sources ?? [])
1422
+ .filter(({ url }) => typeof url === 'string')
1423
+ .map(({ id, url }) => ({ sourceId: id, url })),
1424
+ ...(factBase?.claims ?? [])
1425
+ .flatMap(({ citations }) => citations ?? [])
1426
+ .filter(({ url }) => typeof url === 'string')
1427
+ .map(({ sourceId, url }) => ({ sourceId, url })),
1428
+ ...(factBase?.unresolvedClaims ?? [])
1429
+ .flatMap(({ citations }) => citations ?? [])
1430
+ .filter(({ url }) => typeof url === 'string')
1431
+ .map(({ sourceId, url }) => ({ sourceId, url })),
1432
+ ];
1433
+ return [
1434
+ ...new Map(
1435
+ backlinks
1436
+ .sort(
1437
+ (left, right) =>
1438
+ left.sourceId.localeCompare(right.sourceId) ||
1439
+ left.url.localeCompare(right.url),
1440
+ )
1441
+ .map((backlink) => [`${backlink.sourceId}\0${backlink.url}`, backlink]),
1442
+ ).values(),
1443
+ ];
1444
+ }
1445
+
819
1446
  async function createAuthoredContent(state, options, now) {
820
1447
  const author = options.author;
821
1448
  if (typeof author !== 'function') {
@@ -825,64 +1452,33 @@ async function createAuthoredContent(state, options, now) {
825
1452
  );
826
1453
  }
827
1454
  const trust = authorTrustContext(options, now);
828
-
829
- const floor = await Promise.all(
830
- recipeFloor(state.recipe).map((artifact) =>
831
- authorArtifact(
832
- state,
833
- {
834
- ...artifact,
835
- origin: 'floor',
836
- shell: artifact.authoring === 'html' ? artifact.template : undefined,
837
- },
838
- author,
839
- trust,
840
- ),
841
- ),
842
- );
843
- const proposals = floor.flatMap(
844
- ({ result }) => result.proposedArtifacts ?? [],
845
- );
846
- state.expansion = evaluateExpansionProposals(state.recipe, proposals);
847
- if (!state.expansion.valid) {
848
- throw codedError(
849
- 'E_AUTHOR_RESULT',
850
- `Invalid expansion proposals: ${state.expansion.errors.join('; ')}`,
851
- );
852
- }
853
-
854
- const expansions = [];
855
- for (const accepted of state.expansion.accepted) {
856
- const profile = accepted.profile;
857
- const item = await authorArtifact(
858
- state,
859
- {
860
- id: accepted.id,
861
- type: profile.type,
862
- authoring: profile.authoring,
863
- briefRef: profile.briefRef,
864
- shell: profile.shell,
865
- template:
866
- profile.authoring === 'markdown'
867
- ? templateForType(profile.type)
868
- : profile.shell,
869
- required: false,
870
- origin: 'expansion',
871
- profileId: profile.profileId,
872
- },
873
- author,
874
- trust,
875
- );
1455
+ const artifacts = plannedArtifacts(state.recipe, state.setPlan);
1456
+ state.expansion = {
1457
+ valid: true,
1458
+ accepted: artifacts
1459
+ .filter(({ origin }) => origin === 'expansion')
1460
+ .map((artifact) => ({
1461
+ id: artifact.id,
1462
+ profileId: artifact.profileId,
1463
+ rationale: artifact.plannedArtifact.justification.rationale,
1464
+ status: 'accepted',
1465
+ profile: expansionProfile(state.recipe, artifact.profileId),
1466
+ })),
1467
+ rejected: [],
1468
+ warnings: [],
1469
+ errors: [],
1470
+ };
1471
+ const authored = [];
1472
+ for (const artifact of artifacts) {
1473
+ const item = await authorArtifact(state, artifact, author, trust);
876
1474
  if ((item.result.proposedArtifacts ?? []).length > 0) {
877
1475
  throw codedError(
878
1476
  'E_AUTHOR_RESULT',
879
- `Expansion artifact ${accepted.id} cannot propose nested artifacts.`,
1477
+ `Artifact ${artifact.id} cannot change the validated set plan.`,
880
1478
  );
881
1479
  }
882
- expansions.push(item);
1480
+ authored.push(item);
883
1481
  }
884
-
885
- const authored = [...floor, ...expansions];
886
1482
  state.resolvedArtifacts = authored.map(({ artifact }) => artifact);
887
1483
  const links = expansionLinks(state.resolvedArtifacts);
888
1484
  for (const item of authored) {
@@ -982,12 +1578,20 @@ function resolveAuthorProvenance(claimed, trust, artifactId) {
982
1578
  };
983
1579
  }
984
1580
 
985
- async function authorArtifact(state, artifact, author, trust) {
986
- const brief = await readSkillFile(artifact.briefRef);
987
- const shellContent =
1581
+ async function authorArtifact(
1582
+ state,
1583
+ artifact,
1584
+ author,
1585
+ trust,
1586
+ correctionContext,
1587
+ ) {
1588
+ const [brief, visualAuthoringGuidance, shellContent] = await Promise.all([
1589
+ readSkillFile(artifact.briefRef),
1590
+ readSkillFile('references/visual-authoring.md'),
988
1591
  artifact.authoring === 'html'
989
- ? await readSkillFile(`templates/${artifact.shell}.html`)
990
- : undefined;
1592
+ ? readSkillFile(`templates/${artifact.shell}.html`)
1593
+ : undefined,
1594
+ ]);
991
1595
  const resolvedArtifact = {
992
1596
  ...artifact,
993
1597
  ...(shellContent && { shellContent }),
@@ -996,15 +1600,33 @@ async function authorArtifact(state, artifact, author, trust) {
996
1600
  artifact.origin === 'floor'
997
1601
  ? recipeRequiredNarrative(state.recipe, artifact.id)
998
1602
  : [];
1603
+ const plannedDiagrams = diagramAnalyses(artifact.plannedArtifact.draft);
1604
+ const graphSemantics = graphSemanticsForArtisticAuthor(plannedDiagrams);
1605
+ if (
1606
+ graphSemantics.length > 0 &&
1607
+ resolveDiagramRenderingRoute(state.recipe, artifact, plannedDiagrams) !==
1608
+ 'artistic'
1609
+ ) {
1610
+ throw codedError(
1611
+ 'E_DIAGRAM_TOPOLOGY',
1612
+ `Artifact ${artifact.id} cannot preserve its planner-owned non-linear graph through inline rendering.`,
1613
+ );
1614
+ }
999
1615
  const authorRequest = {
1000
1616
  schemaVersion: 'explainer-kit.author-request/v2',
1001
1617
  artifactId: artifact.id,
1002
1618
  artifactType: artifact.type,
1003
1619
  authoring: artifact.authoring,
1004
1620
  brief,
1621
+ visualAuthoringGuidance,
1005
1622
  factBase: structuredClone(state.factBase),
1006
1623
  ...(shellContent && { shell: shellContent }),
1007
1624
  theme: structuredClone(state.theme),
1625
+ setContext: structuredClone(state.setPlan),
1626
+ plannedArtifact: structuredClone(artifact.plannedArtifact),
1627
+ ...(graphSemantics.length > 0 && {
1628
+ graphSemantics: structuredClone(graphSemantics),
1629
+ }),
1008
1630
  ...(artifact.origin === 'floor' &&
1009
1631
  requiredNarrative.length > 0 && {
1010
1632
  floor: { requiredNarrative },
@@ -1021,7 +1643,13 @@ async function authorArtifact(state, artifact, author, trust) {
1021
1643
  );
1022
1644
  }
1023
1645
 
1024
- const result = await author(structuredClone(authorRequest));
1646
+ const result =
1647
+ correctionContext === undefined
1648
+ ? await author(structuredClone(authorRequest))
1649
+ : await author(
1650
+ structuredClone(authorRequest),
1651
+ structuredClone(correctionContext),
1652
+ );
1025
1653
  const resultValidation = validateContract('author-result/v2', result);
1026
1654
  if (!resultValidation.valid) {
1027
1655
  throw codedError(
@@ -1036,6 +1664,26 @@ async function authorArtifact(state, artifact, author, trust) {
1036
1664
  `Author result for ${artifact.id} must match its identity and ${artifact.authoring} path.`,
1037
1665
  );
1038
1666
  }
1667
+ if (artifact.authoring === 'markdown') {
1668
+ const diagrams = diagramAnalyses(content);
1669
+ if (
1670
+ diagrams.length > 0 &&
1671
+ resolveDiagramRenderingRoute(state.recipe, artifact, diagrams) ===
1672
+ 'reject'
1673
+ ) {
1674
+ const features = [
1675
+ ...new Set(
1676
+ diagrams.flatMap(({ topology }) => topology?.features ?? []),
1677
+ ),
1678
+ ].join(', ');
1679
+ throw codedError(
1680
+ 'E_DIAGRAM_TOPOLOGY',
1681
+ `Artifact ${artifact.id} contains ${features || 'non-linear'} diagram topology that requires artistic composition; inline rendering is rejected.`,
1682
+ );
1683
+ }
1684
+ } else if (graphSemantics.length > 0) {
1685
+ assertAuthoredGraphSemantics(content, graphSemantics);
1686
+ }
1039
1687
  const retained = {
1040
1688
  ...structuredClone(result),
1041
1689
  provenance: resolveAuthorProvenance(result.provenance, trust, artifact.id),
@@ -1070,6 +1718,23 @@ async function authorArtifact(state, artifact, author, trust) {
1070
1718
  };
1071
1719
  }
1072
1720
 
1721
+ function diagramAnalyses(markdown) {
1722
+ const ast = parseMarkdown(markdown);
1723
+ const diagrams = [];
1724
+ const visit = (nodes) => {
1725
+ for (const node of nodes) {
1726
+ if (node.type === 'diagram') {
1727
+ diagrams.push(parseDiagram(node.source));
1728
+ }
1729
+ if (Array.isArray(node.children)) {
1730
+ visit(node.children);
1731
+ }
1732
+ }
1733
+ };
1734
+ visit(ast.children);
1735
+ return diagrams;
1736
+ }
1737
+
1073
1738
  async function readSkillFile(relativePath) {
1074
1739
  return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
1075
1740
  }
@@ -1193,6 +1858,54 @@ function approvalArtifacts(state) {
1193
1858
  }));
1194
1859
  }
1195
1860
 
1861
+ function validateResumedArtifactBindings(state, approval) {
1862
+ const expected = plannedArtifacts(state.recipe, state.setPlan).map(
1863
+ (artifact) => ({
1864
+ artifactId: artifact.id,
1865
+ origin: artifact.origin,
1866
+ ...(artifact.profileId && { profileId: artifact.profileId }),
1867
+ authoring: artifact.authoring,
1868
+ contentPath: `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
1869
+ authorResultPath: `source/author/${artifact.id}.json`,
1870
+ }),
1871
+ );
1872
+ if (
1873
+ !Array.isArray(approval.artifacts) ||
1874
+ !Array.isArray(approval.authorResultPaths)
1875
+ ) {
1876
+ throw codedError(
1877
+ 'E_APPROVAL_RESUME',
1878
+ 'Approval resume requires retained artifact and author-result bindings.',
1879
+ );
1880
+ }
1881
+ const persistedById = new Map(
1882
+ approval.artifacts.map((artifact) => [artifact.artifactId, artifact]),
1883
+ );
1884
+ if (
1885
+ persistedById.size !== expected.length ||
1886
+ canonicalHash(approval.authorResultPaths) !==
1887
+ canonicalHash(expected.map(({ authorResultPath }) => authorResultPath))
1888
+ ) {
1889
+ throw codedError(
1890
+ 'E_APPROVAL_RESUME',
1891
+ 'Approval artifact set does not match the retained set-plan portfolio.',
1892
+ );
1893
+ }
1894
+ for (const artifact of expected) {
1895
+ const persisted = persistedById.get(artifact.artifactId);
1896
+ if (
1897
+ persisted === undefined ||
1898
+ canonicalHash(persisted) !== canonicalHash(artifact)
1899
+ ) {
1900
+ throw codedError(
1901
+ 'E_APPROVAL_RESUME',
1902
+ `Approval artifact binding for ${artifact.artifactId} has drifted from the retained set plan.`,
1903
+ );
1904
+ }
1905
+ }
1906
+ return expected;
1907
+ }
1908
+
1196
1909
  function expansionLinks(artifacts) {
1197
1910
  return artifacts
1198
1911
  .filter(({ origin }) => origin === 'expansion')
@@ -1283,8 +1996,14 @@ async function immutableHashesFor(state) {
1283
1996
  'source/fact-base.json',
1284
1997
  'source/fact-base.md',
1285
1998
  'source/content-approval.json',
1999
+ ...state.setPlanPaths,
1286
2000
  ...state.authorResultPaths,
1287
2001
  ...state.contentPaths.values(),
2002
+ ...state.browserEvidence.flatMap(({ screenshotPath, metricsPath }) => [
2003
+ screenshotPath,
2004
+ metricsPath,
2005
+ ]),
2006
+ ...state.visualReviewPaths,
1288
2007
  ...(state.theme ? ['theme.resolved.json'] : []),
1289
2008
  ...state.artifacts
1290
2009
  .filter(
@@ -1341,7 +2060,9 @@ function resultFor(state, error) {
1341
2060
  ? 'failed'
1342
2061
  : state.approval?.canResume === false
1343
2062
  ? 'incomplete'
1344
- : 'built-not-durable',
2063
+ : state.reviewGateBlocked
2064
+ ? 'built-needs-review'
2065
+ : 'built-not-durable',
1345
2066
  ...(state.approval?.record?.marking && {
1346
2067
  marking: state.approval.record.marking,
1347
2068
  }),
@@ -1354,8 +2075,12 @@ function resultFor(state, error) {
1354
2075
  ...(state.approval.record.marking && {
1355
2076
  marking: state.approval.record.marking,
1356
2077
  }),
2078
+ ...(state.resumeToken && { resumeToken: state.resumeToken }),
1357
2079
  },
1358
2080
  }),
2081
+ ...(state.visualReview && {
2082
+ visualReview: structuredClone(state.visualReview.result),
2083
+ }),
1359
2084
  ...(error && {
1360
2085
  errors: [{ code: error.code ?? 'E_RUN', message: safeMessage(error) }],
1361
2086
  }),