@nextcommerce/campaigns-os 1.57.0 → 1.60.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 (55) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +6 -5
  3. package/campaign-spec/dist/checkout-flow.d.ts +7 -0
  4. package/campaign-spec/dist/checkout-flow.js +17 -1
  5. package/compatibility.json +1 -1
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -28
  8. package/contracts/effects.v1.json +5 -5
  9. package/contracts/release-ledger.json +975 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/build-packet.md +35 -19
  13. package/docs/campaign-build-brief.md +1 -1
  14. package/docs/campaigns-os-build-flow.md +1 -1
  15. package/docs/local-setup.md +1 -1
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/polish-evidence.md +1 -1
  18. package/docs/qa-and-test-orders.md +54 -12
  19. package/docs/runtime-readiness.md +1 -1
  20. package/docs/skills-revision.md +10 -10
  21. package/package.json +1 -1
  22. package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
  24. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  25. package/skills/campaign-readback-classification/SKILL.md +3 -3
  26. package/skills/campaign-run-evidence/SKILL.md +3 -3
  27. package/skills/contribution-intake/SKILL.md +3 -3
  28. package/skills/next-campaigns-build/SKILL.md +7 -7
  29. package/skills/next-campaigns-os/SKILL.md +3 -3
  30. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  31. package/skills/next-campaigns-polish/SKILL.md +9 -10
  32. package/skills/next-campaigns-qa/SKILL.md +3 -3
  33. package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
  34. package/skills.json +18 -10
  35. package/src/built-site-scope.mjs +96 -12
  36. package/src/campaign-identity.mjs +6 -4
  37. package/src/cli.mjs +50 -81
  38. package/src/doctor/checks.mjs +79 -6
  39. package/src/doctor/inspect.mjs +2 -8
  40. package/src/doctor/next-step.mjs +9 -17
  41. package/src/orchestration-stage-contract.mjs +6 -3
  42. package/src/page-kit-build-summary.mjs +21 -1
  43. package/src/page-kit-campaign-config.mjs +19 -0
  44. package/src/polish-gate.mjs +1 -5
  45. package/src/polish-node.mjs +5 -4
  46. package/src/progress-node.mjs +2 -1
  47. package/src/qa-browser.mjs +137 -2
  48. package/src/qa-cart-entry.mjs +105 -0
  49. package/src/qa-node.mjs +3 -1
  50. package/src/qa-verdict.mjs +24 -1
  51. package/src/sdk-attribute-index.mjs +8 -7
  52. package/src/source-html-intake.mjs +27 -18
  53. package/src/spec-derive.mjs +24 -9
  54. package/src/stage-ledger.mjs +27 -1
  55. package/src/stage-record.mjs +218 -35
