@nextcommerce/campaigns-os 1.55.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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  5. package/campaign-spec/dist/checkout-flow.js +229 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  45. package/contracts/release-ledger.json +705 -0
  46. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  47. package/contracts/supported-surface.json +8 -4
  48. package/docs/build-packet.md +52 -8
  49. package/docs/local-setup.md +1 -1
  50. package/docs/orientation-contract-reference.md +1 -1
  51. package/docs/qa-and-test-orders.md +78 -18
  52. package/docs/runtime-readiness.md +8 -3
  53. package/docs/skills-revision.md +10 -10
  54. package/docs/supported-surface.md +1 -1
  55. package/docs/versioning.md +4 -1
  56. package/package.json +2 -1
  57. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  58. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  59. package/schemas/campaign-spec.v5.schema.json +586 -0
  60. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  61. package/skills/campaign-readback-classification/SKILL.md +3 -3
  62. package/skills/campaign-run-evidence/SKILL.md +3 -3
  63. package/skills/contribution-intake/SKILL.md +3 -3
  64. package/skills/next-campaigns-build/SKILL.md +6 -4
  65. package/skills/next-campaigns-os/SKILL.md +3 -3
  66. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  67. package/skills/next-campaigns-polish/SKILL.md +21 -6
  68. package/skills/next-campaigns-qa/SKILL.md +4 -3
  69. package/skills.json +10 -10
  70. package/src/adapter-decision-contract.mjs +1 -1
  71. package/src/build-brief.mjs +14 -5
  72. package/src/cli-helpers.mjs +129 -0
  73. package/src/cli.mjs +121 -53
  74. package/src/commercial-journey.mjs +25 -2
  75. package/src/design-source-package.mjs +1 -1
  76. package/src/design-source-publication.mjs +7 -7
  77. package/src/doctor/checks.mjs +196 -13
  78. package/src/doctor/inspect.mjs +4 -2
  79. package/src/doctor/next-step.mjs +31 -11
  80. package/src/doctor-sidecar.mjs +5 -3
  81. package/src/gate-actions.mjs +21 -1
  82. package/src/input-refresh.mjs +6 -6
  83. package/src/local-preview-policy.mjs +110 -12
  84. package/src/polish-node.mjs +3 -3
  85. package/src/qa-browser.mjs +226 -34
  86. package/src/qa-cart-entry.mjs +78 -14
  87. package/src/qa-node.mjs +35 -33
  88. package/src/qa-test-order-topology.mjs +118 -1
  89. package/src/qa-verdict-discovery.mjs +21 -4
  90. package/src/runtime-state-ignore.mjs +7 -5
  91. package/src/source-html-intake.mjs +46 -4
  92. package/src/stage-ledger.mjs +4 -2
  93. package/src/stage-record.mjs +3 -3
@@ -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
  }
