@nextcommerce/campaigns-os 1.55.0 → 1.59.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 (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -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/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  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 +124 -22
  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/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
package/src/cli.mjs CHANGED
@@ -21,9 +21,10 @@ import { homedir } from "node:os";
21
21
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { shellToken } from "./shell-token.mjs";
23
23
  import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
24
+ import { checkoutPathFrom, entryPages, isCheckoutFlowPage, paymentPageForFunnel, upgradeCampaignSpec } from "../campaign-spec/dist/index.js";
24
25
  import { diagnosticExport, diagnosticTextLines } from "./diagnostic.mjs";
25
26
  import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
26
- import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
27
+ import { HIDDEN_EAGER_MEDIA_ACTIONS, qaRunCommand, requiredActionText, substitutePacket } from "./gate-actions.mjs";
27
28
  import { orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
28
29
  import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
29
30
  // The same predicate stage-ledger.mjs judges a mutator's result with, imported
@@ -252,6 +253,7 @@ import {
252
253
  readStoreProfile,
253
254
  } from "./spec-derive-store.mjs";
254
255
  import { ROOT, cmd, asInvocation } from "./install-invocation.mjs";
256
+ import { hostedTemplateNeedsPreviewUrl, hostedTemplateNeedsUsablePreview } from "./local-preview-policy.mjs";
255
257
  import {
256
258
  requireArg,
257
259
  isObject,
@@ -263,6 +265,7 @@ import {
263
265
  sha256File,
264
266
  relFromDir,
265
267
  isLocalAbsolutePath,
268
+ portableArtifactPaths,
266
269
  resolveFromFile,
267
270
  extractFrontmatterValue,
268
271
  addIssue,
@@ -302,6 +305,7 @@ const HELP = `Campaigns OS toolkit
302
305
 
303
306
  Usage:
304
307
  campaigns-os help
308
+ campaigns-os --version # print the installed package version; -v is equivalent
305
309
  campaigns-os demo --target <new-directory> # offline inert sample; open landing/index.html; no campaign evidence
306
310
  campaigns-os start (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
307
311
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
@@ -339,7 +343,7 @@ Usage:
339
343
  campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
340
344
  campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
341
345
  campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
342
- campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again); refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
346
+ campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
343
347
  campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
344
348
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
345
349
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -476,6 +480,12 @@ function closestCommand(input) {
476
480
  // (src/qc-test-factories.mjs). The bin entry point never passes it, and no
477
481
  // flag, environment variable or file can supply one.
478
482
  export async function main(argv, { authentication, qcStandIns } = {}) {
483
+ // Global version flags bypass invocation policy, including session reads and
484
+ // lifecycle capture, just as help bypasses campaign effects.
485
+ if (argv[0] === "--version" || argv[0] === "-v") {
486
+ console.log(packageVersion());
487
+ return;
488
+ }
479
489
  return runInvocation(parseArgs(argv), {
480
490
  dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
481
491
  closeOutStaleRunSessions,
@@ -729,7 +739,7 @@ export function recordQaStageOutcome(args, result) {
729
739
  disposition: verdict.disposition,
730
740
  timestamp: verdict.completed_at,
731
741
  command: `campaigns-os ${QA_RUN_PRODUCER}`,
732
- outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
742
+ outputs: portableArtifactPaths([result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString), workspace.targetRepo),
733
743
  blockers: verdict.disposition === "blocked" ? failed : [],
734
744
  warnings: verdict.disposition === "ready_with_exceptions"
735
745
  ? ["QA passed with explicitly attributed exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass."]
@@ -4217,11 +4227,11 @@ const RUN_RECORD_QA_DIGEST_LIMIT = 8;
4217
4227
  // Digests of the verdicts the report records, best-effort: an unreadable
4218
4228
  // hint contributes no digest, which fails open to "outdated" rather than to
4219
4229
  // a false match.
4220
- function currentQaVerdictDigestsForReport(report, reportPath) {
4230
+ function currentQaVerdictDigestsForReport(report, reportPath, targetRepo) {
4221
4231
  const digests = new Set();
4222
4232
  // Lazy on purpose, and the check follows the add: once the limit is
4223
4233
  // reached the next candidate is never pulled, read or hashed.
4224
- for (const candidate of iterateQaVerdicts({ report, reportPath, withDigest: true })) {
4234
+ for (const candidate of iterateQaVerdicts({ report, reportPath, roots: [targetRepo], withDigest: true })) {
4225
4235
  if (candidate.source === "assembly_report" && candidate.sha256) digests.add(candidate.sha256);
4226
4236
  if (digests.size >= RUN_RECORD_QA_DIGEST_LIMIT) break;
4227
4237
  }
@@ -4302,7 +4312,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4302
4312
  // Atomic like the assembly report: a torn sidecar would be a corrupted
4303
4313
  // freshness artifact — the exact green-lie shape this refresh exists to
4304
4314
  // prevent (Kilo review, PR #176). Stamped generated_by: "next" (#312).
4305
- writeDoctorSidecar(doctorOutPath, doctor, { command: NEXT_PRODUCER });
4315
+ writeDoctorSidecar(doctorOutPath, doctor, { command: NEXT_PRODUCER, targetRepo });
4306
4316
  } catch {
4307
4317
  // sidecar refresh is best-effort; orchestration must not fail on it
4308
4318
  }
@@ -4318,7 +4328,10 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4318
4328
  // participate in divergence detection or override prepare-only recovery.
4319
4329
  const divergences = prepareBuildGate?.binding_failure
4320
4330
  ? []
4321
- : detectLedgerDivergence(report, packet, targetRepo);
4331
+ : detectLedgerDivergence(report, packet, targetRepo, {
4332
+ allowUnrecordedHostedPreview: polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview"
4333
+ || hostedTemplateNeedsPreviewUrl(packet, polishCheckpointGate),
4334
+ });
4322
4335
  // Every return path runs through this finalizer so the machine-readable
4323
4336
  // contract is uniform: `gates` (pass/blocked/waived/not_applicable per
4324
4337
  // gate) and `next_actions` (exact commands — not prose) are always present,
@@ -4342,7 +4355,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4342
4355
  records: readRunRecordsForTarget(dirname(packetPath)),
4343
4356
  packet,
4344
4357
  report,
4345
- currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null),
4358
+ currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null, targetRepo),
4346
4359
  qaVerdictRecorded: qaVerdictPathHints(report).length > 0,
4347
4360
  });
4348
4361
  } catch {
@@ -4475,7 +4488,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4475
4488
  if (!assemblyStatus.startsWith("completed")) addIssue(errors, "next.polish.assembly", `Assembly status is "${assemblyStatus || "missing"}"; polish expects completed assembly or an explicit blocked/skipped handoff.`);
4476
4489
  if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
4477
4490
  addThemeGateErrors(errors, themeGate, "polish");
4478
- prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
4491
+ prompt = polishPrompt(packetPath, reportPath, packet, intentSummary, polishCheckpointGate);
4479
4492
  } else if (stage === "deploy") {
4480
4493
  addPrepareBuildGateErrors(errors, report);
4481
4494
  // Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
@@ -4485,7 +4498,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4485
4498
  addPolishGateErrors(errors, polishGate, "deploy");
4486
4499
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "deploy");
4487
4500
  addThemeGateErrors(errors, themeGate, "deploy");
4488
- prompt = deployPrompt(packetPath, reportPath, packet);
4501
+ prompt = deployPrompt(packetPath, reportPath, packet, polishGate, polishCheckpointGate);
4489
4502
  } else if (stage === "qa") {
4490
4503
  addPrepareBuildGateErrors(errors, report);
4491
4504
  if (!report) addIssue(errors, "next.qa.report", "Assembly report is required before QA.");
@@ -4494,7 +4507,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4494
4507
  addPolishGateErrors(errors, polishGate, "qa");
4495
4508
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
4496
4509
  addThemeGateErrors(errors, themeGate, "qa");
4497
- prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary);
4510
+ prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
4498
4511
  }
4499
4512
  const status = errors.length
4500
4513
  ? "blocked"
@@ -4743,18 +4756,18 @@ function quoteDivergences(divergences) {
4743
4756
  .join(" ");
4744
4757
  }
4745
4758
 
4746
- function divergenceInspectAction(divergences, packetPath) {
4759
+ function divergenceInspectAction(divergences, packetPath, { packet = null, polishCheckpointGate = null } = {}) {
4747
4760
  const divergedStages = divergences.map((divergence) => divergence.stage);
4748
4761
  const forwardHint = divergedStages.includes("qa")
4749
4762
  ? "The artifacts include a QA verdict for this campaign, so the campaign may already be built, deployed, and QA'd — verify the artifacts before redoing any stage."
4750
4763
  : divergedStages.includes("deploy")
4751
- ? `If the recorded deploy URL is real and current, the forward path is QA (${cmd("next")} qa --packet ${packetPath}), not re-running earlier stages.`
4764
+ ? `If the recorded deploy URL is real and current, the forward path is QA (${cmd("next")} qa --packet ${shellToken(packetPath)}), not re-running earlier stages.`
4752
4765
  : "If the built output is real and current, the forward path is polish/deploy/QA, not re-running setup or build.";
4753
4766
  return {
4754
4767
  id: "divergence_inspect",
4755
4768
  kind: "manual",
4756
4769
  command: null,
4757
- description: `Ledger and artifacts disagree — ${divergences.length} divergence(s): ${quoteDivergences(divergences)} The same entries are the divergences[] field of \`${cmd("next")} --json\` output; they are not written to any file. This is the ONLY next action: stage actions are suppressed while the disagreement stands, because every one of them would be derived from the same contradictory evidence. Inspect both sides and decide which is right; update the assembly report only after inspection. Do not rerun start/prepare-build or redo completed-looking work on the strength of the ledger alone, and do not treat artifact presence as proof a stage is complete. ${forwardHint} Re-run \`${cmd("next")} --packet ${packetPath} --json\` once the report matches the artifacts to get the normal action list.`,
4770
+ description: `Ledger and artifacts disagree — ${divergences.length} divergence(s): ${quoteDivergences(divergences)} The same entries are the divergences[] field of \`${cmd("next")} --json\` output; they are not written to any file. This is the ONLY next action: stage actions are suppressed while the disagreement stands, because every one of them would be derived from the same contradictory evidence. Inspect both sides and decide which is right; update the assembly report only after inspection. Do not rerun start/prepare-build or redo completed-looking work on the strength of the ledger alone, and do not treat artifact presence as proof a stage is complete. ${forwardHint}${hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate) ? ` After reconciliation, set a hosted preview URL that differs from the production URL with \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`.` : ""} Re-run \`${cmd("next")} --packet ${shellToken(packetPath)} --json\` once the report matches the artifacts to get the normal action list.`,
4758
4771
  required: true,
4759
4772
  };
4760
4773
  }
@@ -4785,7 +4798,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4785
4798
  // of the stale ledger. Suppressing branch-by-branch would leave the next
4786
4799
  // branch someone adds unguarded; returning here cannot rot that way.
4787
4800
  // The operator reconciles, then re-runs `next` for the normal list.
4788
- const inspect = divergenceInspectAction(divergences, packetPath);
4801
+ const inspect = divergenceInspectAction(divergences, packetPath, { packet, polishCheckpointGate });
4789
4802
  push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
4790
4803
  return actions;
4791
4804
  }