package/src/cli.mjs CHANGED
@@ -193,13 +193,9 @@ import {
193
193
  import {
194
194
  ASSEMBLY_REPORT_STAGE_KEYS,
195
195
  NEXT_STAGE_ORDER,
196
- STAGE_TERMINAL_STATUS_PREFIXES,
197
196
  stageIsTerminal,
198
197
  } from "./orchestration-stage-contract.mjs";
199
- import {
200
- currentBuildFingerprint,
201
- evaluatePolishGate,
202
- } from "./polish-gate.mjs";
198
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
203
199
  import {
204
200
  assertPolishCaptureBindingUnchanged,
205
201
  capturePolishPageLoad,
@@ -221,6 +217,7 @@ import {
221
217
  import {
222
218
  loadPageKitCampaignEntry,
223
219
  PAGE_KIT_CAMPAIGNS_REL_PATH,
220
+ pageKitIgnoresAssetsHtml,
224
221
  } from "./page-kit-campaign-config.mjs";
225
222
  import {
226
223
  evaluatePageKitStoreProfile,
@@ -240,6 +237,7 @@ import {
240
237
  applySpecDerive,
241
238
  formatDeriveValue,
242
239
  isPageTreeIgnoredDir,
240
+ isPageTreeIgnoredPath,
243
241
  frontmatterPermalink,
244
242
  pageRouteForFile,
245
243
  permalinkRoute,
@@ -293,7 +291,7 @@ import {
293
291
  addPrepareBuildGateErrors,
294
292
  assessPurchaseProofCoverage,
295
293
  deployUrlFromReportOutputs,
296
- checkpointExceptionPresent,
294
+ readinessStatus,
297
295
  } from "./doctor/next-step.mjs";
298
296
 
299
297
  export { derivePackagePin, localInstallStatus };
@@ -343,7 +341,7 @@ Usage:
343
341
  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)
344
342
  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
345
343
  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
346
- 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
344
+ 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
347
345
  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.
348
346
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
349
347
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -3546,10 +3544,10 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
3546
3544
  // the route each one builds to (filename-derived, or the file's permalink;
3547
3545
  // a permalink derive cannot read as a campaign route carries `problem` and a
3548
3546
  // null route). Discovery follows page-kit's own: every .html outside
3549
- // `_layouts/` and `_includes/`, symlinked entries included. Returns null when
3550
- // the directory does not exist, so the plan can say "no page tree" rather
3551
- // than "no pages".
3552
- function listPageKitPageFiles(outputDir, publicRouteSlug) {
3547
+ // `_layouts/` and `_includes/` (and, from page-kit 0.3.0, the top-level
3548
+ // `assets/`), symlinked entries included. Returns null when the directory
3549
+ // does not exist, so the plan can say "no page tree" rather than "no pages".
3550
+ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored = false } = {}) {
3553
3551
  if (!outputDir || !existsSync(outputDir) || !statSync(outputDir).isDirectory()) return null;
3554
3552
  const files = [];
3555
3553
  const walk = (dir) => {
@@ -3563,8 +3561,7 @@ function listPageKitPageFiles(outputDir, publicRouteSlug) {
3563
3561
  }
3564
3562
  if (!stats.isFile() || extname(entry.name).toLowerCase() !== ".html") continue;
3565
3563
  const path = relative(outputDir, fullPath).split(sep).join("/");
3566
- const segments = path.split("/");
3567
- if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) continue;
3564
+ if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) continue;
3568
3565
  // page-kit honours a permalink before its filename rule, so the
3569
3566
  // frontmatter is read first (quoting, comments, BOM and CRLF as
3570
3567
  // page-kit's reader takes them); only a file with no usable permalink
@@ -3773,7 +3770,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3773
3770
  try {
3774
3771
  if (existsSync(outputDir)) {
3775
3772
  const realOutputDir = realpathSync(outputDir);
3776
- if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug);
3773
+ if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
3777
3774
  else addIssue(result.warnings, "spec.derive.page_tree_escapes_repo", `${outputDir} resolves to ${realOutputDir}, outside the target repo; routes were not read from it.`);
3778
3775
  }
3779
3776
  } catch (error) {
@@ -4318,7 +4315,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4318
4315
  }
4319
4316
  }
4320
4317
  const themeGate = doctor.derived?.theme_gate || null;
4321
- const polishGate = doctor.derived?.polish_gate || evaluatePolishGate({ report });
4318
+ const polishGate = doctor.derived?.polish_gate;
4322
4319
  const polishCheckpointGate = doctor.derived?.polish_checkpoint_gate || null;
4323
4320
  // Packet 03 (INV-5 first slice): compare the ledger's self-report against
4324
4321
  // the repo's artifacts before any recommendation goes out. Additive:
@@ -4398,21 +4395,25 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4398
4395
  const ready = [...doctor.ready];
4399
4396
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
4400
4397
 
4401
- // Explicit stage requests are still downstream of prepare-build. Do not
4402
- // construct the requested stage's prompt or executable actions when the
4403
- // authoritative prepare gate is blocked or unavailable; return the same
4404
- // recovery-only shape as automatic stage selection.
4405
- if (NEXT_STAGE_ORDER.includes(stage) && prepareBuildGate) {
4398
+ // prepare-build is upstream of every stage. While its authoritative gate is
4399
+ // blocked or unavailable, doctor's picker chooses prepare-build, and an
4400
+ // explicit stage request gets the same recovery-only shape (requested_stage
4401
+ // names it) instead of that stage's prompt or executable actions.
4402
+ if (prepareBuildGate) {
4406
4403
  addPrepareBuildGateErrors(errors, report, prepareBuildGate);
4407
4404
  return finalize({
4408
4405
  ok: false,
4409
4406
  status: "blocked",
4410
4407
  stage: "prepare-build",
4411
- requested_stage: stage,
4408
+ ...(stage ? { requested_stage: stage } : {}),
4412
4409
  reason: prepareBuildGate.reason,
4413
4410
  errors,
4414
4411
  warnings,
4415
4412
  ready,
4413
+ // Packet 03: when the artifacts contradict the ledger, do NOT tell
4414
+ // the operator to start over — rerunning prepare-build/start is the
4415
+ // most destructive documented recovery, and the ledger alone is not
4416
+ // trustworthy evidence that it is needed.
4416
4417
  prompt: prepareBuildRecoveryPrompt,
4417
4418
  stage_blocked: true,
4418
4419
  });
@@ -4438,34 +4439,16 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4438
4439
  prompt: `Resolve the doctor errors above before continuing. Re-run \`${cmd("doctor")} --packet <path> --write\` to record the recovery, then \`${cmd("next")} --packet <path>\` to advance.`,
4439
4440
  });
4440
4441
  }
