@nextcommerce/campaigns-os 1.54.0 → 1.57.0

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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { specHashOf, specHashesMatch } from "./spec-hash.mjs";
14
+ import { CHECKOUT_FLOW_PAGE_TYPES, upgradeCampaignSpec } from "../campaign-spec/dist/index.js";
14
15
 
15
16
  export const CALC_LABEL = "Campaigns-calculated · before tax";
16
17
  export const CALCULATED_PAIR_EVIDENCE = "calculated_pair";
@@ -213,6 +214,20 @@ function catalogImportedAt(mapDoc) {
213
214
  ?? null;
214
215
  }
215
216
 
217
+ // The page's type in the v5 vocabulary: a v4 Map's `checkout → checkout` chain
218
+ // reads as `checkout_step` pages ahead of the one Checkout (campaigns-os#641).
219
+ const upgradedMaps = new WeakMap();
220
+ function v5PageType(mapDoc, page) {
221
+ if (!mapDoc || typeof mapDoc !== "object") return page?.type ?? null;
222
+ if (!upgradedMaps.has(mapDoc)) upgradedMaps.set(mapDoc, upgradeCampaignSpec(mapDoc));
223
+ const upgraded = upgradedMaps.get(mapDoc);
224
+ for (const funnel of array(upgraded?.funnels)) {
225
+ const hit = array(funnel?.pages).find((candidate) => present(page?.id) && String(candidate?.id) === String(page.id));
226
+ if (hit) return hit.type ?? null;
227
+ }
228
+ return page?.type ?? null;
229
+ }
230
+
216
231
  function descriptor(page, mapDoc, role, lines, options = {}) {
217
232
  const funnel = findFunnel(mapDoc, page);
218
233
  const suffix = options.id_suffix ? `:${options.id_suffix}` : "";
@@ -221,7 +236,7 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
221
236
  upsell: page?.type === "upsell" || page?.type === "downsell",
222
237
  context: {
223
238
  page_id: page?.id ?? null,
224
- page_type: page?.type ?? null,
239
+ page_type: v5PageType(mapDoc, page),
225
240
  page_label: page?.label ?? page?.name ?? page?.id ?? null,
226
241
  page_order: Number(page?.order) || 0,
227
242
  funnel_id: funnel?.id ?? null,
@@ -1045,8 +1060,16 @@ function acceptDelta(page) {
1045
1060
  return moneyFact(signedCents(cents), total.state);
1046
1061
  }
1047
1062
 
1063
+ // The checkout total is the cart the shopper pays for at the Checkout. That cart
1064
+ // is priced on whichever checkout-flow page carries it: the select page of a
1065
+ // two-step flow, the first step of a split checkout, or the Checkout itself
1066
+ // (campaigns-os#641). The Checkout of a multi-step path carries no packages, so
1067
+ // looking for the page typed "checkout" priced nothing on those flows. Only
1068
+ // pages that declare packages are planned, so every page here carries a cart;
1069
+ // pages arrive sorted by page_order, and with several paths the first is
1070
+ // summarized.
1048
1071
  function journeySummary(pages) {
1049
- const checkout = pages.find((page) => page.page_type === "checkout");
1072
+ const checkout = pages.find((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.page_type));
1050
1073
  const checkoutTotal = checkout?.representative_total
1051
1074
  || unresolvedFact("Unresolved: checkout scenario unavailable");
1052
1075
  const bumps = pages.flatMap((page) => page.bumps.map((bump) => ({ ...bump, page_id: page.page_id })));
package/src/consent.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
+ import { timeOperatorWait } from "./lifecycle.mjs";
17
18
 
18
19
  export const TELEMETRY_ENV_VAR = "CAMPAIGNS_OS_TELEMETRY";
19
20
  export const TELEMETRY_CONFIG_SCHEMA = "campaigns-os-telemetry-config/v0";
@@ -337,9 +338,9 @@ export async function promptAndPersistConsent({
337
338
  if (existing.resolved) return { ...existing, prompted: false };
338
339
  if (!isTTY) return { state: "off", source: "default", resolved: false, prompted: false };
339
340
 
340
- const answer = await ask(
341
+ const answer = await timeOperatorWait(() => ask(
341
342
  "Campaigns OS can send build telemetry and minimal stage observations to Next Commerce to improve templates, tools, and guidance; stage observations support Workspace progress. Share telemetry from this machine? [Y/n] (change any time): ",
342
- );
343
+ ));
343
344
  const normalized = String(answer || "").trim().toLowerCase();
344
345
  const state = (normalized === "" || ["y", "yes", "1", "true", "on"].includes(normalized)) ? "on" : "off";
345
346
  writeConsentConfig(state, { configPath, proxyBase, source: "prompt", now });
@@ -2085,7 +2085,7 @@ function sourceReferenceKindForManifestFile(file) {
2085
2085
  function normalizedPageRole(type) {
2086
2086
  const normalized = slugify(type || "page");
2087
2087
  if (["landing", "product", "presell", "advertorial"].includes(normalized)) return "landing";
2088
- if (["checkout", "select", "cart"].includes(normalized)) return "checkout";
2088
+ if (["checkout", "checkout-step", "checkout_step", "select", "cart"].includes(normalized)) return "checkout";
2089
2089
  if (["upsell", "oto", "one-time-offer"].includes(normalized)) return "upsell";
2090
2090
  if (["downsell", "downsell-offer"].includes(normalized)) return "downsell";
2091
2091
  if (["receipt", "thank-you", "thankyou", "confirmation"].includes(normalized)) return "receipt";
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { accessSync, constants as fsConstants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { writeThemeArtifacts } from "./brand-theme.mjs";
7
- import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
7
+ import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, portableArtifactPaths, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
8
8
  import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
9
9
  import { stampDoctorProducer } from "./doctor-sidecar.mjs";
10
10
  import { stageWriteInputs } from "./input-currency.mjs";
@@ -21,7 +21,7 @@ import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
21
21
  // states never count as accumulated agent evidence. setup's seed state may
22
22
  // legitimately be "skipped" (scaffold already present); every other stage is
23
23
  // seeded "pending" with empty ledger arrays.
24
- function assemblyReportStagesWithEvidence(existingReport) {
24
+ export function assemblyReportStagesWithEvidence(existingReport) {
25
25
  const stages = existingReport?.stages;
26
26
  if (!isObject(stages)) return [];
27
27
  const withEvidence = [];
@@ -69,7 +69,7 @@ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {
69
69
  throw new Error(
70
70
  `Assembly report at ${reportPath} ${announced ? "gained stage evidence while prepare-build was running" : "already carries stage evidence"} (${stageKeys.join(", ")}). `
71
71
  + `Rerunning prepare-build/start/build would reset ${stageKeys.length === 1 ? "this stage" : "these stages"} to pending and destroy that evidence. `
72
- + `Pass --force to overwrite (destructive).`,
72
+ + `If the brief or CampaignSpec changed, run record brief or record spec instead to bind it while keeping that evidence. Pass --force only to overwrite (destructive).`,
73
73
  );
74
74
  }
75
75
  const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
@@ -979,10 +979,10 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
979
979
  : [],
980
980
  );
981
981
  publishPrepareBuildJsonOutputs([
982
- { label: "Build Packet", path: packetPath, value: packet },
983
- { label: "Campaign Build Brief", path: briefPath, value: brief },
984
- { label: "Build Context", path: contextPath, value: context },
985
- { label: "Assembly Report", path: reportPath, value: report },
982
+ { label: "Build Packet", path: packetPath, value: portableArtifactPaths(packet, targetRepo, { artifactPath: packetPath }) },
983
+ { label: "Campaign Build Brief", path: briefPath, value: portableArtifactPaths(brief, targetRepo, { artifactPath: briefPath }) },
984
+ { label: "Build Context", path: contextPath, value: portableArtifactPaths(context, targetRepo, { artifactPath: contextPath }) },
985
+ { label: "Assembly Report", path: reportPath, value: portableArtifactPaths(report, targetRepo, { artifactPath: reportPath }) },
986
986
  ], prepareBuildCollisionPaths, {
987
987
  beforePublish: recheckStageEvidence,
988
988
  });
@@ -997,7 +997,7 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
997
997
  // stamped with the intake command that ran doctor (`start` or `build`,
998
998
  // #312) without a second write path for it.
999
999
  publishPrepareBuildJsonOutputs([
1000
- { label: "Doctor Output", path: doctorOutPath, value: stampDoctorProducer(doctor, command) },
1000
+ { label: "Doctor Output", path: doctorOutPath, value: portableArtifactPaths(stampDoctorProducer(doctor, command), targetRepo) },
1001
1001
  ], prepareBuildCollisionPaths);
1002
1002
  },
1003
1003
  }));
package/src/deviation.mjs CHANGED
@@ -15,7 +15,7 @@ export const DEVIATION_JOURNAL_REL_PATH = ".campaign-runtime/agent-deviations.js
15
15
 
16
16
  // Commands that advance the pipeline. Read-only / bookkeeping commands
17
17
  // (doctor, next, findings, telemetry, run, validate-*) never deviate.
18
- export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["start", "prepare-build", "theme", "polish", "qa", "run-record"]));
18
+ export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["prepare-build", "theme", "polish", "qa", "run-record"]));
19
19
 
20
20
  // Setup and metadata subcommands of a tracked command. They produce no stage
21
21
  // output, so running one outside the recommendation is not a detour:
@@ -77,6 +77,7 @@ export function buildRecommendation({ stage, status, expectedCommands, now = new
77
77
  * recommendation. Returns a deviation entry or null.
78
78
  */
79
79
  export function detectDeviation({ lastRecommendation, command, subcommand = null, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
80
+ if (argvShape.includes("--help")) return null;
80
81
  if (!TRACKED_STAGE_COMMANDS.has(command)) return null;
81
82
  if (subcommand && UNTRACKED_SUBCOMMANDS.has(`${command} ${subcommand}`)) return null;
82
83
  if (!lastRecommendation || !Array.isArray(lastRecommendation.expected_commands)) return null;
@@ -93,6 +93,7 @@ import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvena
93
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
94
  import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
95
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
96
+ import { assemblyReportStagesWithEvidence } from "../design-source-publication.mjs";
96
97
  import {
97
98
  assemblySourcePackageFingerprintMissing,
98
99
  assessAssemblySourcePackageFreshnessWaivers,
@@ -108,7 +109,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
108
109
  // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
109
110
  // node engine in package.json without type-stripping. build runs on `prepare`,
110
111
  // so a fresh install (including the git-ref consumer) always has dist.
111
- import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
112
+ import { CHECKOUT_FLOW_PAGE_TYPES, checkoutPathFrom, isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules, upgradeCampaignSpec } from "../../campaign-spec/dist/index.js";
112
113
  import { cmd, asInvocation } from "../install-invocation.mjs";
113
114
  import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
114
115
  import {
@@ -237,8 +238,12 @@ const US_MARKET_COPY_PATTERNS = [
237
238
  const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
238
239
  const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
239
240
 
241
+ // Pages read through here see the v5 vocabulary: a v4 spec's `checkout → checkout`
242
+ // chain is retyped `checkout_step` on read (campaigns-os#641), so every doctor
243
+ // check asks "is this the page that takes payment" of `type === "checkout"`.
240
244
  function normalizeFunnels(spec) {
241
- if (Array.isArray(spec?.funnels)) return spec.funnels;
245
+ const upgraded = upgradeCampaignSpec(spec);
246
+ if (Array.isArray(upgraded?.funnels)) return upgraded.funnels;
242
247
  if (Array.isArray(spec?.funnel_pages)) {
243
248
  return [{ id: "default", weight: 100, pages: spec.funnel_pages }];
244
249
  }
@@ -440,7 +445,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
440
445
  {
441
446
  id: SDK_MARKUP,
442
447
  phase: "built-output",
443
- run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
448
+ run: ({ spec, packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived, spec),
444
449
  },
445
450
  {
446
451
  id: SCRIPT_SYNTAX,
@@ -542,6 +547,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
542
547
  addIssue(errors, "packet.type", "Build Packet must be a JSON object.");
543
548
  return;
544
549
  }
550
+ Object.defineProperty(derived, "page_kit_entry_mappings", {
551
+ value: Object.fromEntries((packet.source_html?.pages || [])
552
+ .filter((page) => page?.page_kit?.target_path).map((page) => [page.page_id, page])),
553
+ configurable: true,
554
+ });
545
555
  const synthesizedBuiltSite = isSynthesizedBuiltSitePacket(packet);
546
556
  if (packet.schema_version !== PACKET_SCHEMA) addIssue(errors, "schema_version", `Expected ${PACKET_SCHEMA}.`);
547
557
  else ready.push(`Build Packet schema ${PACKET_SCHEMA}`);
@@ -832,7 +842,7 @@ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, re
832
842
  const packetContract = packet.source_html?.adapter_contract;
833
843
  validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
834
844
  validateAdapterSourceFiles({
835
- decisions: packetContract,
845
+ decisions: buildState.report?.adapter_decisions || buildState.context?.adapter_decisions || packetContract,
836
846
  sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
837
847
  pages: packet.source_html?.pages || [],
838
848
  warnings,
@@ -1201,17 +1211,20 @@ function paymentLogoForcedOn(html, method) {
1201
1211
  // unless the checkout page passes show_<method>=false.
1202
1212
  export const STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS = Object.freeze(["paypal", "klarna", "apple_pay", "google_pay"]);
1203
1213
 
1204
- // Built checkout pages on disk for the spec's active checkout pages: the
1205
- // rendered _site/<slug>/<route>/index.html files that exist. Empty before a
1206
- // build (or when the spec declares no checkout page), which is the pre-build
1207
- // state the spec-only advisory covers.
1214
+ // Built checkout-flow pages on disk for the spec's active select, checkout_step
1215
+ // and checkout pages: the rendered _site/<slug>/<route>/index.html files that
1216
+ // exist. Steps and select pages are included on purpose: they carry express
1217
+ // payment buttons upstream of the Checkout (campaigns-os#641), and an
1218
+ // unsupported method rendered there ships just the same. Empty before a build
1219
+ // (or when the spec declares no checkout page), which is the pre-build state
1220
+ // the spec-only advisory covers.
1208
1221
  function builtCheckoutPagesForSpec(spec, packet, derived = {}) {
1209
1222
  const targetRepo = derived?.target_repo;
1210
1223
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1211
1224
  if (!targetRepo || !publicRouteSlug) return [];
1212
1225
  const built = [];
1213
1226
  for (const page of activeSpecPages(spec)) {
1214
- if (String(page?.type || page?.page_type || "").toLowerCase().trim() !== "checkout") continue;
1227
+ if (!CHECKOUT_FLOW_PAGE_TYPES.includes(String(page?.type || page?.page_type || "").toLowerCase().trim())) continue;
1215
1228
  const path = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1216
1229
  if (!path || !existsSync(path) || !statSync(path).isFile()) continue;
1217
1230
  built.push({ page_id: page.id, path, file: relative(targetRepo, path).split(sep).join("/") });
@@ -2147,6 +2160,10 @@ export function validateCampaignIdentity(packet, errors, ready, derived, spec =
2147
2160
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2148
2161
  },
2149
2162
  pages,
2163
+ expectedApiKey: (() => {
2164
+ const resolved = resolveCampaignsApiKeySource(packet, null, process.env, { spec });
2165
+ return resolved.key ? { key: resolved.key, source: resolved.origin } : null;
2166
+ })(),
2150
2167
  errors,
2151
2168
  ready,
2152
2169
  derived,
@@ -2290,8 +2307,8 @@ function readBoundedScript(realSiteRoot, path, maxBytes) {
2290
2307
  }
2291
2308
  }
2292
2309
 
2293
- function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
2294
- const gate = evaluateCampaignIdentity({ subject, pages });
2310
+ function recordCampaignIdentityGate({ subject, pages, expectedApiKey = null, errors, ready, derived }) {
2311
+ const gate = evaluateCampaignIdentity({ subject, pages, expectedApiKey });
2295
2312
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
2296
2313
 
2297
2314
  if (gate.status === "blocked") {
@@ -2315,17 +2332,23 @@ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived })
2315
2332
  // Static SDK markup checks (#303). Every doctor invocation, both entry points,
2316
2333
  // filesystem enumeration, blocking regardless of stage status — the same
2317
2334
  // contract as the two gates above, for the same reasons.
2318
- function validateSdkMarkup(packet, errors, warnings, ready, derived) {
2335
+ function validateSdkMarkup(packet, errors, warnings, ready, derived, spec = null) {
2319
2336
  const targetRepo = derived.target_repo;
2320
2337
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2321
2338
  const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2322
2339
  const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
2340
+ const declaredTypes = new Map(activeSpecPages(spec).filter((page) => ["receipt", "thankyou"].includes(page.type)).map((page) => [
2341
+ builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived), "receipt",
2342
+ ]));
2323
2343
  recordSdkMarkupGate({
2324
2344
  subject: {
2325
2345
  public_route_slug: publicRouteSlug || null,
2326
2346
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2327
2347
  },
2328
- pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
2348
+ pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo).map((page) => ({
2349
+ ...page,
2350
+ page_type: declaredTypes.get(resolve(targetRepo, page.file)) || null,
2351
+ })) : [],
2329
2352
  errors,
2330
2353
  warnings,
2331
2354
  ready,
@@ -2805,7 +2828,7 @@ function bumpDisplaysPresent(block, displays) {
2805
2828
  return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
2806
2829
  }
2807
2830
 
2808
- const CHECKOUT_BUMP_PAGE_TYPES = new Set(["checkout", "select"]);
2831
+ const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
2809
2832
 
2810
2833
  export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
2811
2834
  const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
@@ -2990,6 +3013,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
2990
3013
 
2991
3014
  function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
2992
3015
  if (!targetRepo || !publicRouteSlug) return null;
3016
+ const entryRoute = entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
3017
+ targetOutputDir: derived?.target_output_dir,
3018
+ mapping: derived?.page_kit_entry_mappings?.[page?.id],
3019
+ });
3020
+ if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
2993
3021
  const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
2994
3022
  const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
2995
3023
  if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
@@ -2997,6 +3025,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
2997
3025
  return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
2998
3026
  }
2999
3027
 
3028
+ export function isRootRoutedEntryPage(page, publicRouteSlug) {
3029
+ return Boolean(page?.is_entry) && runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug) === "";
3030
+ }
3031
+
3032
+ // An explicit permalink belongs to this entry's Page Kit source file. It wins
3033
+ // even when an older build left output at the filename-derived route.
3034
+ export function entryPageKitServingRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
3035
+ if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
3036
+ const permalink = entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping);
3037
+ if (permalink !== null) return { route: permalink, source: "permalink" };
3038
+ const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
3039
+ if (existsSync(root) && statSync(root).isFile()) return { route: "", source: "root" };
3040
+ const fileRoute = entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir, mapping });
3041
+ return fileRoute ? { route: `${fileRoute}/`, source: "file" } : { route: "", source: "declared" };
3042
+ }
3043
+
3044
+ function entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) {
3045
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3046
+ const names = mapping?.page_kit?.target_path
3047
+ ? [mapping.page_kit.target_path]
3048
+ : [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
3049
+ const routes = names
3050
+ .filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name))
3051
+ .map((name) => join(targetOutputDir, name))
3052
+ .filter((path) => existsSync(path) && statSync(path).isFile())
3053
+ .map((path) => extractFrontmatterValue(readFileSync(path, "utf8"), "permalink"))
3054
+ .filter(isNonEmptyString)
3055
+ .map((value) => stripPublicRoutePrefix(normalizePageKitRoute(value), publicRouteSlug));
3056
+ return routes[0] ?? null;
3057
+ }
3058
+
3059
+ // Page Kit serves a page file without a permalink at its basename route.
3060
+ // Only the recorded target file (or the family's own materialised stock file)
3061
+ // may identify an entry fallback; an unrelated built index is not evidence.
3062
+ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
3063
+ if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
3064
+ if (entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) !== null) return null;
3065
+ const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
3066
+ if (existsSync(root) && statSync(root).isFile()) return null;
3067
+ let names;
3068
+ if (mapping?.page_kit?.target_path) {
3069
+ names = [mapping.page_kit.target_path];
3070
+ } else {
3071
+ if (!targetOutputDir) return null;
3072
+ names = [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
3073
+ }
3074
+ const routes = names
3075
+ .filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name) && name !== "index.html")
3076
+ .filter((name) => mapping?.page_kit || (existsSync(join(targetOutputDir, name)) && statSync(join(targetOutputDir, name)).isFile()))
3077
+ .map((name) => name.slice(0, -".html".length))
3078
+ .filter((route) => {
3079
+ const built = join(targetRepo, "_site", publicRouteSlug, route, "index.html");
3080
+ return existsSync(built) && statSync(built).isFile();
3081
+ });
3082
+ return routes[0] ?? null;
3083
+ }
3084
+
3000
3085
  function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
3001
3086
  if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3002
3087
 
@@ -3436,7 +3521,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
3436
3521
  }
3437
3522
 
3438
3523
  function pageRole(type) {
3439
- if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
3524
+ if (["checkout", "checkout_step", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
3440
3525
  return "visual";
3441
3526
  }
3442
3527
 
@@ -3540,6 +3625,7 @@ function templateStockDecision(buildState, pageId) {
3540
3625
  function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
3541
3626
  const pages = packet.source_html?.pages || [];
3542
3627
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
3628
+ const routeRoot = campaignRouteRoot(packet);
3543
3629
  const materialisedTemplateStock = [];
3544
3630
  const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
3545
3631
  validateSourceHtmlManifestAtRoot(sourceRoot, {
@@ -3567,6 +3653,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3567
3653
  }
3568
3654
  mappedIds.add(page.page_id);
3569
3655
  const specPage = activeById.get(page.page_id);
3656
+ const entryRoute = specPage
3657
+ ? entryPageKitServingRoute(derived.target_repo, publicRouteSlug, specPage, {
3658
+ targetOutputDir: derived.target_output_dir,
3659
+ mapping: page,
3660
+ })
3661
+ : null;
3662
+ const builtRoute = entryRoute ? entryRoute.route : specPage ? publicRouteForPage(specPage) : null;
3663
+ const rootOutput = entryRoute?.route ? join(derived.target_repo, "_site", publicRouteSlug, "index.html") : null;
3664
+ if (rootOutput && (entryRoute.source === "file" || entryRoute.source === "permalink")
3665
+ && !(existsSync(rootOutput) && statSync(rootOutput).isFile())) {
3666
+ const emittedRoute = `${routeRoot}${entryRoute.route}`;
3667
+ const pageKitRoot = `/${normalizePublicRouteSlug(publicRouteSlug)}/`;
3668
+ addIssue(warnings, "entry_route.unserved", `CampaignSpec entry page "${specPage.id}" is routed to ${routeRoot}, but Page Kit emitted it at ${emittedRoute} and nothing serves ${routeRoot}. Add a redirect from ${routeRoot} to ${emittedRoute}, or set \`permalink: ${pageKitRoot}\` (the campaign root as Page Kit emits it) on the entry page.`, { page_id: specPage.id, root_route: routeRoot, emitted_route: emittedRoute });
3669
+ }
3570
3670
  if (!activeIds.has(page.page_id)) {
3571
3671
  addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3572
3672
  }
@@ -3601,7 +3701,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3601
3701
  page_id: specPage.id,
3602
3702
  type: specPage.type || "page",
3603
3703
  role: pageRole(specPage.type),
3604
- route: publicRouteForPage(specPage),
3704
+ route: builtRoute,
3605
3705
  source_path: page.path,
3606
3706
  });
3607
3707
  }
@@ -3617,7 +3717,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3617
3717
  page_id: specPage.id,
3618
3718
  type: specPage.type || "page",
3619
3719
  role: pageRole(specPage.type),
3620
- route: publicRouteForPage(specPage),
3720
+ route: builtRoute,
3621
3721
  source_path: null,
3622
3722
  template_stock: true,
3623
3723
  template_family: family,
@@ -3946,6 +4046,98 @@ function packageRefsFromEntries(entries) {
3946
4046
  .filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
3947
4047
  .map((value) => String(value));
3948
4048
  }
4049
+
4050
+ function supportsCheckoutVariantSlots(agentContract) {
4051
+ const required = frontmatterList(agentContract, "requiredWhenCloning");
4052
+ return required.includes("variant_slots[].id") && required.includes("variant_slots[].quantity");
4053
+ }
4054
+
4055
+ function selectableVariantMatrixCount(packages) {
4056
+ const selectable = Array.isArray(packages)
4057
+ ? packages.filter((pkg) => pkg && pkg.is_order_bump !== true && pkg.is_upsell !== true)
4058
+ : [];
4059
+ const quantityRows = selectable.filter((pkg) => String(pkg.qty ?? "").trim());
4060
+ if (quantityRows.length <= 1) return 0;
4061
+ const attributeIdentity = (pkg) => {
4062
+ if (!Array.isArray(pkg.variant_attributes) || pkg.variant_attributes.length === 0) return null;
4063
+ const attributes = pkg.variant_attributes.map((attribute) => {
4064
+ const value = String(attribute?.value ?? "").trim();
4065
+ if (!value) return null;
4066
+ return [String(attribute?.code || attribute?.name || "").trim(), value];
4067
+ });
4068
+ return attributes.every(Boolean) ? JSON.stringify(attributes.sort(([a], [b]) => a.localeCompare(b))) : null;
4069
+ };
4070
+ const textIdentity = (key) => (pkg) => {
4071
+ const value = pkg[key];
4072
+ return typeof value === "string" && value.trim() ? value.trim() : null;
4073
+ };
4074
+ for (const identity of [attributeIdentity, textIdentity("product_variant_name")]) {
4075
+ const values = quantityRows.map(identity);
4076
+ if (!values.every(Boolean)) continue;
4077
+ const quantitiesByVariant = new Map();
4078
+ for (const [index, value] of values.entries()) {
4079
+ const qty = String(quantityRows[index].qty).trim();
4080
+ if (!quantitiesByVariant.has(value)) quantitiesByVariant.set(value, new Set());
4081
+ quantitiesByVariant.get(value).add(qty);
4082
+ }
4083
+ return quantitiesByVariant.size > 1 && [...quantitiesByVariant.values()].some((quantities) => quantities.size > 1)
4084
+ ? quantitiesByVariant.size
4085
+ : 0;
4086
+ }
4087
+ return 0;
4088
+ }
4089
+
4090
+ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, report) {
4091
+ const selectedContract = catalog.families?.[family]?.agentContract;
4092
+ if (
4093
+ !selectedContract ||
4094
+ !isCertifiedTemplateFamily(family, catalog) ||
4095
+ !frontmatterList(selectedContract, "requiredWhenCloning").includes("packages.main_package") ||
4096
+ supportsCheckoutVariantSlots(selectedContract)
4097
+ ) return;
4098
+ const fittingFamilies = [...certifiedTemplateFamilies(catalog)]
4099
+ .filter((candidate) => supportsCheckoutVariantSlots(catalog.families[candidate]?.agentContract))
4100
+ .sort();
4101
+ const checkoutPages = specPages.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type));
4102
+ const byFunnel = new Map();
4103
+ for (const page of checkoutPages) {
4104
+ if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
4105
+ byFunnel.get(page.funnel_id).push(page);
4106
+ }
4107
+ const paths = new Map();
4108
+ for (const page of checkoutPages) {
4109
+ const pages = byFunnel.get(page.funnel_id);
4110
+ const path = checkoutPathFrom(pages, page) || [page];
4111
+ const end = path[path.length - 1];
4112
+ const key = `${page.funnel_id}:${end.id}`;
4113
+ if (!paths.has(key)) paths.set(key, []);
4114
+ const pathPages = paths.get(key);
4115
+ for (const candidate of path) {
4116
+ if (!pathPages.includes(candidate)) pathPages.push(candidate);
4117
+ }
4118
+ }
4119
+ const hasStageEvidence = assemblyReportStagesWithEvidence(report).length > 0;
4120
+ const multiStep = [...paths.values()].some((pathPages) =>
4121
+ pathPages.some((candidate) => candidate.type === "checkout_step" || candidate.type === "select"));
4122
+ for (const pathPages of paths.values()) {
4123
+ const page = pathPages.find((candidate) => selectableVariantMatrixCount(candidate.packages) > 1);
4124
+ if (!page) continue;
4125
+ const variantCount = selectableVariantMatrixCount(page.packages);
4126
+ const shapeMatches = fittingFamilies.filter((candidate) => Boolean(catalog.families[candidate]?.canonicalSurfaces?.selectStep) === multiStep);
4127
+ // The catalog has no base-family field; its family-name prefix is the shared-base convention.
4128
+ const baseMatches = shapeMatches.filter((candidate) => candidate.startsWith(`${family}-`));
4129
+ const ranked = [...baseMatches, ...shapeMatches.filter((candidate) => !baseMatches.includes(candidate))];
4130
+ const action = hasStageEvidence
4131
+ ? "The template family is a build-time decision; changing it requires re-running intake with --force (destructive; clears recorded stage evidence)."
4132
+ : "The family is chosen at intake with --template-family.";
4133
+ addIssue(
4134
+ warnings,
4135
+ "template_contract.checkout_package_fit",
4136
+ `Checkout path through page "${page.id}" has ${variantCount} variants, some offered at several quantities, but template family "${family}" has no configurable variant_slots[] checkout surface. ${ranked.length ? `Families that can present it: ${ranked.join(", ")}.` : "No certified family has a matching configurable variant-slot checkout surface."} ${action}`,
4137
+ { page_id: page.id, funnel_id: page.funnel_id, template_family: family, distinct_product_variants: variantCount, fitting_families: ranked },
4138
+ );
4139
+ }
4140
+ }
3949
4141
  function offerRefsFromEntries(entries) {
3950
4142
  if (!Array.isArray(entries)) return [];
3951
4143
  return entries
@@ -4130,6 +4322,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4130
4322
 
4131
4323
  // ADR-003 step 2: template-contract checks ported from the private doctor.
4132
4324
  const specPages = activeSpecPages(spec);
4325
+ warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
4133
4326
 
4134
4327
  const mismatchedFamilies = specPages.filter(
4135
4328
  (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
@@ -4143,7 +4336,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4143
4336
  }
4144
4337
 
4145
4338
  const checkoutPackageRefs = specPages
4146
- .filter((page) => page.type === "checkout" || page.type === "select")
4339
+ .filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
4147
4340
  .flatMap((page) => packageRefsFromEntries(page.packages));
4148
4341
  if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
4149
4342
  addIssue(
@@ -4258,7 +4451,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
4258
4451
  const exitPop = contract?.exit_pop;
4259
4452
  if (!exitPop || !spec) return;
4260
4453
  const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
4261
- page?.type === "checkout" && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
4454
+ CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type) && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
4262
4455
  ));