@@ -4811,7 +4824,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4811
4824
  );
4812
4825
  }
4813
4826
  }
4814
- push("doctor_recheck", "command", `${cmd("doctor")} --packet ${packetPath} --write --json`, "Record a fresh doctor result after resolving the listed errors.");
4827
+ push("doctor_recheck", "command", `${cmd("doctor")} --packet ${shellToken(packetPath)} --write --json`, "Record a fresh doctor result after resolving the listed errors.");
4815
4828
  return actions;
4816
4829
  }
4817
4830
  if (result.stage === "prepare-build") {
@@ -4845,16 +4858,30 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4845
4858
  for (const action of themeGate.required_actions) {
4846
4859
  push(`theme_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4847
4860
  }
4848
- push("recheck", "command", `${cmd("next")} --packet ${packetPath} --json`, "Re-run next after resolving the theme gate to advance.");
4861
+ push("recheck", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Re-run next after resolving the theme gate to advance.");
4849
4862
  return actions;
4850
4863
  }
4851
4864
  if (polishGateRequiresBuild(polishGate) && ["build", "polish", "deploy", "qa"].includes(result.stage)) {
4852
4865
  for (const action of polishGate?.required_actions || []) {
4853
4866
  push(`polish_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4854
4867
  }
4855
- push("recheck", "command", `${cmd("next")} --packet ${packetPath} --json`, "Re-run next after rebuilding against the current Design Source Package.");
4868
+ push("recheck", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Re-run next after rebuilding against the current Design Source Package.");
4856
4869
  return actions;
4857
4870
  }
4871
+ const hostedTemplate = packet?.deploy?.target && packet.deploy.target !== LOCAL_SERVE_DEPLOY_TARGET
4872
+ && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes";
4873
+ if (hostedTemplate && ["polish", "deploy", "qa"].includes(result.stage)) {
4874
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
4875
+ pushPolishCheckpointActions();
4876
+ push("recheck", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Re-run next after recording a hosted preview URL that differs from the production URL.");
4877
+ return actions;
4878
+ }
4879
+ if (result.stage === "polish" && polishGate?.carried_forward?.policy === "hosted_template_preview") {
4880
+ push("record_deploy", "command", `${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}`, "Record the hosted preview deploy; missing Polish evidence remains a warning.");
4881
+ push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Then run browser and typed-card QA against the hosted preview URL.");
4882
+ return actions;
4883
+ }
4884
+ }
4858
4885
  if ((polishGate?.status === "blocked" || polishCheckpointGate?.status === "blocked")
4859
4886
  && ["deploy", "qa"].includes(result.stage)) {
4860
4887
  const checkpointActionIds = new Set(
@@ -4867,7 +4894,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4867
4894
  push(`polish_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4868
4895
  }
4869
4896
  pushPolishCheckpointActions();
4870
- push("recheck", "command", `${cmd("next")} --packet ${packetPath} --json`, "Re-run next after recording valid Polish evidence.");
4897
+ push("recheck", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Re-run next after recording valid Polish evidence.");
4871
4898
  return actions;
4872
4899
  }
4873
4900
  // Ahead of every stage that still has QA in front of it, say what QA will do
@@ -4888,9 +4915,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4888
4915
  }
4889
4916
  }
4890
4917
  if (result.stage === "setup") {
4891
- push("setup_skill", "skill", "next-campaigns-os-setup", `Prepare the target page-kit structure and agent context, then record setup with ${cmd("record")} setup --packet ${packetPath}.`);
4918
+ push("setup_skill", "skill", "next-campaigns-os-setup", `Prepare the target page-kit structure and agent context, then record setup with ${cmd("record")} setup --packet ${shellToken(packetPath)}.`);
4892
4919
  } else if (result.stage === "build") {
4893
- const recordBuild = `${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
4920
+ const recordBuild = `${cmd("record")} build --packet ${shellToken(packetPath)}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
4894
4921
  push("build_skill", "skill", "next-campaigns-build", `Assemble the campaign per the build prompt, run the page-kit build, then record build with ${recordBuild}.`);
4895
4922
  if (isLocalServePacket(packet)) {
4896
4923
  push("build_local_proof", "command", LOCAL_PROOF_BUILD_COMMAND, `Local proof mode (deploy.target is local-serve): build page-kit in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment into _site/, then record it with ${recordBuild}, which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}" (never hand-edit it). Vendor loaders are environment-gated out of this render (their protocol-relative //host/... URLs fail over a plain-HTTP local serve); SDK dl_* events still fire. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
@@ -4902,16 +4929,18 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4902
4929
  }
4903
4930
  }
4904
4931
  } else if (result.stage === "polish") {
4905
- push("polish_skill", "skill", "next-campaigns-polish", `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${packetPath} --evidence <polish-evidence.json>.`);
4932
+ push("polish_skill", "skill", "next-campaigns-polish", polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
4933
+ ? "Every mapped page is template stock, so there is no design route to capture. Follow the preview guidance for missing Polish evidence."
4934
+ : `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${shellToken(packetPath)} --evidence <polish-evidence.json>.`);
4906
4935
  if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
4907
4936
  } else if (result.stage === "deploy") {
4908
4937
  if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4909
4938
  const plan = localServePlan(packet);
4910
- push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${packetPath} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
4939
+ push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
4911
4940
  } else {
4912
- push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"}, then record deploy.preview_url (or production_url) on the packet and stages.deploy in the assembly report.`);
4941
+ push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"} with _site/ as the origin root, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${packet.deploy?.preview_url ? shellToken(packet.deploy.preview_url) : "<https preview url>"}: it checks every built page answers HTTP 200 and every script and stylesheet the pages load from that origin is served byte-identical to the build, and records deploy.preview_url and stages.deploy.`);
4913
4942
  }
4914
- push("advance", "command", `${cmd("next")} --packet ${packetPath} --json`, "Advance to QA once the deploy URL is recorded.");
4943
+ push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
4915
4944
  } else if (result.stage === "qa") {
4916
4945
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4917
4946
  push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
@@ -5072,7 +5101,8 @@ Rules:
5072
5101
  - For one-time prepurchase/order-bump packages outside the main bundles, default package_sync=false and show_line_total_price=false unless the spec explicitly requires quantity sync.
5073
5102
  - Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
5074
5103
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
5075
- - For two-step package-selection flows, treat the selector page as the pre-checkout step and pass the selected cart to checkout with forcePackageId; preserve normal tracking params and strip forcePackageId from visible checkout URLs after SDK initialization.
5104
+ - Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's next_step) goes to the next page. Use forcePackageId only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
5105
+ - Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
5076
5106
  - After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
5077
5107
  - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
5078
5108
  - Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
@@ -5143,8 +5173,22 @@ When copying a starter template family, copy the family as an atomic page-kit sl
5143
5173
  Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
5144
5174
  }
5145
5175
 
5146
- function polishPrompt(packetPath, reportPath, packet, intent) {
5176
+ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGate = null) {
5147
5177
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5178
+ if (polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes") {
5179
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET && polishCheckpointGate?.status !== "carried_forward") {
5180
+ return `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward only on the local preview served from a loopback host (localhost, 127.0.0.1 or [::1]); this packet's deploy.preview_url is not one. Serve the build locally and record it with \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url <served url>\`, then run \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5181
+ }
5182
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
5183
+ return `Every mapped page is template stock, so there is no design route to capture. On the local loopback preview, missing Polish evidence is carried forward as a warning. Run \`${cmd("next")} --packet ${shellToken(packetPath)}\` to continue to deploy and QA. Production QA still requires the evidence.`;
5184
+ }
5185
+ if (polishCheckpointGate?.status !== "carried_forward" && packet.deploy?.preview_url) {
5186
+ return `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward only on a hosted preview whose deploy.preview_url is a non-loopback http(s) URL different from deploy.production_url; this packet's is not. Set one with \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5187
+ }
5188
+ return packet.deploy?.preview_url
5189
+ ? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
5190
+ : `Every mapped page is template stock, so there is no design route to capture. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` to record the hosted preview URL, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5191
+ }
5148
5192
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
5149
5193
 
5150
5194
  Read first:
@@ -5203,7 +5247,12 @@ function localServePlan(packet) {
5203
5247
  };
5204
5248
  }
5205
5249
 
5206
- function deployPrompt(packetPath, reportPath, packet) {
5250
+ function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishCheckpointGate = null) {
5251
+ if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5252
+ && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5253
+ && !packet.deploy?.preview_url) {
5254
+ return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5255
+ }
5207
5256
  const target = packet.deploy?.target || "unknown";
5208
5257
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
5209
5258
  if (target === LOCAL_SERVE_DEPLOY_TARGET) {
@@ -5225,6 +5274,9 @@ Once the server is up:
5225
5274
 
5226
5275
  If the served build cannot be reached, set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
5227
5276
  }
5277
+ if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
5278
+ return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\` to record the deploy, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. Production QA remains strict.`;
5279
+ }
5228
5280
  return `Deploy the built campaign to ${target}.
5229
5281
 
5230
5282
  Read first:
@@ -5236,10 +5288,9 @@ Read first:
5236
5288
  Deploy is currently an out-of-band step: the page-kit build produces _site/ output; you (or your CI) ship it to ${target}. Use the deploy target's normal tooling (netlify deploy, wrangler pages deploy, vercel deploy, etc.).
5237
5289
 
5238
5290
  After deploy succeeds:
5239
- 1. Record the resulting URL on the packet at deploy.preview_url (preview deploys) or deploy.production_url (production).
5240
- 2. Update the assembly report's stages.deploy.status to "completed" with the URL and any relevant notes in outputs.
5241
- 3. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
5242
- 4. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5291
+ 1. Run \`${cmd("record")} deploy --packet ${packetPath} --base-url <https preview origin>${liveUrlPath}\`. It requests every built page under that URL (each must answer HTTP 200) and compares every script and stylesheet the built pages load from that origin with the built output by sha256 (HTML is not compared; hosts inject markup), then records the URL at deploy.preview_url and stages.deploy as completed with the URL in outputs and the build fingerprint it probed. It refuses, writing nothing, naming each page or asset that does not match.
5292
+ 2. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
5293
+ 3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5243
5294
 
5244
5295
  If the deploy is blocked (non-localhost allowed-domain not yet added, CI permission missing, host-side outage), set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
5245
5296
  }
@@ -5247,36 +5298,40 @@ If the deploy is blocked (non-localhost allowed-domain not yet added, CI permiss
5247
5298
  // The QA command the QA stage hands over. Given a bump cart it is the
5248
5299
  // order-bump run: the same command with --cart selecting the base tier and
5249
5300
  // toggling each declared bump.
5250
- function qaRunCommand(packetPath, url, bumpCart = null) {
5251
- return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
5252
- }
5253
-
5254
5301
  // `qa run --test-order common` never puts a checkout order bump in a test
5255
5302
  // order: the tier planner skips bump rows by design and bump coverage comes
5256
5303
  // from --cart. So when the spec declares one, `next` names a second run with
5257
- // the bump in the cart. It reads the one checkout QA drives, with QA's own row
5258
- // classifiers: QA's test orders run on findPage(topologies, "checkout"), the
5259
- // first checkout across funnels in array order, each funnel's pages filtered
5260
- // to enabled and sorted by `order || 0` (extractTopologies). This picks the
5261
- // same page, and when that checkout declares no bump it returns null rather
5262
- // than looking at a later funnel: QA never drives a later funnel's checkout,
5263
- // so that funnel's bump ref in --cart would land on a checkout without it. The base is the first selector tier QA would plan: QA's default
5264
- // keeps the page's pre-selected card, which a spec does not name. A checkout
5265
- // that declares no tier (the cart is filled on an entry page) gets the bump
5266
- // alone, on whatever selection the entry page made.
5304
+ // the bump in the cart. It reads the one checkout path QA drives, with QA's own
5305
+ // row classifiers: QA's test orders run on the Checkout (the page that takes
5306
+ // payment) of the first entry path of the first funnel that has one, each
5307
+ // funnel's pages filtered to enabled and sorted by `order || 0`
5308
+ // (extractTopologies, findPaymentPage). The rows are read from every
5309
+ // checkout-flow page on that path — the select page or first step of a
5310
+ // multi-step checkout declares the cart, not the Checkout (campaigns-os#641).
5311
+ // When that path declares no bump it returns null rather than looking at a
5312
+ // later funnel: QA never drives a later funnel's checkout, so that funnel's
5313
+ // bump ref in --cart would land on a checkout without it. The base is the
5314
+ // first selector tier QA would plan: QA's default keeps the page's
5315
+ // pre-selected card, which a spec does not name. A path that declares no tier
5316
+ // (the cart is filled on an entry page) gets the bump alone, on whatever
5317
+ // selection the entry page made.
5267
5318
  export function checkoutOrderBumpCart(spec) {
5268
- const funnels = Array.isArray(spec?.funnels)
5269
- ? spec.funnels
5270
- : Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
5319
+ const upgraded = upgradeCampaignSpec(spec);
5320
+ const funnels = Array.isArray(upgraded?.funnels)
5321
+ ? upgraded.funnels
5322
+ : Array.isArray(upgraded?.funnel_pages) ? [{ pages: upgraded.funnel_pages }] : [];
5271
5323
  for (const funnel of funnels) {
5272
- const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5324
+ const pages = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5273
5325
  .filter((page) => page && page.enabled !== false)
5274
- .sort((a, b) => (a.order || 0) - (b.order || 0))
5275
- .find((page) => page.type === "checkout");
5326
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
5327
+ const sorted = { ...funnel, pages };
5328
+ const checkout = paymentPageForFunnel(sorted);
5276
5329
  if (!checkout) continue;
5277
- const bumps = declaredOrderBumps(checkout);
5330
+ const entryPath = entryPages(sorted).map((entry) => checkoutPathFrom(pages, entry)).find((path) => path?.includes(checkout));
5331
+ const pathPages = (entryPath || [checkout]).filter((page) => isCheckoutFlowPage(page));
5332
+ const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
5278
5333
  if (!bumps.length) return null;
5279
- const base = declaredSelectorTiers(checkout)[0]?.ref || null;
5334
+ const base = pathPages.map((page) => declaredSelectorTiers(page)[0]?.ref).find(Boolean) || null;
5280
5335
  return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
5281
5336
  }
5282
5337
  return null;
@@ -5288,7 +5343,12 @@ function orderBumpRunReason(bumpCart) {
5288
5343
  return `The checkout declares order bump ${bumpCart.bumps.join(", ")} (is_order_bump or is_upsell), and the default run never puts it in a test order. This run repeats QA with ${cart} in the cart.`;
5289
5344
  }
5290
5345
 
5291
- function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
5346
+ function qaPrompt(packetPath, reportPath, packet, spec = null, intent, polishCheckpointGate = null) {
5347
+ if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5348
+ && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5349
+ && !packet.deploy?.preview_url) {
5350
+ return `Every mapped page is template stock, and QA needs the hosted preview URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} qa --packet ${shellToken(packetPath)}\`.`;
5351
+ }
5292
5352
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5293
5353
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5294
5354
  const bumpCart = checkoutOrderBumpCart(spec);