4441
- if (picked.stage === "prepare-build") {
4442
- addPrepareBuildGateErrors(errors, report, prepareBuildGate);
4443
- return finalize({
4444
- ok: false,
4445
- status: "blocked",
4446
- stage: "prepare-build",
4447
- reason: picked.reason,
4448
- errors,
4449
- warnings,
4450
- ready,
4451
- // Packet 03: when the artifacts contradict the ledger, do NOT tell
4452
- // the operator to start over — rerunning prepare-build/start is the
4453
- // most destructive documented recovery, and the ledger alone is not
4454
- // trustworthy evidence that it is needed.
4455
- prompt: prepareBuildRecoveryPrompt,
4456
- stage_blocked: true,
4457
- });
4458
- }
4459
4442
  if (picked.stage === "done") {
4460
4443
  return finalize({
4461
4444
  ok: true,
4462
- status: checkpointExceptionPresent(doctor.derived) ? "ready_with_waivers" : (warnings.length ? "ready_with_warnings" : "ready"),
4445
+ status: readinessStatus(warnings, doctor.derived),
4463
4446
  stage: "done",
4464
4447
  reason: picked.reason,
4465
4448
  errors,
4466
4449
  warnings,
4467
4450
  ready,
4468
- prompt: `${picked.outcome === "hosted_preview_satisfies_deploy" ? picked.reason : "Pipeline complete. All stages in the assembly report are in a terminal status."} To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA.${picked.outcome === "hosted_preview_satisfies_deploy" ? "" : " For a hosted deploy, record the URL and stage outcome as the deploy prompt describes."} Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4451
+ prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4469
4452
  });
4470
4453
  }
4471
4454
  stage = picked.stage;
@@ -4509,13 +4492,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4509
4492
  addThemeGateErrors(errors, themeGate, "qa");
4510
4493
  prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
4511
4494
  }
4512
- const status = errors.length
4513
- ? "blocked"
4514
- : checkpointExceptionPresent(doctor.derived)
4515
- ? "ready_with_waivers"
4516
- : warnings.length
4517
- ? "ready_with_warnings"
4518
- : "ready";
4495
+ const status = errors.length ? "blocked" : readinessStatus(warnings, doctor.derived);
4519
4496
  const result = {
4520
4497
  ok: errors.length === 0,
4521
4498
  status,
@@ -4877,8 +4854,8 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4877
4854
  return actions;
4878
4855
  }
4879
4856
  if (result.stage === "polish" && polishGate?.carried_forward?.policy === "hosted_template_preview") {
4880
- push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview; missing Polish evidence remains a warning.");
4881
- push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview URL.");
4857
+ 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.");
4858
+ push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Then run browser and typed-card QA against the hosted preview URL.");
4882
4859
  return actions;
4883
4860
  }
4884
4861
  }
@@ -4934,20 +4911,13 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4934
4911
  : `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${shellToken(packetPath)} --evidence <polish-evidence.json>.`);
4935
4912
  if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
4936
4913
  } else if (result.stage === "deploy") {
4937
- if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
4938
- push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect the QA stage for this hosted preview without recording a hosted deploy.");
4939
- push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview; its missing Polish evidence remains a warning.");
4940
- } else if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4914
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4941
4915
  const plan = localServePlan(packet);
4942
4916
  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.`);
4943
4917
  } else {
4944
- 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.`);
4945
- }
4946
- if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
4947
- push("advance", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview without a deploy record.");
4948
- } else {
4949
- push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
4918
+ 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.`);
4950
4919
  }
