@open-agent-toolkit/cli 0.2.26 → 0.2.28

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 (76) hide show
  1. package/assets/NOTICES.md +156 -0
  2. package/assets/agents/oat-phase-implementer.md +312 -7
  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/implementation-execution.md +151 -1
  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-dispatch-subagents/SKILL.md +16 -3
  45. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  46. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  47. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  48. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  49. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  50. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  51. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  52. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +2 -2
  53. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +2 -2
  54. package/assets/skills/oat-project-implement/SKILL.md +5 -4
  55. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +21 -0
  56. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +2 -2
  57. package/assets/skills/oat-project-implement/references/phase-execution.md +359 -12
  58. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +2 -2
  59. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +2 -2
  60. package/assets/templates/state.md +7 -0
  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/release/public-package-contract.d.ts +6 -0
  74. package/dist/release/public-package-contract.d.ts.map +1 -1
  75. package/dist/release/public-package-contract.js +75 -0
  76. package/package.json +2 -2
@@ -1,5 +1,12 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { assertBrowserProbeSession } from './browser-runtime.mjs';
5
+ import { writeJsonAtomic } from './fs-safe.mjs';
1
6
  import { findUnpinnedResourceRefs } from './html-safety.mjs';
7
+ import { decodeBrowserPng } from './png.mjs';
2
8
  import { recipeFloor, recipeRequiredNarrative } from './recipes.mjs';
9
+ import { cohesionEvidenceFromLedger } from './visual-review.mjs';
3
10
 