4263
4456
  if (hasGovernedOfferSurface) {
4264
4457
  ready.push(`${family} exit-pop/promo-code behavior is governed by CampaignSpec offer-surface fields`);
@@ -34,6 +34,7 @@ import {
34
34
  readJsonIfExists,
35
35
  relFromDir,
36
36
  isLocalAbsolutePath,
37
+ portableArtifactPaths,
37
38
  addIssue,
38
39
  } from "../cli-helpers.mjs";
39
40
  import {
@@ -187,18 +188,19 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
187
188
  commitAssemblyReport(workspace, (report) => recordDoctorStageOutcome(report, result, {
188
189
  command: `campaigns-os ${DOCTOR_PRODUCER}`,
189
190
  doctorOutPath: workspace.doctorOutPath,
191
+ targetRepo: workspace.targetRepo,
190
192
  }), { stage: "doctor", command: DOCTOR_PRODUCER, refreshDoctor: () => result });
191
193
  }
192
194
  return result;
193
195
  }
194
196
 
195
- function recordDoctorStageOutcome(report, result, { command, doctorOutPath }) {
197
+ function recordDoctorStageOutcome(report, result, { command, doctorOutPath, targetRepo }) {
196
198
  return recordProducerStageOutcome(report, {
197
199
  stage: "doctor",
198
200
  disposition: result.ok ? (result.warnings?.length ? "ready_with_warnings" : "ready") : "blocked",
199
201
  timestamp: result.generated_at,
200
202
  command,
201
- outputs: [doctorOutPath],
203
+ outputs: portableArtifactPaths([doctorOutPath], targetRepo),
202
204
  blockers: (result.errors || []).map((issue) => issue?.message).filter(isNonEmptyString),
203
205
  warnings: (result.warnings || []).map((issue) => issue?.message).filter(isNonEmptyString),
204
206
  });