4920
+ push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
4951
4921
  } else if (result.stage === "qa") {
4952
4922
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4953
4923
  push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
@@ -5108,11 +5078,11 @@ Rules:
5108
5078
  - 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.
5109
5079
  - Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
5110
5080
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
5111
- - 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_url) 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.
5112
- - Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_url>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter step_number); the checkout page's form carries neither. On every page that feeds a Checkout (select, checkout_step, or a landing page with express checkout buttons), next-success-url is that Checkout's post-payment destination (frontmatter success_url), not the next step, because the SDK reads it for express orders; forward navigation uses the step form attribute or a plain link, never that meta tag.
5081
+ - 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.
5082
+ - 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).
5113
5083
  - 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.
5114
5084
  - 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.
5115
- - 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)}`;
5085
+ - 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), and on page-kit >= 0.3.0 for its top-level assetWarnings: ASSET_IGNORED names an .html file under assets/ that is neither built nor copied, so a link or iframe to it 404s. If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
5116
5086
  }
5117
5087
 
5118
5088
  // Local proof mode lines for the build prompt: under local-serve the build is
@@ -5193,7 +5163,7 @@ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGa
5193
5163
  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)}\`.`;
5194
5164
  }
5195
5165
  return packet.deploy?.preview_url
5196
- ? `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("next")} qa --packet ${shellToken(packetPath)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
5166
+ ? `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.`
5197
5167
  : `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)}\`.`;
5198
5168
  }
5199
5169
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
@@ -5255,10 +5225,8 @@ function localServePlan(packet) {
5255
5225
  }
5256
5226
 
5257
5227
  function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishCheckpointGate = null) {
5258
- if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5259
- && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5260
- && !packet.deploy?.preview_url) {
5261
- 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)}\`. There is no hosted record deploy command.`;
5228
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5229
+ return `Every mapped page is template stock, and deploy needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5262
5230
  }
5263
5231
  const target = packet.deploy?.target || "unknown";
5264
5232
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
@@ -5282,7 +5250,7 @@ Once the server is up:
5282
5250
  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.`;
5283
5251
  }
5284
5252
  if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
5285
- 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("next")} qa --packet ${shellToken(packetPath)}\` to inspect the QA stage, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. The recorded hosted preview satisfies this deploy handoff; after QA records its verdict, run \`${cmd("next")} --packet ${shellToken(packetPath)}\` for closeout. Production QA remains strict.`;
5253
+ 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.`;
5286
5254
  }
5287
5255
  return `Deploy the built campaign to ${target}.
5288
5256
 
@@ -5295,10 +5263,9 @@ Read first:
5295
5263
  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.).
5296
5264
 
5297
5265
  After deploy succeeds:
5298
- 1. Record the resulting URL on the packet at deploy.preview_url (preview deploys) or deploy.production_url (production).
5299
- 2. Update the assembly report's stages.deploy.status to "completed" with the URL and any relevant notes in outputs.
5300
- 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.
5301
- 4. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5266
+ 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.
5267
+ 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.
5268
+ 3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5302
5269
 