4
11
  const VOID_ELEMENTS = new Set([
5
12
  'area',
@@ -21,6 +28,7 @@ const INLINE_ASSET_VIOLATION_PATTERN =
21
28
  /<link\b|@import\b|url\(\s*["']?(?!data:|#)/i;
22
29
  const TOKEN_PATTERN = /{{\s*[A-Z][A-Z0-9_]*\s*}}/g;
23
30
  const ARROW_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
31
+ const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
24
32
  const INLINE_DIAGRAM_PATTERN =
25
33
  /<svg\b(?=[^>]*(?:\bclass\s*=\s*["'][^"']*\bdiagram\b|\baria-label\s*=\s*["'][^"']*(?:architecture|diagram)))[^>]*>/i;
26
34
  const STRUCTURED_BLOCK_PATTERNS = [
@@ -420,12 +428,33 @@ export function checkHtmlStructure({
420
428
  return { valid: issues.length === 0, issues };
421
429
  }
422
430
 
423
- export function checkArtifactCohesion(artifacts) {
431
+ export function checkArtifactCohesion(artifacts, { ledger = null } = {}) {
424
432
  if (!Array.isArray(artifacts)) {
425
433
  throw new TypeError('Artifact cohesion input must be an array.');
426
434
  }
427
435
  const issues = [];
428
436
  const groups = ['terminology', 'numericClaims', 'statuses'];
437
+ const expected = ledger
438
+ ? {
439
+ terminology: new Map(
440
+ (ledger.terminology ?? []).map(({ term }) => [term, term]),
441
+ ),
442
+ numericClaims: new Map(
443
+ (ledger.numbers ?? []).map(({ subject, value }) => [subject, value]),
444
+ ),
445
+ statuses: new Map(
446
+ (ledger.statuses ?? []).map(({ subject, value }) => [subject, value]),
447
+ ),
448
+ }
449
+ : null;
450
+
451
+ if (expected && groups.some((group) => expected[group].size === 0)) {
452
+ issues.push({
453
+ code: 'cohesion-ledger-empty',
454
+ message:
455
+ 'Adaptive recap cohesion requires non-empty terminology, numeric, and status ledger entries.',
456
+ });
457
+ }
429
458
 
430
459
  for (const group of groups) {
431
460
  const claims = new Map();
@@ -458,6 +487,38 @@ export function checkArtifactCohesion(artifacts) {
458
487
  }
459
488
  }
460
489
  }
490
+ if (expected) {
491
+ for (const [claim, expectedValue] of expected[group]) {
492
+ const observed = claims.get(claim);
493
+ if (
494
+ !observed ||
495
+ observed.normalized !== normalizeClaim(expectedValue)
496
+ ) {
497
+ issues.push({
498
+ code: 'cohesion-claim-unobserved',
499
+ message: `Rendered artifacts do not observably support ${group}.${claim}.`,
500
+ claim,
501
+ });
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ if (expected) {
508
+ for (const artifact of artifacts) {
509
+ const count = groups.reduce(
510
+ (total, group) =>
511
+ total + Object.keys(artifact?.cohesion?.[group] ?? {}).length,
512
+ 0,
513
+ );
514
+ if (count === 0) {
515
+ issues.push({
516
+ code: 'cohesion-observations-empty',
517
+ message: `Artifact ${String(artifact?.id)} has no observed shared-ledger evidence.`,
518
+ artifactId: artifact?.id,
519
+ });
520
+ }
521
+ }
461
522
  }
462
523
 
463
524
  return { valid: issues.length === 0, issues };
@@ -466,14 +527,36 @@ export function checkArtifactCohesion(artifacts) {
466
527
  export async function runBrowserProbes({
467
528
  artifacts,
468
529
  probe,
530
+ browserSession,
469
531
  widths = REPRESENTATIVE_WIDTHS,
532
+ evidenceRoot,
533
+ requireEvidence = false,
534
+ onProbeResult,
470
535
  }) {
471
536
  if (!Array.isArray(artifacts) || artifacts.length === 0) {
472
537
  throw new TypeError('Browser QA requires at least one artifact.');
473
538
  }
474
- if (typeof probe !== 'function') {
539
+ if (browserSession !== undefined && probe !== undefined) {
540
+ throw new TypeError(
541
+ 'Browser QA accepts either a trusted browser session or a bare non-retaining probe, not both.',
542
+ );
543
+ }
544
+ const session =
545
+ browserSession === undefined
546
+ ? null
547
+ : assertBrowserProbeSession(browserSession, { allowFixture: true });
548
+ const resolvedProbe = session?.probe ?? probe;
549
+ if (typeof resolvedProbe !== 'function') {
475
550
  throw new TypeError('Browser QA requires a probe callback.');
476
551
  }
552
+ if (evidenceRoot && !session) {
553
+ throw new TypeError(
554
+ 'A trusted browser session is required for retained evidence.',
555
+ );
556
+ }
557
+ if (onProbeResult !== undefined && typeof onProbeResult !== 'function') {
558
+ throw new TypeError('Browser QA probe observer must be a callback.');
559
+ }
477
560
  if (
478
561
  !Array.isArray(widths) ||
479
562
  widths.length === 0 ||
@@ -483,10 +566,21 @@ export async function runBrowserProbes({
483
566
  }
484
567
 
485
568
  const issues = [];
569
+ const evidence = [];
486
570
  let probes = 0;
487
571
  for (const artifact of artifacts) {
572
+ const evidenceId = browserEvidenceId(artifact.id);
488
573
  for (const width of widths) {
489
574
  for (const scenario of browserScenarios(artifact)) {
575
+ const viewport = viewportName(width);
576
+ const screenshotPath =
577
+ evidenceRoot && scenario === 'default'
578
+ ? `qa/browser/${evidenceId}/${viewport}.png`
579
+ : undefined;
580
+ const metricsPath =
581
+ evidenceRoot && scenario === 'default'
582
+ ? `qa/browser/${evidenceId}/${viewport}.json`
583
+ : undefined;
490
584
  const request = {
491
585
  artifact,
492
586
  scenario,
@@ -500,6 +594,9 @@ export async function runBrowserProbes({
500
594
  artifact.type === 'deck' && scenario === 'default'
501
595
  ? { tab: true, arrows: [...ARROW_KEYS] }
502
596
  : { tab: true },
597
+ ...(screenshotPath && {
598
+ screenshotPath: join(evidenceRoot, screenshotPath),
599
+ }),
503
600
  ...(scenario === 'no-js' && { javascriptEnabled: false }),
504
601
  ...(scenario === 'print' && { media: 'print' }),
505
602
  ...(artifact.type === 'deck' &&
@@ -520,11 +617,62 @@ export async function runBrowserProbes({
520
617
  },
521
618
  }),
522
619
  };
523
- const result = await probe(request);
620
+ let result;
621
+ try {
622
+ result = await resolvedProbe(request);
623
+ } catch (cause) {
624
+ const error = new Error(
625
+ `Browser evidence callback failed: ${cause?.message ?? String(cause)}`,
626
+ { cause },
627
+ );
628
+ error.code = 'E_VISUAL_REVIEW';
629
+ throw error;
630
+ }
524
631
  probes += 1;
525
632
  validateProbeResult(result, artifact.id, width, request);
633
+ if (onProbeResult) {
634
+ try {
635
+ await onProbeResult(
636
+ structuredClone({
637
+ artifactId: artifact.id,
638
+ artifactType: artifact.type,
639
+ scenario,
640
+ viewport: request.viewport,
641
+ result,
642
+ }),
643
+ );
644
+ } catch (cause) {
645
+ const error = new Error(
646
+ `Browser evidence observer failed: ${cause?.message ?? String(cause)}`,
647
+ { cause },
648
+ );
649
+ error.code = 'E_VISUAL_REVIEW';
650
+ throw error;
651
+ }
652
+ }
526
653
 
527
654
  const context = { artifactId: artifact.id, width, scenario };
655
+ if (screenshotPath && metricsPath) {
656
+ const retained = await retainBrowserEvidence({
657
+ evidenceRoot,
658
+ artifactId: artifact.id,
659
+ viewport,
660
+ viewportSize: request.viewport,
661
+ screenshotPath,
662
+ metricsPath,
663
+ result,
664
+ browserSession: session,
665
+ });
666
+ if (retained.valid) {
667
+ evidence.push(retained.evidence);
668
+ } else if (requireEvidence) {
669
+ issues.push({
670
+ ...context,
671
+ code: 'browser-evidence-missing',
672
+ message: retained.message,
673
+ });
674
+ }
675
+ }
528
676
  if (result.pageOverflowX) {
529
677
  issues.push({
530
678
  ...context,
@@ -625,29 +773,51 @@ export async function runBrowserProbes({
625
773
  }
626
774
  }
627
775
 
628
- return { valid: issues.length === 0, issues, probes };
776
+ return {
777
+ valid: issues.length === 0,
778
+ issues,
779
+ probes,
780
+ ...(evidenceRoot && { evidence }),
781
+ };
629
782
  }
630
783
 
631
784
  export async function auditArtifactSet({
632
785
  artifacts,
633
786
  denylist = [],
634
787
  browserProbe,
788
+ browserSession,
635
789
  widths,
790
+ evidenceRoot,
791
+ requireBrowserEvidence = false,
792
+ onProbeResult,
793
+ setPlan,
636
794
  }) {
637
795
  if (!Array.isArray(artifacts) || artifacts.length === 0) {
638
796
  throw new TypeError('Render QA requires at least one artifact.');
639
797
  }
640
798
 
641
- const structural = artifacts.map((artifact) => ({
799
+ const artifactsWithCohesion = cohesionEvidenceFromLedger(artifacts, setPlan);
800
+ const structural = artifactsWithCohesion.map((artifact) => ({
642
801
  id: artifact.id,
643
802
  ...checkHtmlStructure({ ...artifact, denylist }),
644
803
  }));
645
- const cohesion = checkArtifactCohesion(artifacts);
646
- const browser = browserProbe
804
+ const cohesion = checkArtifactCohesion(artifactsWithCohesion, {
805
+ ...(setPlan?.recipe?.id === 'project-recap' &&
806
+ Object.values(setPlan.ledger ?? {}).some(
807
+ (entries) => Array.isArray(entries) && entries.length > 0,
808
+ ) && {
809
+ ledger: setPlan.ledger,
810
+ }),
811
+ });
812
+ const browserProvider = browserSession ?? browserProbe;
813
+ const browser = browserProvider
647
814
  ? await runBrowserProbes({
648
815
  artifacts,
649
- probe: browserProbe,
816
+ ...(browserSession ? { browserSession } : { probe: browserProbe }),
650
817
  ...(widths && { widths }),
818
+ ...(evidenceRoot && { evidenceRoot }),
819
+ ...(requireBrowserEvidence && { requireEvidence: true }),
820
+ ...(onProbeResult !== undefined && { onProbeResult }),
651
821
  })
652
822
  : null;
653
823
  const issues = [
@@ -665,6 +835,108 @@ export async function auditArtifactSet({
665
835
  };
666
836
  }
667
837
 
838
+ async function retainBrowserEvidence({
839
+ evidenceRoot,
840
+ artifactId: artifactIdentifier,
841
+ viewport,
842
+ viewportSize,
843
+ screenshotPath,
844
+ metricsPath,
845
+ result,
846
+ browserSession,
847
+ }) {
848
+ let screenshot;
849
+ try {
850
+ screenshot = await stat(join(evidenceRoot, screenshotPath));
851
+ } catch {
852
+ return {
853
+ valid: false,
854
+ message: `Browser screenshot evidence is missing for ${artifactIdentifier} at ${viewportSize.width}px.`,
855
+ };
856
+ }
857
+ if (
858
+ !screenshot.isFile() ||
859
+ screenshot.size === 0 ||
860
+ screenshot.size > MAX_SCREENSHOT_BYTES
861
+ ) {
862
+ return {
863
+ valid: false,
864
+ message: `Browser screenshot evidence for ${artifactIdentifier} at ${viewportSize.width}px is empty or exceeds ${MAX_SCREENSHOT_BYTES} bytes.`,
865
+ };
866
+ }
867
+ const screenshotBytes = await readFile(join(evidenceRoot, screenshotPath));
868
+ const decoded = decodedPng(screenshotBytes);
869
+ if (
870
+ !decoded ||
871
+ decoded.width !== viewportSize.width ||
872
+ decoded.height !== viewportSize.height
873
+ ) {
874
+ return {
875
+ valid: false,
876
+ message: `Browser screenshot evidence for ${artifactIdentifier} at ${viewportSize.width}px must be a viewport-matched PNG.`,
877
+ };
878
+ }
879
+ await writeJsonAtomic(evidenceRoot, metricsPath, {
880
+ schemaVersion: 'explainer-kit.browser-evidence/v2',
881
+ artifactId: artifactIdentifier,
882
+ viewport,
883
+ viewportSize,
884
+ scenario: 'default',
885
+ runtime: structuredClone(browserSession.runtime),
886
+ capture: structuredClone(browserSession.capture),
887
+ captureIdentity: browserSession.captureIdentity,
888
+ screenshotPath,
889
+ metrics: structuredClone(result),
890
+ });
891
+ return {
892
+ valid: true,
893
+ evidence: {
894
+ artifactId: artifactIdentifier,
895
+ viewport,
896
+ width: viewportSize.width,
897
+ height: viewportSize.height,
898
+ screenshotPath,
899
+ decodedScreenshotHash: decoded.decodedHash,
900
+ metricsPath,
901
+ runtime: structuredClone(browserSession.runtime),
902
+ captureIdentity: browserSession.captureIdentity,
903
+ },
904
+ };
905
+ }
906
+
907
+ export function pngDimensions(bytes) {
908
+ const decoded = decodedPng(bytes);
909
+ return decoded ? { width: decoded.width, height: decoded.height } : null;
910
+ }
911
+
912
+ function decodedPng(bytes) {
913
+ try {
914
+ return decodeBrowserPng(bytes);
915
+ } catch {
916
+ return null;
917
+ }
918
+ }
919
+
920
+ function browserEvidenceId(value) {
921
+ if (
922
+ typeof value !== 'string' ||
923
+ !/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(value)
924
+ ) {
925
+ throw new TypeError('Browser evidence requires a safe artifact id.');
926
+ }
927
+ return value;
928
+ }
929
+
930
+ function viewportName(width) {
931
+ return (
932
+ {
933
+ 320: 'mobile',
934
+ 768: 'tablet',
935
+ 1440: 'desktop',
936
+ }[width] ?? `viewport-${width}`
937
+ );
938
+ }
939
+
668
940
  function checkHeadings(html, add) {
669
941
  const headings = [
670
942
  ...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi),
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
3
  const RECIPE_SCHEMA_V2 = 'explainer-kit.recipe/v2';
4
- const RECIPE_V2_ROOT_KEYS = [
4
+ const RECIPE_V2_REQUIRED_ROOT_KEYS = [
5
5
  'discoveryLimits',
6
6
  'expansion',
7
7
  'floor',
@@ -10,6 +10,7 @@ const RECIPE_V2_ROOT_KEYS = [
10
10
  'sourceRoles',
11
11
  'version',
12
12
  ];
13
+ const RECIPE_V2_ROOT_KEYS = [...RECIPE_V2_REQUIRED_ROOT_KEYS, 'fallback'];
13
14
  const SOURCE_ROLE_KEYS = [
14
15
  'accepts',
15
16
  'maxBindings',
@@ -34,9 +35,14 @@ const PROFILE_REQUIRED_KEYS = [
34
35
  'profileId',
35
36
  'type',
36
37
  ];
37
- const PROFILE_KEYS = [...PROFILE_REQUIRED_KEYS, 'shell'];
38
+ const PROFILE_KEYS = [
39
+ ...PROFILE_REQUIRED_KEYS,
40
+ 'allowedJustificationKinds',
41
+ 'shell',
42
+ ];
38
43
  const EXPANSION_LIMIT_KEYS = ['maxArtifacts', 'maxPerType'];
39
44
  const DISCOVERY_LIMIT_KEYS = ['consecutiveNoNewFindingsRounds', 'maxRounds'];
45
+ const FALLBACK_KEYS = ['authoring', 'mode', 'scope', 'selection'];
40
46
  const SOURCE_KINDS = new Set([
41
47
  'file',
42
48
  'directory',
@@ -81,6 +87,54 @@ export function loadRecipe(id, version) {
81
87
  return structuredClone(recipe);
82
88
  }
83
89
 
90
+ export function selectRecipeAuthoring(recipe, mode = 'artistic') {
91
+ const selected = structuredClone(recipe);
92
+ if (mode === 'artistic') {
93
+ return selected;
94
+ }
95
+ if (selected.fallback?.mode !== mode) {
96
+ const error = new Error(
97
+ `Recipe ${selected.id}@${selected.version} does not support recap mode ${mode}`,
98
+ );
99
+ error.code = 'E_RECIPE_UNSUPPORTED';
100
+ throw error;
101
+ }
102
+
103
+ selected.floor = selected.floor.map((artifact) => ({
104
+ ...artifact,
105
+ authoring: selected.fallback.authoring,
106
+ }));
107
+ selected.expansion.profiles = selected.expansion.profiles.map(
108
+ ({ shell: _shell, ...profile }) => ({
109
+ ...profile,
110
+ authoring: selected.fallback.authoring,
111
+ }),
112
+ );
113
+ return selected;
114
+ }
115
+
116
+ export function resolveDiagramRenderingRoute(recipe, artifact, diagrams) {
117
+ if (
118
+ !recipe ||
119
+ typeof recipe !== 'object' ||
120
+ !artifact ||
121
+ typeof artifact !== 'object' ||
122
+ !Array.isArray(diagrams)
123
+ ) {
124
+ throw new TypeError(
125
+ 'Diagram routing requires a recipe, artifact, and diagram analyses.',
126
+ );
127
+ }
128
+ const supported = diagrams.filter(({ valid }) => valid === true);
129
+ if (
130
+ supported.length === 0 ||
131
+ supported.every(({ inlineSupported }) => inlineSupported === true)
132
+ ) {
133
+ return 'inline';
134
+ }
135
+ return artifact.authoring === 'html' ? 'artistic' : 'reject';
136
+ }
137
+
84
138
  export function recipeFloor(recipe) {
85
139
  if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
86
140
  return structuredClone(recipe.floor);
@@ -304,6 +358,54 @@ export function validateContentModel(recipe, contentModel) {
304
358
  return { valid: errors.length === 0, errors };
305
359
  }
306
360
 
361
+ export function validatePlannedPortfolio(recipe, portfolio) {
362
+ const errors = [];
363
+ if (!Array.isArray(portfolio)) {
364
+ return { valid: false, errors: ['Planned portfolio must be an array'] };
365
+ }
366
+ const floorIds = new Set(recipeFloor(recipe).map(({ id }) => id));
367
+ const expansion = recipeExpansion(recipe);
368
+ const profiles = new Map(
369
+ expansion.profiles.map((profile) => [profile.profileId, profile]),
370
+ );
371
+ const counts = new Map();
372
+ let optionalCount = 0;
373
+
374
+ for (const artifact of portfolio) {
375
+ if (floorIds.has(artifact?.artifactId)) continue;
376
+ optionalCount += 1;
377
+ const profile = profiles.get(artifact?.profileId);
378
+ if (!profile) {
379
+ errors.push(`Unknown planned profile: ${artifact?.profileId}`);
380
+ continue;
381
+ }
382
+ counts.set(profile.profileId, (counts.get(profile.profileId) ?? 0) + 1);
383
+ if (!isObject(artifact.justification)) {
384
+ errors.push(
385
+ `Optional artifact ${artifact.artifactId} requires a source-backed justification`,
386
+ );
387
+ continue;
388
+ }
389
+ if (
390
+ profile.allowedJustificationKinds !== undefined &&
391
+ !profile.allowedJustificationKinds.includes(artifact.justification.kind)
392
+ ) {
393
+ errors.push(
394
+ `Optional artifact ${artifact.artifactId} justification ${artifact.justification.kind} is not allowed for ${profile.profileId}`,
395
+ );
396
+ }
397
+ }
398
+ if (optionalCount > expansion.limits.maxArtifacts) {
399
+ errors.push('Planned portfolio exceeds the recipe optional-artifact limit');
400
+ }
401
+ for (const [profileId, count] of counts) {
402
+ if (count > profiles.get(profileId).maxCount) {
403
+ errors.push(`Planned portfolio exceeds the ${profileId} profile limit`);
404
+ }
405
+ }
406
+ return { valid: errors.length === 0, errors };
407
+ }
408
+
307
409
  export function shouldStopDiscovery(recipe, findingsByRound) {
308
410
  if (
309
411
  !Array.isArray(findingsByRound) ||
@@ -329,7 +431,12 @@ export function shouldStopDiscovery(recipe, findingsByRound) {
329
431
  export function validateRecipe(recipe, file = 'recipe') {
330
432
  assertObject(recipe, `${file} recipe`);
331
433
  if (recipe.schemaVersion === RECIPE_SCHEMA_V2) {
332
- assertExactKeys(recipe, RECIPE_V2_ROOT_KEYS, `${file} recipe`);
434
+ assertAllowedKeys(
435
+ recipe,
436
+ RECIPE_V2_ROOT_KEYS,
437
+ RECIPE_V2_REQUIRED_ROOT_KEYS,
438
+ `${file} recipe`,
439
+ );
333
440
  validateV2Shape(recipe, file);
334
441
  } else {
335
442
  assert(false, `${file} has unsupported schemaVersion`);
@@ -368,6 +475,22 @@ export function validateRecipe(recipe, file = 'recipe') {
368
475
  }
369
476
  assertUnique(roleNames, `${file} source role names`);
370
477
 
478
+ if ('fallback' in recipe) {
479
+ assert(
480
+ recipe.id === 'project-recap',
481
+ `${file} fallback is allowed only for project-recap`,
482
+ );
483
+ assertObject(recipe.fallback, `${file} fallback`);
484
+ assertExactKeys(recipe.fallback, FALLBACK_KEYS, `${file} fallback`);
485
+ assert(
486
+ recipe.fallback.mode === 'deterministic-markdown' &&
487
+ recipe.fallback.selection === 'explicit' &&
488
+ recipe.fallback.authoring === 'markdown' &&
489
+ recipe.fallback.scope === 'portfolio',
490
+ `${file} has an unsupported fallback policy`,
491
+ );
492
+ }
493
+
371
494
  validateDiscoveryLimits(recipe.discoveryLimits, file);
372
495
  return recipe;
373
496
  }
@@ -435,6 +558,12 @@ function validateV2Shape(recipe, file) {
435
558
  `${file} expansion profile briefRef`,
436
559
  );
437
560
  assertFiniteCount(profile.maxCount, `${file} expansion profile maxCount`);
561
+ if ('allowedJustificationKinds' in profile) {
562
+ assertUniqueNonEmptyStrings(
563
+ profile.allowedJustificationKinds,
564
+ `${file} expansion profile allowedJustificationKinds`,
565
+ );
566
+ }
438
567
  if (profile.authoring === 'html' || 'shell' in profile) {
439
568
  assertNonEmptyString(
440
569
  profile.shell,