@@ -5356,7 +5416,7 @@ function qaVerdictArtifactsForCampaign(packet, targetRepo) {
5356
5416
  // Presence-based only: a stale built output still diverges from a pending
5357
5417
  // ledger, and the entry says the two disagree — it never asserts the stage
5358
5418
  // is complete or the artifact current. This function writes nothing.
5359
- export function detectLedgerDivergence(report, packet, targetRepo) {
5419
+ export function detectLedgerDivergence(report, packet, targetRepo, { allowUnrecordedHostedPreview = false } = {}) {
5360
5420
  const divergences = [];
5361
5421
  const stages = report?.stages;
5362
5422
  if (!isObject(stages) || !isNonEmptyString(targetRepo)) return divergences;
@@ -5389,7 +5449,7 @@ export function detectLedgerDivergence(report, packet, targetRepo) {
5389
5449
  const packetPreviewUrl = optionalString(packet?.deploy?.preview_url);
5390
5450
  const packetProductionUrl = optionalString(packet?.deploy?.production_url);
5391
5451
  const url = reportUrl || packetPreviewUrl || packetProductionUrl;
5392
- if (url) {
5452
+ if (url && !(allowUnrecordedHostedPreview && !reportUrl)) {
5393
5453
  const source = reportUrl
5394
5454
  ? "report.stages.deploy.outputs"
5395
5455
  : packetPreviewUrl
@@ -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 })));
@@ -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 = [];
@@ -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
  }));