5303
5270
  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.`;
5304
5271
  }
@@ -5352,10 +5319,8 @@ function orderBumpRunReason(bumpCart) {
5352
5319
  }
5353
5320
 
5354
5321
  function qaPrompt(packetPath, reportPath, packet, spec = null, intent, polishCheckpointGate = null) {
5355
- if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5356
- && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5357
- && !packet.deploy?.preview_url) {
5358
- 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)}\`.`;
5322
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5323
+ return `Every mapped page is template stock, and QA needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5359
5324
  }
5360
5325
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5361
5326
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
@@ -7125,6 +7090,12 @@ function runSessionStatus(args, ambient = null) {
7125
7090
  // Session progress: incomplete assembly-report stages, the deviation count,
7126
7091
  // and the exact next command. Read-only and best-effort; the status command
7127
7092
  // must never fail because an artifact is missing or torn.
7093
+ //
7094
+ // The incomplete stages are the recorded statuses. Whether every stage is
7095
+ // actually done is `next`'s decision, not this one: an input change or a new
7096
+ // build owes a stage again while its recorded status still reads completed.
7097
+ // So the next command is always `next`, which names `run end` once the
7098
+ // campaign is done and a session is active.
7128
7099
  function runSessionProgress(found) {
7129
7100
  const packetPath = found.session.packet;
7130
7101
  if (!isNonEmptyString(packetPath) || !existsSync(packetPath)) return null;
@@ -7134,7 +7105,7 @@ function runSessionProgress(found) {
7134
7105
  const incomplete = [];
7135
7106
  for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
7136
7107
  const status = String(report?.stages?.[key]?.status || "");
7137
- if (!STAGE_TERMINAL_STATUS_PREFIXES.some((prefix) => status.startsWith(prefix))) {
7108
+ if (!stageIsTerminal(status)) {
7138
7109
  incomplete.push({ stage: key, status: status || null });
7139
7110
  }
7140
7111
  }
@@ -7143,9 +7114,7 @@ function runSessionProgress(found) {
7143
7114
  incomplete_stages: incomplete,
7144
7115
  deviations,
7145
7116
  qa_attempt_count: Array.isArray(found.session.qa_attempts) ? found.session.qa_attempts.length : 0,
7146
- next_command: incomplete.length
7147
- ? `${cmd("next")} --packet ${packetPath} --json`
7148
- : `${cmd("run")} end --packet ${packetPath}`,
7117
+ next_command: `${cmd("next")} --packet ${packetPath} --json`,
7149
7118
  };
7150
7119
  } catch {
7151
7120
  return null;
@@ -76,7 +76,7 @@ import {
76
76
  resolveTemplateBrandContract,
77
77
  } from "../private-template-source.mjs";
78
78
  import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
79
- import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
79
+ import { BUILD_DRIFT_PATH_LIMIT, computeBuildFingerprint, describeBuildOutputDrift, resolveBuiltSiteScope } from "../built-site-scope.mjs";
80
80
  import {
81
81
  UPSELL_SELECTOR_SCOPE,
82
82
  builtPageTypeOverRouteGuess,
@@ -99,7 +99,7 @@ import {
99
99
  assessAssemblySourcePackageFreshnessWaivers,
100
100
  currentBuildFingerprint,
101
101
  } from "../polish-gate.mjs";
102
- import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
102
+ import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
103
103
  import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
104
104
  import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
105
105
  // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
@@ -1895,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
1895
1895
  );
1896
1896
  return;
1897
1897
  }
1898
+ const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
1899
+ const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
1898
1900
  addIssue(
1899
1901
  assemblyComplete ? errors : warnings,
1900
1902
  "built_output.fingerprint_stale",
1901
1903
  `Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
1904
+ + `${drift.summary} `
1902
1905
  + "The output changed after build recorded it; re-run build (page-kit build, then record the current fingerprint) before polish or QA evidence can bind to it.",
1903
- { root: `_site/${publicRouteSlug}/`, recorded, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
1906
+ {
1907
+ root: `_site/${publicRouteSlug}/`,
1908
+ recorded,
1909
+ current: current.fingerprint,
1910
+ file_count: current.file_count,
1911
+ assembly_complete: assemblyComplete,
1912
+ drift: drift.manifest === "recorded"
1913
+ ? {
1914
+ manifest: "recorded",
1915
+ extra_count: drift.extra.length,
1916
+ missing_count: drift.missing.length,
1917
+ changed_count: drift.changed.length,
1918
+ extra: capped(drift.extra),
1919
+ missing: capped(drift.missing),
1920
+ changed: capped(drift.changed),
1921
+ conflict_copies: capped(drift.conflict_copies),
1922
+ }
1923
+ : { manifest: "unavailable" },
1924
+ }
1904
1925
  );
1905
1926
  }
1906
1927
 
@@ -3018,7 +3039,7 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3018
3039
  mapping: derived?.page_kit_entry_mappings?.[page?.id],
3019
3040
  });
3020
3041
  if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
3021
- const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
3042
+ const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
3022
3043
  const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
3023
3044
  if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
3024
3045
  const clean = route.replace(/^\/+|\/+$/g, "");
@@ -3082,13 +3103,16 @@ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targe
3082
3103
  return routes[0] ?? null;
3083
3104
  }
3084
3105
 