@@ -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}`);
@@ -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("/") });
@@ -2815,7 +2828,7 @@ function bumpDisplaysPresent(block, displays) {
2815
2828
  return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
2816
2829
  }
2817
2830
 
2818
- const CHECKOUT_BUMP_PAGE_TYPES = new Set(["checkout", "select"]);
2831
+ const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
2819
2832
 
2820
2833
  export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
2821
2834
  const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
@@ -3000,6 +3013,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
3000
3013
 
3001
3014
  function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3002
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");
3003
3021
  const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
3004
3022
  const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
3005
3023
  if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
@@ -3007,6 +3025,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3007
3025
  return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
3008
3026
  }
3009
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
+
3010
3085
  function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
3011
3086
  if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3012
3087
 
@@ -3446,7 +3521,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
3446
3521
  }
3447
3522
 
3448
3523
  function pageRole(type) {
3449
- 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";
3450
3525
  return "visual";
3451
3526
  }
3452
3527
 
@@ -3550,6 +3625,7 @@ function templateStockDecision(buildState, pageId) {
3550
3625
  function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
3551
3626
  const pages = packet.source_html?.pages || [];
3552
3627
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
3628
+ const routeRoot = campaignRouteRoot(packet);
3553
3629
  const materialisedTemplateStock = [];
3554
3630
  const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
3555
3631
  validateSourceHtmlManifestAtRoot(sourceRoot, {
@@ -3577,6 +3653,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3577
3653
  }
3578
3654
  mappedIds.add(page.page_id);
3579
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
+ }
3580
3670
  if (!activeIds.has(page.page_id)) {
3581
3671
  addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3582
3672
  }
@@ -3611,7 +3701,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3611
3701
  page_id: specPage.id,
3612
3702
  type: specPage.type || "page",
3613
3703
  role: pageRole(specPage.type),
3614
- route: publicRouteForPage(specPage),
3704
+ route: builtRoute,
3615
3705
  source_path: page.path,
3616
3706
  });
3617
3707
  }
@@ -3627,7 +3717,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3627
3717
  page_id: specPage.id,
3628
3718
  type: specPage.type || "page",
3629
3719
  role: pageRole(specPage.type),
3630
- route: publicRouteForPage(specPage),
3720
+ route: builtRoute,
3631
3721
  source_path: null,
3632
3722
  template_stock: true,
3633
3723
  template_family: family,
@@ -3956,6 +4046,98 @@ function packageRefsFromEntries(entries) {
3956
4046
  .filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
3957
4047
  .map((value) => String(value));
3958
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
+ }
3959
4141
  function offerRefsFromEntries(entries) {
3960
4142
  if (!Array.isArray(entries)) return [];
3961
4143
  return entries
@@ -4140,6 +4322,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4140
4322
 
4141
4323
  // ADR-003 step 2: template-contract checks ported from the private doctor.
4142
4324
  const specPages = activeSpecPages(spec);
4325
+ warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
4143
4326
 
4144
4327
  const mismatchedFamilies = specPages.filter(
4145
4328
  (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
@@ -4153,7 +4336,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4153
4336
  }
4154
4337
 
4155
4338
  const checkoutPackageRefs = specPages
4156
- .filter((page) => page.type === "checkout" || page.type === "select")
4339
+ .filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
4157
4340
  .flatMap((page) => packageRefsFromEntries(page.packages));
4158
4341
  if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
4159
4342
  addIssue(
@@ -4268,7 +4451,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
4268
4451
  const exitPop = contract?.exit_pop;
4269
4452
  if (!exitPop || !spec) return;
4270
4453
  const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
4271
- 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)
4272
4455
  ));
4273
4456
  if (hasGovernedOfferSurface) {
4274
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
  });
@@ -1,6 +1,6 @@
1
1
  // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
2
  import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
- import { polishCarriedForwardForLadder } from "../local-preview-policy.mjs";
3
+ import { NO_CAPTURABLE_ROUTES_CODE, hostedTemplateNeedsUsablePreview, polishCarriedForwardForLadder, polishGateCanCarryPreview } from "../local-preview-policy.mjs";
4
4
  import { resolve } from "node:path";
5
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
6
6
  import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
@@ -19,6 +19,7 @@ import {
19
19
  import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
20
20
  import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
21
21
  import { cmd } from "../install-invocation.mjs";
22
+ import { shellToken } from "../shell-token.mjs";
22
23
  import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
23
24
  import { orderPathDepthDrift } from "./checks.mjs";
24
25
 
@@ -412,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
412
413
  };
413
414
  }
414
415
 
415
- function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
416
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
416
417
  const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
417
418
  const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
418
419
  // prepare-build is the earliest lifecycle prerequisite. Surface its
@@ -476,6 +477,12 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
476
477
  // A first local preview may carry missing Polish forward. A theme waiver
477
478
  // or prior capture makes the recorded Polish stage owed again.
478
479
  if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
480
+ // A hosted all-template preview has no hosted deploy recorder. The
481
+ // recorded preview URL satisfies this one deploy handoff while the
482
+ // carried-forward gate identifies the exact preview-only shape.
483
+ if (cliStage === "deploy" && packet?.deploy?.preview_url
484
+ && polishGate?.carried_forward?.policy === "hosted_template_preview"
485
+ && polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
479
486
  const reportKey = reportKeyForCliStage(cliStage);
480
487
  const stage = report.stages[reportKey];
481
488
  if (!stage) {
@@ -517,9 +524,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
517
524
  }
518
525
  }
519
526
 
527
+ const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
520
528
  return {
521
529
  stage: "done",
522
- reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
530
+ outcome: hostedPreview ? "hosted_preview_satisfies_deploy" : "standard_closeout",
531
+ reason: hostedPreview
532
+ ? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
533
+ : "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
523
534
  };
524
535
  }
525
536
 
@@ -577,7 +588,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
577
588
  // The code -> action strings doctor prints under `Next:`. They describe the
578
589
  // repairs the findings ask for and are independent of which stage the picker
579
590
  // names, so they survive the picker consolidation unchanged.
580
- function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
591
+ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
581
592
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
582
593
  const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
583
594
  const actions = [];
@@ -633,11 +644,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
633
644
  actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
634
645
  }
635
646
  if (polishBlocked) {
636
- if (polishGate.status === "blocked") {
647
+ const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
648
+ if (needsHostedPreview) {
649
+ actions.push(`Every mapped page is template stock and needs a hosted preview URL that differs from the production URL. Run ${cmd("qa")} policy set --packet ${packetRef} --preview-url <url>, then run ${cmd("next")} --packet ${packetRef}.`);
650
+ }
651
+ if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
637
652
  actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
638
653
  }
639
- if (polishCheckpointGate?.status === "blocked") {
640
- actions.push(`${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
654
+ if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
655
+ actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
656
+ ? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
657
+ : `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
641
658
  }
642
659
  }
643
660
  return actions;
@@ -656,7 +673,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
656
673
  && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
657
674
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
658
675
  const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
659
- const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
676
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
660
677
  // The picker's vocabulary and this table must not drift apart: a stage the
661
678
  // table does not know would otherwise be relabelled as an operator step and
662
679
  // sliced into the whole ladder. Fail loudly instead.
@@ -665,8 +682,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
665
682
  }
666
683
  // An explicit --context / --report is carried into every recommended
667
684
  // command, so a recovery reads the same artifacts the recommendation did.
668
- const packetRef = `${derived.packet_path || "<packet>"}${sidecarArgs}`;
669
- const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
685
+ const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
686
+ const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
670
687
  const deployStatus = String(report?.stages?.deploy?.status || "");
671
688
  const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
672
689
  || Boolean(deployUrlFromReportOutputs(report));
@@ -713,7 +730,9 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
713
730
  ? `${cmd("next")} --packet ${packetRef}`
714
731
  : `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
715
732
  const fallbackAction = picked.stage === "done"
716
- ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
733
+ ? (picked.outcome === "hosted_preview_satisfies_deploy"
734
+ ? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
735
+ : `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
717
736
  : `Run ${command}.`;
718
737
  if (picked.stage === "done") {
719
738
  actions.unshift(fallbackAction);
@@ -733,6 +752,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
733
752
  default_skill: owners.default_skill,
734
753
  command,
735
754
  reason: picked.reason,
755
+ ...(picked.outcome ? { outcome: picked.outcome } : {}),
736
756
  actions: actions.length ? actions : [fallbackAction],
737
757
  // Gate-blocked stages stay listed even when the recommended stage is
738
758
  // runnable (a Design Source Package change after assembly names build,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { portableArtifactPaths } from "./cli-helpers.mjs";
4
5
 
5
6
  export const DOCTOR_SIDECAR_REL_PATH = ".campaign-runtime/doctor-output.json";
6
7
  export const DOCTOR_SIDECAR_SCHEMA = "campaigns-os-doctor-output/v0";
@@ -61,8 +62,9 @@ export function stampDoctorProducer(doctor, command) {
61
62
  // through this (or through `stampDoctorProducer` when its own transactional
62
63
  // writer must do the rename), so `generated_by` cannot be skipped by one of
63
64
  // them the way #327's cause labels once were.
64
- export function writeDoctorSidecar(path, doctor, { command } = {}) {
65
- writeJsonAtomic(path, stampDoctorProducer(doctor, command));
65
+ export function writeDoctorSidecar(path, doctor, { command, targetRepo = null } = {}) {
66
+ const stamped = stampDoctorProducer(doctor, command);
67
+ writeJsonAtomic(path, targetRepo ? portableArtifactPaths(stamped, targetRepo) : stamped);
66
68
  return path;
67
69
  }
68
70
 
@@ -89,7 +91,7 @@ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason =
89
91
  stale_reason: reason
90
92
  || "A later command changed doctor inputs after this snapshot was written. Re-run campaigns-os doctor (or campaigns-os next) for current state.",
91
93
  };
92
- writeJsonAtomic(path, stamped);
94
+ writeJsonAtomic(path, portableArtifactPaths(stamped, targetBaseDir));
93
95
  return path;
94
96
  }
95
97
 
@@ -68,10 +68,30 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
68
68
  id: "polish.hidden_eager_media.map_design_route",
69
69
  kind: "manual",
70
70
  command: null,
71
- description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
71
+ description: "Every mapped page is template stock, so there is no design route to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or use a local or hosted preview for QA with the missing evidence carried forward as a warning.",
72
72
  }),
73
73
  });
74
74
 
75
+ export function qaRunCommand(packetPath, url, bumpCart = null) {
76
+ return `${invocationPrefixFor(PACKAGE_ROOT)} qa run --packet ${packetPath ? shellToken(packetPath) : "<packet>"} --base-url ${url === "<preview-url>" ? url : shellToken(url)} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
77
+ }
78
+
79
+ export function hostedTemplateQaAction(previewUrl) {
80
+ return {
81
+ id: "polish.hidden_eager_media.hosted_preview_qa",
82
+ kind: "command",
83
+ command: qaRunCommand(null, previewUrl),
84
+ description: "Run QA against the packet's hosted preview URL; missing Polish evidence remains a warning.",
85
+ };
86
+ }
87
+
88
+ export const HOSTED_TEMPLATE_PREVIEW_URL_ACTION = Object.freeze({
89
+ id: "polish.hidden_eager_media.set_hosted_preview_url",
90
+ kind: "command",
91
+ command: "campaigns-os qa policy set --packet <packet> --preview-url <url>",
92
+ description: "Record a hosted preview URL that differs from the production URL with qa policy set, then run next again; missing Polish evidence can be carried forward on that preview.",
93
+ });
94
+
75
95
  const PACKET_PLACEHOLDER = "--packet <packet>";
76
96
 
77
97
  // The placeholder becomes the packet this run read, shell-quoted, so the
@@ -26,7 +26,7 @@ import {
26
26
  loadCampaignBuildBriefFile,
27
27
  } from "./build-brief.mjs";
28
28
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
29
- import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
29
+ import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
30
30
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
31
31
  import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
32
32
  import {
@@ -475,9 +475,9 @@ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget,
475
475
  if (!dryRun) {
476
476
  // Intake's publication order, the report last: a failure between files
477
477
  // leaves the report bound to the old material, which reads as owed.
478
- writeJsonAtomic(packetPath, nextPacket);
479
- writeJsonAtomic(normalizedPath, buildBrief.artifact);
480
- writeJsonAtomic(contextPath, saved.nextContext);
478
+ writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
479
+ writeJsonAtomic(normalizedPath, portableArtifactPaths(buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
480
+ writeJsonAtomic(contextPath, portableArtifactPaths(saved.nextContext, targetRepo, { artifactPath: contextPath }));
481
481
  commitAssemblyReport(workspace, () => nextReport, {
482
482
  command: "record brief",
483
483
  staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
@@ -674,8 +674,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
674
674
  if (!dryRun) {
675
675
  // Intake's publication order, the report last: a failure between files
676
676
  // leaves the report bound to the old spec material, which reads as owed.
677
- if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
678
- writeJsonAtomic(contextPath, nextContext);
677
+ if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
678
+ writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
679
679
  commitAssemblyReport(workspace, () => nextReport, {
680
680
  command: "record spec",
681
681
  staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",