3085
- function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
3106
+ // page-kit 0.3.0 renders no .html under the campaign's top-level assets/, so
3107
+ // a permalink stated there is no route on that version (assetsHtmlIgnored).
3108
+ function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page, { assetsHtmlIgnored = false } = {}) {
3086
3109
  if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3087
3110
 
3088
3111
  const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
3089
3112
  const candidates = [];
3090
3113
  for (const file of collectHtmlFiles(targetOutputDir)) {
3091
3114
  if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
3115
+ if (assetsHtmlIgnored && file.path.split(sep)[0] === "assets") continue;
3092
3116
  const fullPath = join(targetOutputDir, file.path);
3093
3117
  const content = readFileSync(fullPath, "utf8");
3094
3118
  const permalink = extractFrontmatterValue(content, "permalink");
@@ -3342,7 +3366,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
3342
3366
  const matches = [];
3343
3367
  for (const { label: surface, root } of scanRoots) {
3344
3368
  for (const file of collectHtmlFiles(root)) {
3345
- const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
3369
+ const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
3346
3370
  for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
3347
3371
  const found = normalizePhoneNumber(match[0]);
3348
3372
  if (!found || found === expected) continue;
@@ -3370,6 +3394,18 @@ function maskMarketLintIgnoredRegions(content) {
3370
3394
  .replace(ignoredTag, preserveNewlinesMask);
3371
3395
  }
3372
3396
 
3397
+ // Markup a shopper never reads: HTML comments, <style> blocks, and CSS
3398
+ // comments (in style attributes too). Design exports leave long digit runs
3399
+ // there, such as a Figma frame name "Group 1000003339", which the phone
3400
+ // pattern would otherwise read as a number (campaigns-os#660). Masking keeps
3401
+ // offsets and newlines, so reported line numbers stay true.
3402
+ function maskNonRenderedMarkup(content) {
3403
+ return content
3404
+ .replace(/<!--[\s\S]*?-->/g, preserveNewlinesMask)
3405
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, preserveNewlinesMask)
3406
+ .replace(/\/\*[\s\S]*?\*\//g, preserveNewlinesMask);
3407
+ }
3408
+
3373
3409
  function preserveNewlinesMask(value) {
3374
3410
  return value.replace(/[^\n]/g, " ");
3375
3411
  }
@@ -4138,6 +4174,42 @@ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, repo
4138
4174
  );
4139
4175
  }
4140
4176
  }
4177
+ // A family that puts package selection on its own `select` page (the catalog
4178
+ // names a selectStep surface) and carries no selector on checkout (no
4179
+ // mainSelector surface) can only fill the cart from a declared select page.
4180
+ // Without one on the path into a Checkout, QA resolves the landing as the
4181
+ // entry page, finds no add-to-cart control, and every order path fails after
4182
+ // the whole build (campaigns-os#658). Families whose checkout carries its own
4183
+ // selector, and families that fill the cart on the landing page, are not
4184
+ // affected.
4185
+ function warnMissingSelectStep(specPages, family, catalog, warnings) {
4186
+ const surfaces = catalog.families?.[family]?.canonicalSurfaces;
4187
+ if (!surfaces?.selectStep || surfaces.mainSelector) return;
4188
+ const byFunnel = new Map();
4189
+ for (const page of specPages) {
4190
+ if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
4191
+ byFunnel.get(page.funnel_id).push(page);
4192
+ }
4193
+ for (const [funnelId, pages] of byFunnel) {
4194
+ const reachedFromSelect = new Set();
4195
+ for (const select of pages.filter((page) => page.type === "select")) {
4196
+ const path = checkoutPathFrom(pages, select);
4197
+ if (path) reachedFromSelect.add(path[path.length - 1]);
4198
+ }
4199
+ for (const checkout of pages.filter((page) => page.type === "checkout")) {
4200
+ if (reachedFromSelect.has(checkout)) continue;
4201
+ // A split-test path built on another family is judged by that family.
4202
+ const pageFamily = checkout.sdk_hints?.template_family;
4203
+ if (isNonEmptyString(pageFamily) && pageFamily !== family) continue;
4204
+ addIssue(
4205
+ warnings,
4206
+ "template_contract.select_step_missing",
4207
+ `Template family "${family}" puts package selection on a select page, and its checkout carries no selector, but no select page leads to Checkout "${checkout.id}" in funnel "${funnelId}". QA will find no add-to-cart control on the entry page and every order path will fail. Fix: add a Select page between the entry page and checkout in Map Builder.`,
4208
+ { funnel_id: funnelId, checkout_page_id: checkout.id, template_family: family },
4209
+ );
4210
+ }
4211
+ }
4212
+ }
4141
4213
  function offerRefsFromEntries(entries) {
4142
4214
  if (!Array.isArray(entries)) return [];
4143
4215
  return entries
@@ -4323,6 +4395,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4323
4395
  // ADR-003 step 2: template-contract checks ported from the private doctor.
4324
4396
  const specPages = activeSpecPages(spec);
4325
4397
  warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
4398
+ warnMissingSelectStep(specPages, family, catalog, warnings);
4326
4399
 
4327
4400
  const mismatchedFamilies = specPages.filter(
4328
4401
  (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
@@ -63,7 +63,7 @@ import {
63
63
  nextPrepareBuildBindingIssues,
64
64
  prepareBuildGateIssue,
65
65
  addPrepareBuildGateErrors,
66
- checkpointExceptionPresent,
66
+ readinessStatus,
67
67
  buildNextStep,
68
68
  } from "./next-step.mjs";
69
69
 
@@ -664,13 +664,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
664
664
  }
665
665
  }
666
666
  }
667
- const status = errors.length
668
- ? "blocked"
669
- : checkpointExceptionPresent(derived)
670
- ? "ready_with_waivers"
671
- : warnings.length
672
- ? "ready_with_warnings"
673
- : "ready";
667
+ const status = errors.length ? "blocked" : readinessStatus(warnings, derived);
674
668
  const result = {
675
669
  schema_version: DOCTOR_SIDECAR_SCHEMA,
676
670
  generated_at: new Date().toISOString(),
@@ -413,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
413
413
  };
414
414
  }
415
415
 
416
- function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
416
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
417
417
  const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
418
418
  const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
419
419
  // prepare-build is the earliest lifecycle prerequisite. Surface its
@@ -477,12 +477,6 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
477
477
  // A first local preview may carry missing Polish forward. A theme waiver
478
478
  // or prior capture makes the recorded Polish stage owed again.
479
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;
486
480
  const reportKey = reportKeyForCliStage(cliStage);
487
481
  const stage = report.stages[reportKey];
488
482
  if (!stage) {
@@ -524,13 +518,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
524
518
  }
525
519
  }
526
520
 
527
- const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
528
521
  return {
529
522
  stage: "done",
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
+ outcome: "standard_closeout",
524
+ reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
534
525
  };
535
526
  }
536
527
 
@@ -551,6 +542,9 @@ function checkpointExceptionPresent(derived) {
551
542
  || derived?.polish_checkpoint_gate?.status === "waived";
552
543
  }
553
544
 
545
+ // The readiness ladder below "blocked", written once: doctor's status, the
546
+ // `next` command's status and doctor's `next` block each decide "blocked"
547
+ // first, then call it with their own warnings.
554
548
  function readinessStatus(warnings, derived) {
555
549
  if (checkpointExceptionPresent(derived)) return "ready_with_waivers";
556
550
  return warnings.length ? "ready_with_warnings" : "ready";
@@ -673,7 +667,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
673
667
  && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
674
668
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
675
669
  const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
676
- const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
670
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
677
671
  // The picker's vocabulary and this table must not drift apart: a stage the
678
672
  // table does not know would otherwise be relabelled as an operator step and
679
673
  // sliced into the whole ladder. Fail loudly instead.
@@ -730,9 +724,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
730
724
  ? `${cmd("next")} --packet ${packetRef}`
731
725
  : `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
732
726
  const fallbackAction = picked.stage === "done"
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.`)
727
+ ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
736
728
  : `Run ${command}.`;
737
729
  if (picked.stage === "done") {
738
730
  actions.unshift(fallbackAction);
@@ -769,6 +761,6 @@ export {
769
761
  prepareBuildGateIssue,
770
762
  addPrepareBuildGateErrors,
771
763
  deployUrlFromReportOutputs,
772
- checkpointExceptionPresent,
764
+ readinessStatus,
773
765
  buildNextStep,
774
766
  };
@@ -50,9 +50,12 @@ export function reportKeyForCliStage(cliStage) {
50
50
  * "completed", "completed_with_warnings", and "completed_partial" all
51
51
  * count as terminal under "completed". This matches how the existing
52
52
  * stages already report sub-statuses (see report.stages.assembly.status
53
- * shapes in src/cli.mjs and qa/shared/qa-verdict.js). Shared here so the
54
- * `next` picker, the session progress summary and the Assembly Report's own
55
- * derived summary (src/stage-ledger.mjs) read one definition of "done".
53
+ * shapes in src/cli.mjs and qa/shared/qa-verdict.js). This is the rule for a
54
+ * RECORDED status only (the session progress summary's incomplete stages,
55
+ * the prepare-build gate, doctor's checks). Whether a stage is done is
56
+ * decided on its effective status (src/input-currency.mjs), which the `next`
57
+ * picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
58
+ * read: a stage owed again is not done, whatever status it records.
56
59
  */
57
60
  export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
58
61