@nextcommerce/campaigns-os 1.54.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
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
@@ -96,6 +97,7 @@ import {
96
97
  refused,
97
98
  refusing,
98
99
  runWithRefusalScope,
100
+ timeOperatorWait,
99
101
  } from "./lifecycle.mjs";
100
102
  import { commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
101
103
  import {
@@ -251,6 +253,7 @@ import {
251
253
  readStoreProfile,
252
254
  } from "./spec-derive-store.mjs";
253
255
  import { ROOT, cmd, asInvocation } from "./install-invocation.mjs";
256
+ import { hostedTemplateNeedsPreviewUrl, hostedTemplateNeedsUsablePreview } from "./local-preview-policy.mjs";
254
257
  import {
255
258
  requireArg,
256
259
  isObject,
@@ -262,6 +265,7 @@ import {
262
265
  sha256File,
263
266
  relFromDir,
264
267
  isLocalAbsolutePath,
268
+ portableArtifactPaths,
265
269
  resolveFromFile,
266
270
  extractFrontmatterValue,
267
271
  addIssue,
@@ -301,6 +305,7 @@ const HELP = `Campaigns OS toolkit
301
305
 
302
306
  Usage:
303
307
  campaigns-os help
308
+ campaigns-os --version # print the installed package version; -v is equivalent
304
309
  campaigns-os demo --target <new-directory> # offline inert sample; open landing/index.html; no campaign evidence
305
310
  campaigns-os start (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
306
311
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
@@ -335,7 +340,7 @@ Usage:
335
340
  campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
336
341
  campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
337
342
  campaigns-os record setup --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # record setup complete once the campaign output directory exists: Build Context scaffold.required=false and stages.setup completed, validated against their schemas before either is written
338
- campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (--build-environment records stages.assembly.evidence.build_environment; local proof mode records development): stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
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)
339
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
340
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
341
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
@@ -356,7 +361,7 @@ Usage:
356
361
  campaigns-os next deploy --packet <json> --report <json> [--json]
357
362
  campaigns-os next qa --packet <json> --report <json> [--json]
358
363
  campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
359
- campaigns-os qa run --packet <json> [--base-url <url>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
364
+ campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
360
365
  campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
361
366
  campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
362
367
  campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
@@ -364,7 +369,7 @@ Usage:
364
369
  campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
365
370
  campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
366
371
  campaigns-os findings export [--summary | --json] [--packet <json>] [--journal <path>]
367
- campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
372
+ campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
368
373
  run_id: --run-id > the active run session > the most recent Run Record for this packet's campaign (re-emitted in place; a remitted one is left as written) > freshly minted. --new-run always mints; --list prints the run ids on disk for this packet (id, created_at, remit state, path) and, like --no-write, writes and sends nothing.
369
374
  --dry-run assembles the record and prints it (with --json: dry_run, would_write, would_remit), then writes no file and sends nothing — where --no-write skips the assembly's reads too. Combining them is allowed and still writes nothing. \`run end --dry-run\` hands the flag on to run-record and leaves the run session open, so the close can still be made for real afterwards.
370
375
 
@@ -378,7 +383,7 @@ Usage:
378
383
  campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
379
384
  campaigns-os run end [--packet <json>] [--no-remit] [--no-write] [--proxy-base <url>] [--json] # assemble the aggregated Run Record for the session, then clear it (also closes out a stale session at cwd); --proxy-base is handed to run-record, so the session record remits to that receiver under the consent scoped to it
380
385
 
381
- Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (\`theme waive\` / \`qa run --theme-waive "<reason>"\`).
386
+ Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (recorded: \`theme waive --packet <p> --reason "<why>" --waived-by "<named human>"\`; one-run ephemeral: \`qa run --theme-waive "<reason>"\`).
382
387
  Commercial parity: \`qa run\` automatically compares contract-governed authored price/cadence/voucher claims with fresh \`/api/price-preview\` evidence; no extra catalog flag is required.
383
388
  Local proof mode: under deploy.target local-serve the build stage renders the DEVELOPMENT environment (\`CPK_ENV=development npx campaign-build --json > .campaign-runtime/page-kit-build-summary.json\`, recorded as stages.assembly.evidence.build_environment) into _site/, and polish capture, browser QA and typed-card orders run against that served output; starter templates gate every vendor loader on the environment, and a production build's protocol-relative loaders (//host/...) fail over a plain-HTTP local serve. \`page-kit parity\` then proves the pin on the production render before commit; the PR preview is the second check. The toolkit never proposes editing a generated include to make a local capture pass.
384
389
  Wrapper policy: \`start\`/\`prepare-build\`/\`build\` seed source_html.adapter_contract.wrapper_policy from --wrapper-policy, else the source-html manifest's wrapper_policy key, else strip_document_wrappers. Selecting preserve_document_wrappers reports source_html.prep.document_wrapper as a warning instead of blocking, so raw-HTML source can be handed over without a wrapper-stripping pass (docs/source-adapters.md).
@@ -475,6 +480,12 @@ function closestCommand(input) {
475
480
  // (src/qc-test-factories.mjs). The bin entry point never passes it, and no
476
481
  // flag, environment variable or file can supply one.
477
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
+ }
478
489
  return runInvocation(parseArgs(argv), {
479
490
  dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
480
491
  closeOutStaleRunSessions,
@@ -728,10 +739,10 @@ export function recordQaStageOutcome(args, result) {
728
739
  disposition: verdict.disposition,
729
740
  timestamp: verdict.completed_at,
730
741
  command: `campaigns-os ${QA_RUN_PRODUCER}`,
731
- outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
742
+ outputs: portableArtifactPaths([result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString), workspace.targetRepo),
732
743
  blockers: verdict.disposition === "blocked" ? failed : [],
733
744
  warnings: verdict.disposition === "ready_with_exceptions"
734
- ? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
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."]
735
746
  : [],
736
747
  // The producer knows its own run id and must restate it, or the stage
737
748
  // keeps a previous run's identity beside this run's status and outputs.
@@ -897,6 +908,7 @@ async function autoEndRunSessionAfterTerminalQa(args, sessionHolder, thrown, imp
897
908
 
898
909
  const summary = await closeRunSession(updatedFound, {
899
910
  packet,
911
+ closedBy: "qa_auto_end",
900
912
  extraArgs,
901
913
  silent: true,
902
914
  promptForConsent: false,
@@ -1020,7 +1032,13 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1020
1032
  args.spec = resolved.specPath;
1021
1033
  // `command` rides along for the doctor sidecar's generated_by stamp when
1022
1034
  // the mode runs doctor (#312): threaded from here, not re-read from argv.
1023
- const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput, publishSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag }));
1035
+ // Doctor runs inside prepare-build, so its codes are recorded while that
1036
+ // phase is active and attach to it.
1037
+ const result = await recorder.time("prepare-build", async () => {
1038
+ const prepared = await prepareBuild(args, { ...mode, command, specInput, publishSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag });
1039
+ if (prepared.doctor && !prepared.doctor.ok) recorder.recordFindingCodes(prepared.doctor.errors?.map((issue) => issue.code));
1040
+ return prepared;
1041
+ });
1024
1042
  result.spec_source = resolved;
1025
1043
  autoStartRunSession(result, args, ambient, sessionHolder);
1026
1044
  printPrepareResult(result, args);
@@ -1032,6 +1050,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1032
1050
  // synchronous inspection; see readDoctorLiveCampaign.
1033
1051
  const liveCampaign = await readDoctorLiveCampaign(args);
1034
1052
  const result = doctorCommand(args, { liveCampaign, qcStandIns });
1053
+ if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
1035
1054
  writeResult(result, args, result.ok ? 0 : 2);
1036
1055
  printDoctorTinyPrompt(result, args);
1037
1056
  return;
@@ -1225,6 +1244,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1225
1244
  // command a QA run prints has to agree with the run_id this session will
1226
1245
  // later close and remit under.
1227
1246
  const result = await runQaCli(args, { ambient });
1247
+ if (result?.verdict?.disposition === "blocked") {
1248
+ recorder.recordFindingCodes(result.verdict.assertions?.filter((item) => item.status === "fail" || item.severity === "blocker")
1249
+ .map((item) => item.evidence?.code || item.id));
1250
+ }
1228
1251
  // nextStage requires a packet. Guard its optional, swallowed progress
1229
1252
  // probe explicitly so it cannot construct a refusal in that try block.
1230
1253
  if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
@@ -1968,7 +1991,7 @@ function prepareBuildUnderLock({
1968
1991
  },
1969
1992
  qa: {
1970
1993
  proof_policy: proofPolicy,
1971
- test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions. Run them any time with `qa run --test-order common`: when every actual terminal path in the selected checkout topology fits under the flood cap, common runs them all (effective depth `full`, reason `under_cap`); above the cap it runs checkout, first-offer accept/decline and a deduplicated shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, up to the cap, and names any page left out. The `browser-test-order:upsell-action-coverage` verdict row warns naming each offer page whose decline no executed order clicked. Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_order_bump` or `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag: depth is the only control.",
1994
+ test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions, but each creates a real store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing test orders; then run `qa run --test-order common`: when every actual terminal path in the selected checkout topology fits under the flood cap, common runs them all (effective depth `full`, reason `under_cap`); above the cap it runs checkout, first-offer accept/decline and a deduplicated shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, up to the cap, and names any page left out. The `browser-test-order:upsell-action-coverage` verdict row warns naming each offer page whose decline no executed order clicked. Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_order_bump` or `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag; depth controls coverage after the operator has answered.",
1972
1995
  },
1973
1996
  notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
1974
1997
  };
@@ -3436,7 +3459,7 @@ export function pageKitSyncCommand(args) {
3436
3459
  result.written = true;
3437
3460
  if (stageIsTerminal(report?.stages?.assembly?.status)) {
3438
3461
  addIssue(result.warnings, "page_kit.sync.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}), and the built output was rendered from the entry just rewritten: its store profile links, phone and Campaign Cart loader pin are now stale. Re-run the build stage before polish, deploy or QA; doctor's page-kit gates read ${PAGE_KIT_CAMPAIGNS_REL_PATH}, not _site/.`);
3439
- result.next = `${cmd("doctor")} --packet ${shellToken(packetPath)}, then rebuild: set stages.assembly.status back to "pending" on the Assembly Report and run ${cmd("next")} --packet ${shellToken(packetPath)}`;
3462
+ result.next = `${cmd("doctor")} --packet ${shellToken(packetPath)}, then rebuild with page-kit, record it with ${cmd("record")} build --packet ${shellToken(packetPath)} and run ${cmd("next")} --packet ${shellToken(packetPath)}`;
3440
3463
  }
3441
3464
  // The retained doctor snapshot (if any) now predates the entry it judged.
3442
3465
  // The data file is already written, so a failure here is a warning on the
@@ -4204,11 +4227,11 @@ const RUN_RECORD_QA_DIGEST_LIMIT = 8;
4204
4227
  // Digests of the verdicts the report records, best-effort: an unreadable
4205
4228
  // hint contributes no digest, which fails open to "outdated" rather than to
4206
4229
  // a false match.
4207
- function currentQaVerdictDigestsForReport(report, reportPath) {
4230
+ function currentQaVerdictDigestsForReport(report, reportPath, targetRepo) {
4208
4231
  const digests = new Set();
4209
4232
  // Lazy on purpose, and the check follows the add: once the limit is
4210
4233
  // reached the next candidate is never pulled, read or hashed.
4211
- for (const candidate of iterateQaVerdicts({ report, reportPath, withDigest: true })) {
4234
+ for (const candidate of iterateQaVerdicts({ report, reportPath, roots: [targetRepo], withDigest: true })) {
4212
4235
  if (candidate.source === "assembly_report" && candidate.sha256) digests.add(candidate.sha256);
4213
4236
  if (digests.size >= RUN_RECORD_QA_DIGEST_LIMIT) break;
4214
4237
  }
@@ -4289,7 +4312,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4289
4312
  // Atomic like the assembly report: a torn sidecar would be a corrupted
4290
4313
  // freshness artifact — the exact green-lie shape this refresh exists to
4291
4314
  // prevent (Kilo review, PR #176). Stamped generated_by: "next" (#312).
4292
- writeDoctorSidecar(doctorOutPath, doctor, { command: NEXT_PRODUCER });
4315
+ writeDoctorSidecar(doctorOutPath, doctor, { command: NEXT_PRODUCER, targetRepo });
4293
4316
  } catch {
4294
4317
  // sidecar refresh is best-effort; orchestration must not fail on it
4295
4318
  }
@@ -4305,7 +4328,10 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4305
4328
  // participate in divergence detection or override prepare-only recovery.
4306
4329
  const divergences = prepareBuildGate?.binding_failure
4307
4330
  ? []
4308
- : detectLedgerDivergence(report, packet, targetRepo);
4331
+ : detectLedgerDivergence(report, packet, targetRepo, {
4332
+ allowUnrecordedHostedPreview: polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview"
4333
+ || hostedTemplateNeedsPreviewUrl(packet, polishCheckpointGate),
4334
+ });
4309
4335
  // Every return path runs through this finalizer so the machine-readable
4310
4336
  // contract is uniform: `gates` (pass/blocked/waived/not_applicable per
4311
4337
  // gate) and `next_actions` (exact commands — not prose) are always present,
@@ -4329,7 +4355,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4329
4355
  records: readRunRecordsForTarget(dirname(packetPath)),
4330
4356
  packet,
4331
4357
  report,
4332
- currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null),
4358
+ currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null, targetRepo),
4333
4359
  qaVerdictRecorded: qaVerdictPathHints(report).length > 0,
4334
4360
  });
4335
4361
  } catch {
@@ -4439,7 +4465,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4439
4465
  errors,
4440
4466
  warnings,
4441
4467
  ready,
4442
- prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. If you need to re-run a stage, set its status back to "pending" in the report and call \`next\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.`,
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." : ""}`,
4443
4469
  });
4444
4470
  }
4445
4471
  stage = picked.stage;
@@ -4462,7 +4488,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4462
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.`);
4463
4489
  if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
4464
4490
  addThemeGateErrors(errors, themeGate, "polish");
4465
- prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
4491
+ prompt = polishPrompt(packetPath, reportPath, packet, intentSummary, polishCheckpointGate);
4466
4492
  } else if (stage === "deploy") {
4467
4493
  addPrepareBuildGateErrors(errors, report);
4468
4494
  // Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
@@ -4472,7 +4498,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4472
4498
  addPolishGateErrors(errors, polishGate, "deploy");
4473
4499
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "deploy");
4474
4500
  addThemeGateErrors(errors, themeGate, "deploy");
4475
- prompt = deployPrompt(packetPath, reportPath, packet);
4501
+ prompt = deployPrompt(packetPath, reportPath, packet, polishGate, polishCheckpointGate);
4476
4502
  } else if (stage === "qa") {
4477
4503
  addPrepareBuildGateErrors(errors, report);
4478
4504
  if (!report) addIssue(errors, "next.qa.report", "Assembly report is required before QA.");
@@ -4481,7 +4507,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4481
4507
  addPolishGateErrors(errors, polishGate, "qa");
4482
4508
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
4483
4509
  addThemeGateErrors(errors, themeGate, "qa");
4484
- prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary);
4510
+ prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
4485
4511
  }
4486
4512
  const status = errors.length
4487
4513
  ? "blocked"
@@ -4700,7 +4726,7 @@ function themeStarterPaletteAdvisory(themeGate, packetPath, residueState) {
4700
4726
  + "runs the template-residue checks at blocker severity, so `qa run` will block on "
4701
4727
  + "template-residue:<page>:style:* rows for the starter call-to-action colour. Decide before QA, "
4702
4728
  + "not after a blocked verdict. Either record an explicit operator waiver — "
4703
- + `\`${cmd("theme")} waive --packet ${packetArg} --reason "<why the starter palette is acceptable>"\` `
4729
+ + `\`${cmd("theme")} waive --packet ${packetArg} --reason "<why the starter palette is acceptable>" --waived-by "<named human>"\` `
4704
4730
  + "— which downgrades those rows to warn severity and keeps the shipped palette visible in the "
4705
4731
  + "verdict; or hand-author the brand layer (write brand-theme.css, list it after next-core.css in "
4706
4732
  + "commerce-page frontmatter styles, rebuild, then run "
@@ -4730,18 +4756,18 @@ function quoteDivergences(divergences) {
4730
4756
  .join(" ");
4731
4757
  }
4732
4758
 
4733
- function divergenceInspectAction(divergences, packetPath) {
4759
+ function divergenceInspectAction(divergences, packetPath, { packet = null, polishCheckpointGate = null } = {}) {
4734
4760
  const divergedStages = divergences.map((divergence) => divergence.stage);
4735
4761
  const forwardHint = divergedStages.includes("qa")
4736
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."
4737
4763
  : divergedStages.includes("deploy")
4738
- ? `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.`
4739
4765
  : "If the built output is real and current, the forward path is polish/deploy/QA, not re-running setup or build.";
4740
4766
  return {
4741
4767
  id: "divergence_inspect",
4742
4768
  kind: "manual",
4743
4769
  command: null,
4744
- 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.`,
4745
4771
  required: true,
4746
4772
  };
4747
4773
  }
@@ -4772,7 +4798,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4772
4798
  // of the stale ledger. Suppressing branch-by-branch would leave the next
4773
4799
  // branch someone adds unguarded; returning here cannot rot that way.
4774
4800
  // The operator reconciles, then re-runs `next` for the normal list.
4775
- const inspect = divergenceInspectAction(divergences, packetPath);
4801
+ const inspect = divergenceInspectAction(divergences, packetPath, { packet, polishCheckpointGate });
4776
4802
  push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
4777
4803
  return actions;
4778
4804
  }
@@ -4798,7 +4824,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4798
4824
  );
4799
4825
  }
4800
4826
  }
4801
- 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.");
4802
4828
  return actions;
4803
4829
  }
4804
4830
  if (result.stage === "prepare-build") {
@@ -4832,16 +4858,30 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4832
4858
  for (const action of themeGate.required_actions) {
4833
4859
  push(`theme_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4834
4860
  }
4835
- 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.");
4836
4862
  return actions;
4837
4863
  }
4838
4864
  if (polishGateRequiresBuild(polishGate) && ["build", "polish", "deploy", "qa"].includes(result.stage)) {
4839
4865
  for (const action of polishGate?.required_actions || []) {
4840
4866
  push(`polish_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4841
4867
  }
4842
- 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.");
4843
4869
  return actions;
4844
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("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.");
4882
+ return actions;
4883
+ }
4884
+ }
4845
4885
  if ((polishGate?.status === "blocked" || polishCheckpointGate?.status === "blocked")
4846
4886
  && ["deploy", "qa"].includes(result.stage)) {
4847
4887
  const checkpointActionIds = new Set(
@@ -4854,7 +4894,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4854
4894
  push(`polish_gate.${action.id}`, action.kind, asInvocation(action.command), action.description);
4855
4895
  }
4856
4896
  pushPolishCheckpointActions();
4857
- 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.");
4858
4898
  return actions;
4859
4899
  }
4860
4900
  // Ahead of every stage that still has QA in front of it, say what QA will do
@@ -4875,9 +4915,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4875
4915
  }
4876
4916
  }
4877
4917
  if (result.stage === "setup") {
4878
- 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)}.`);
4879
4919
  } else if (result.stage === "build") {
4880
- 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}` : ""}`;
4881
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}.`);
4882
4922
  if (isLocalServePacket(packet)) {
4883
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}`);
@@ -4889,16 +4929,25 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4889
4929
  }
4890
4930
  }
4891
4931
  } else if (result.stage === "polish") {
4892
- 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>.`);
4893
4935
  if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
4894
4936
  } else if (result.stage === "deploy") {
4895
- if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
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) {
4896
4941
  const plan = localServePlan(packet);
4897
- 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.`);
4942
+ 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.`);
4898
4943
  } else {
4899
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.`);
4900
4945
  }
4901
- push("advance", "command", `${cmd("next")} --packet ${packetPath} --json`, "Advance to QA once the deploy URL is recorded.");
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.");
4950
+ }
4902
4951
  } else if (result.stage === "qa") {
4903
4952
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4904
4953
  push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
@@ -5059,9 +5108,10 @@ Rules:
5059
5108
  - 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.
5060
5109
  - Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
5061
5110
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
5062
- - 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.
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.
5063
5113
  - 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.
5064
- - 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}` : ""}\`. 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.
5114
+ - 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.
5065
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)}`;
5066
5116
  }
5067
5117
 
@@ -5130,8 +5180,22 @@ When copying a starter template family, copy the family as an atomic page-kit sl
5130
5180
  Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
5131
5181
  }
5132
5182
 
5133
- function polishPrompt(packetPath, reportPath, packet, intent) {
5183
+ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGate = null) {
5134
5184
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5185
+ if (polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes") {
5186
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET && polishCheckpointGate?.status !== "carried_forward") {
5187
+ 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)}\`.`;
5188
+ }
5189
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
5190
+ 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.`;
5191
+ }
5192
+ if (polishCheckpointGate?.status !== "carried_forward" && packet.deploy?.preview_url) {
5193
+ 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
+ }
5195
+ 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.`
5197
+ : `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
+ }
5135
5199
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
5136
5200
 
5137
5201
  Read first:
@@ -5190,7 +5254,12 @@ function localServePlan(packet) {
5190
5254
  };
5191
5255
  }
5192
5256
 
5193
- function deployPrompt(packetPath, reportPath, packet) {
5257
+ 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.`;
5262
+ }
5194
5263
  const target = packet.deploy?.target || "unknown";
5195
5264
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
5196
5265
  if (target === LOCAL_SERVE_DEPLOY_TARGET) {
@@ -5212,6 +5281,9 @@ Once the server is up:
5212
5281
 
5213
5282
  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.`;
5214
5283
  }
5284
+ 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.`;
5286
+ }
5215
5287
  return `Deploy the built campaign to ${target}.
5216
5288
 
5217
5289
  Read first:
@@ -5234,36 +5306,40 @@ If the deploy is blocked (non-localhost allowed-domain not yet added, CI permiss
5234
5306
  // The QA command the QA stage hands over. Given a bump cart it is the
5235
5307
  // order-bump run: the same command with --cart selecting the base tier and
5236
5308
  // toggling each declared bump.
5237
- function qaRunCommand(packetPath, url, bumpCart = null) {
5238
- return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
5239
- }
5240
-
5241
5309
  // `qa run --test-order common` never puts a checkout order bump in a test
5242
5310
  // order: the tier planner skips bump rows by design and bump coverage comes
5243
5311
  // from --cart. So when the spec declares one, `next` names a second run with
5244
- // the bump in the cart. It reads the one checkout QA drives, with QA's own row
5245
- // classifiers: QA's test orders run on findPage(topologies, "checkout"), the
5246
- // first checkout across funnels in array order, each funnel's pages filtered
5247
- // to enabled and sorted by `order || 0` (extractTopologies). This picks the
5248
- // same page, and when that checkout declares no bump it returns null rather
5249
- // than looking at a later funnel: QA never drives a later funnel's checkout,
5250
- // 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
5251
- // keeps the page's pre-selected card, which a spec does not name. A checkout
5252
- // that declares no tier (the cart is filled on an entry page) gets the bump
5253
- // alone, on whatever selection the entry page made.
5312
+ // the bump in the cart. It reads the one checkout path QA drives, with QA's own
5313
+ // row classifiers: QA's test orders run on the Checkout (the page that takes
5314
+ // payment) of the first entry path of the first funnel that has one, each
5315
+ // funnel's pages filtered to enabled and sorted by `order || 0`
5316
+ // (extractTopologies, findPaymentPage). The rows are read from every
5317
+ // checkout-flow page on that path — the select page or first step of a
5318
+ // multi-step checkout declares the cart, not the Checkout (campaigns-os#641).
5319
+ // When that path declares no bump it returns null rather than looking at a
5320
+ // later funnel: QA never drives a later funnel's checkout, so that funnel's
5321
+ // bump ref in --cart would land on a checkout without it. The base is the
5322
+ // first selector tier QA would plan: QA's default keeps the page's
5323
+ // pre-selected card, which a spec does not name. A path that declares no tier
5324
+ // (the cart is filled on an entry page) gets the bump alone, on whatever
5325
+ // selection the entry page made.
5254
5326
  export function checkoutOrderBumpCart(spec) {
5255
- const funnels = Array.isArray(spec?.funnels)
5256
- ? spec.funnels
5257
- : Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
5327
+ const upgraded = upgradeCampaignSpec(spec);
5328
+ const funnels = Array.isArray(upgraded?.funnels)
5329
+ ? upgraded.funnels
5330
+ : Array.isArray(upgraded?.funnel_pages) ? [{ pages: upgraded.funnel_pages }] : [];
5258
5331
  for (const funnel of funnels) {
5259
- const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5332
+ const pages = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5260
5333
  .filter((page) => page && page.enabled !== false)
5261
- .sort((a, b) => (a.order || 0) - (b.order || 0))
5262
- .find((page) => page.type === "checkout");
5334
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
5335
+ const sorted = { ...funnel, pages };
5336
+ const checkout = paymentPageForFunnel(sorted);
5263
5337
  if (!checkout) continue;
5264
- const bumps = declaredOrderBumps(checkout);
5338
+ const entryPath = entryPages(sorted).map((entry) => checkoutPathFrom(pages, entry)).find((path) => path?.includes(checkout));
5339
+ const pathPages = (entryPath || [checkout]).filter((page) => isCheckoutFlowPage(page));
5340
+ const bumps = [...new Set(pathPages.flatMap((page) => declaredOrderBumps(page)))];
5265
5341
  if (!bumps.length) return null;
5266
- const base = declaredSelectorTiers(checkout)[0]?.ref || null;
5342
+ const base = pathPages.map((page) => declaredSelectorTiers(page)[0]?.ref).find(Boolean) || null;
5267
5343
  return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
5268
5344
  }
5269
5345
  return null;
@@ -5275,7 +5351,12 @@ function orderBumpRunReason(bumpCart) {
5275
5351
  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.`;
5276
5352
  }
5277
5353
 
5278
- function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
5354
+ 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)}\`.`;
5359
+ }
5279
5360
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5280
5361
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5281
5362
  const bumpCart = checkoutOrderBumpCart(spec);
@@ -5295,7 +5376,7 @@ ${cmd("qa")} install-browser
5295
5376
  Node QA command:
5296
5377
  ${qaRunCommand(packetPath, url)}${bumpCommand}
5297
5378
 
5298
- Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods and template trust badges, variant media, the template's own promo placeholders (demo timers, promo banners, placeholder voucher codes, exit-pop offers), and pricing presentation against the Campaign Build Brief. Do not flag the source design's own proof, urgency or guarantee elements: they are the merchant's content. Test Orders use global test cards that bypass the payment gateway and create no transactions, so they are safe to run any time and need no permission flags, packet policy, or merchant setup. Localhost on any port is a globally allowed Development domain for SDK initialization and suppresses Campaigns analytics events; non-localhost preview/production origins still need the SDK origin allowlist. Use --test-order common for the default depth: every actual terminal path when they fit under the flood cap, otherwise checkout, first-offer accept/decline and a deduplicated shortest real receipt path plus the shortest path that clicks the decline on each offer or downsell page not yet declined, up to the cap (the verdict row browser-test-order:upsell-action-coverage warns naming each offer page whose decline no order clicked); use an explicit path such as accept-decline-accept for a targeted matrix; or use --test-order full for every actual terminal path in the selected checkout topology. Cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. The default accidental-flood cap is 6, and an overflow names the exact explicit --max-test-orders raise. That cap bounds planned paths; --max-order-creations bounds actual order creations and is reserved before each submit click, defaulting to the planned path count. A path whose failure is classified as created (the order is already placed) is inspected read-only and never resubmitted. A not_created failure may be re-run once, if the creation budget has a slot no still-unrun planned path needs; an ambiguous failure stops that path with an explicit operator check instead of buying again. Read evidence.recovery to tell a recovered pass from a first-attempt pass. Click rendered SDK upsell accept/decline controls for upsell proof. For multi-tier package selectors, drive a specific card with --select-package <ref[:qty],...> (strict: the path fails if the requested card cannot be found or selected, unlike best-effort --cart), or use --test-order tiers / tiers:common / tiers:full to drive every selector tier the CampaignSpec declares on the checkout page in one run (order-bump rows marked is_order_bump or is_upsell are add-ons, not tiers; --select-package narrows a tiers run to the listed tiers); prove coupon-bearing orders with --apply-coupon <code> (typed into the rendered promo input, verified against the persisted-order voucher read-back). Reuse one test customer email via --test-email or CAMPAIGNS_OS_QA_TEST_EMAIL (a real monitored inbox in internal runs) so repeated QA does not litter the customer list.
5379
+ Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods and template trust badges, variant media, the template's own promo placeholders (demo timers, promo banners, placeholder voucher codes, exit-pop offers), and pricing presentation against the Campaign Build Brief. Do not flag the source design's own proof, urgency or guarantee elements: they are the merchant's content. Test Orders use global test cards that bypass the payment gateway and create no transactions, but each creates a real order record on the store. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing test orders; then proceed. No permission flag, packet policy, or merchant setup is required. Localhost on any port is a globally allowed Development domain for SDK initialization and suppresses Campaigns analytics events; non-localhost preview/production origins still need the SDK origin allowlist. Use --test-order common for the default depth: every actual terminal path when they fit under the flood cap, otherwise checkout, first-offer accept/decline and a deduplicated shortest real receipt path plus the shortest path that clicks the decline on each offer or downsell page not yet declined, up to the cap (the verdict row browser-test-order:upsell-action-coverage warns naming each offer page whose decline no order clicked); use an explicit path such as accept-decline-accept for a targeted matrix; or use --test-order full for every actual terminal path in the selected checkout topology. Cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. The default accidental-flood cap is 6, and an overflow names the exact explicit --max-test-orders raise. That cap bounds planned paths; --max-order-creations bounds actual order creations and is reserved before each submit click, defaulting to the planned path count. A path whose failure is classified as created (the order is already placed) is inspected read-only and never resubmitted. A not_created failure may be re-run once, if the creation budget has a slot no still-unrun planned path needs; an ambiguous failure stops that path with an explicit operator check instead of buying again. Read evidence.recovery to tell a recovered pass from a first-attempt pass. Click rendered SDK upsell accept/decline controls for upsell proof. For multi-tier package selectors, drive a specific card with --select-package <ref[:qty],...> (strict: the path fails if the requested card cannot be found or selected, unlike best-effort --cart), or use --test-order tiers / tiers:common / tiers:full to drive every selector tier the CampaignSpec declares on the checkout page in one run (order-bump rows marked is_order_bump or is_upsell are add-ons, not tiers; --select-package narrows a tiers run to the listed tiers); prove coupon-bearing orders with --apply-coupon <code> (typed into the rendered promo input, verified against the persisted-order voucher read-back). Reuse one test customer email via --test-email or CAMPAIGNS_OS_QA_TEST_EMAIL (a real monitored inbox in internal runs) so repeated QA does not litter the customer list.
5299
5380
 
5300
5381
  Launch readiness note: Campaigns OS can prove the campaign build, SDK wiring, browser behavior, and typed-card order paths. It does not prove the merchant is ready for real shoppers. Before launch, confirm the production storefront URL, live payment methods, shipping markets, legal/support URLs, analytics expectations, and any merchant-side configuration. Treat those as real-shopper readiness items, not Campaigns OS build blockers.
5301
5382
 
@@ -5343,7 +5424,7 @@ function qaVerdictArtifactsForCampaign(packet, targetRepo) {
5343
5424
  // Presence-based only: a stale built output still diverges from a pending
5344
5425
  // ledger, and the entry says the two disagree — it never asserts the stage
5345
5426
  // is complete or the artifact current. This function writes nothing.
5346
- export function detectLedgerDivergence(report, packet, targetRepo) {
5427
+ export function detectLedgerDivergence(report, packet, targetRepo, { allowUnrecordedHostedPreview = false } = {}) {
5347
5428
  const divergences = [];
5348
5429
  const stages = report?.stages;
5349
5430
  if (!isObject(stages) || !isNonEmptyString(targetRepo)) return divergences;
@@ -5376,7 +5457,7 @@ export function detectLedgerDivergence(report, packet, targetRepo) {
5376
5457
  const packetPreviewUrl = optionalString(packet?.deploy?.preview_url);
5377
5458
  const packetProductionUrl = optionalString(packet?.deploy?.production_url);
5378
5459
  const url = reportUrl || packetPreviewUrl || packetProductionUrl;
5379
- if (url) {
5460
+ if (url && !(allowUnrecordedHostedPreview && !reportUrl)) {
5380
5461
  const source = reportUrl
5381
5462
  ? "report.stages.deploy.outputs"
5382
5463
  : packetPreviewUrl
@@ -6621,10 +6702,10 @@ async function promptForFinding(current) {
6621
6702
  const { createInterface } = await import("node:readline/promises");
6622
6703
  const rl = createInterface({ input: process.stdin, output: process.stdout });
6623
6704
  try {
6624
- const stage = current.stage || (await rl.question(`Stage (${FINDING_STAGES.join("/")}): `)).trim();
6625
- const kind = current.kind || (await rl.question(`Kind (${FINDING_KINDS.join("/")}): `)).trim();
6626
- const summary = current.summary || (await rl.question("Summary: ")).trim();
6627
- const details = current.details || (await rl.question("Details (optional): ")).trim() || null;
6705
+ const stage = current.stage || (await timeOperatorWait(() => rl.question(`Stage (${FINDING_STAGES.join("/")}): `))).trim();
6706
+ const kind = current.kind || (await timeOperatorWait(() => rl.question(`Kind (${FINDING_KINDS.join("/")}): `))).trim();
6707
+ const summary = current.summary || (await timeOperatorWait(() => rl.question("Summary: "))).trim();
6708
+ const details = current.details || (await timeOperatorWait(() => rl.question("Details (optional): "))).trim() || null;
6628
6709
  return { stage, kind, summary, details };
6629
6710
  } finally {
6630
6711
  rl.close();
@@ -7099,7 +7180,7 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
7099
7180
  // `run end` is "assemble the Run Record for this session and stop logging to
7100
7181
  // it". A throw leaves the session active so the operator can fix the packet
7101
7182
  // and re-run `run end`. In text mode run-record prints its own summary.
7102
- const summary = await closeRunSession(found, { packet, extraArgs: args, silent: args.json === true });
7183
+ const summary = await closeRunSession(found, { packet, extraArgs: args, silent: args.json === true, closedBy: "run_end" });
7103
7184
  return { result: summary, exitCode: 0 };
7104
7185
  }
7105
7186
 
@@ -7114,13 +7195,9 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
7114
7195
  // `dryRun` entries in src/invocation.mjs and autoEndRunSessionAfterTerminalQa.
7115
7196
  export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
7116
7197
  "context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
7117
- "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
7118
7198
  "no-remit", "no-write", "proxy-base", "dry-run", "json",
7119
7199
  ]);
7120
7200
  const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
7121
- const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
7122
- "agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
7123
- ]);
7124
7201
 
7125
7202
  // The run-record argv that closes `session`: its run_id and journal, the
7126
7203
  // packet the closer resolved, and only the inheritable flags of `extraArgs`.
@@ -7144,7 +7221,7 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
7144
7221
  // session is cleared only AFTER run-record succeeds: on a throw it stays on
7145
7222
  // disk, and the error is rethrown unless `onError` takes it, in which case
7146
7223
  // the closer returns null.
7147
- async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null } = {}) {
7224
+ async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null, closedBy } = {}) {
7148
7225
  const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
7149
7226
  try {
7150
7227
  // No internal closeout currently constructs a refusal before its invoking
@@ -7152,7 +7229,7 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
7152
7229
  // run-record, run end validates its own argv first, and QA auto-end runs
7153
7230
  // after QA persistence. Keep the scope at this boundary so a future
7154
7231
  // closeout refusal swallowed by onError cannot mark the outer invocation.
7155
- const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
7232
+ const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent, closedBy }));
7156
7233
  // Clearing the session is a write like any other, so a closer carrying
7157
7234
  // --dry-run leaves it open: the operator sees the record the close would
7158
7235
  // assemble and can still close for real afterwards.
@@ -7233,6 +7310,7 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
7233
7310
  if (packet && existsSync(packet)) {
7234
7311
  const summary = await closeRunSession(stale, {
7235
7312
  packet,
7313
+ closedBy: "stale_sweep",
7236
7314
  extraArgs: { ...inherited, json: true },
7237
7315
  silent: true,
7238
7316
  promptForConsent: false,
@@ -7261,12 +7339,11 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
7261
7339
  // run-record.mjs to assemble the manifest, then (consent-gated, non-fatal)
7262
7340
  // remit it. Capture is ALWAYS local; consent gates only the remit. See
7263
7341
  // docs/workflow-findings-sidecar.md.
7264
- async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
7342
+ async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true, closedBy = "manual" } = {}) {
7265
7343
  const packetPath = resolve(requireArg(args, "packet"));
7266
7344
  if (args["new-run"] === true && optionalString(args["run-id"])) {
7267
7345
  throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
7268
7346
  }
7269
- const agentUsage = refusing(() => parseAgentUsageArgs(args));
7270
7347
  // --dry-run assembles the record and shows it, then writes and sends
7271
7348
  // nothing. It differs from --no-write, which skips the assembly's reads
7272
7349
  // as well; combining the two is allowed and still writes nothing.
@@ -7503,6 +7580,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7503
7580
  packageVersion: packageVersion(),
7504
7581
  ...toolkitProvenance({ silent }),
7505
7582
  qaVerdictPublish,
7583
+ closedBy,
7506
7584
  command: "run-record",
7507
7585
  argvShape: argvShape(args),
7508
7586
  consent: { state: consent.state, source: consent.source },
@@ -7524,7 +7602,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7524
7602
  primarySurface: optionalString(args["primary-surface"]),
7525
7603
  surfaceConfidence: optionalString(args["surface-confidence"]),
7526
7604
  lifecycle,
7527
- agentUsage,
7528
7605
  });
7529
7606
 
7530
7607
  const shouldAttemptRemit = !remitDisabled && consent.state === "on";
@@ -7886,9 +7963,7 @@ function parseRunRecordSurfaces(value) {
7886
7963
 
7887
7964
  function validateRunRecordArgv(args) {
7888
7965
  for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
7889
- // The integer flags are checked below by parseAgentUsageArgs, which keeps
7890
- // their non-negative-integer diagnostics for bare and blank values.
7891
- if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && !RUN_RECORD_INTEGER_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
7966
+ if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
7892
7967
  }
7893
7968
  if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
7894
7969
  if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
@@ -7898,41 +7973,11 @@ function validateRunRecordArgv(args) {
7898
7973
  throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
7899
7974
  }
7900
7975
  return {
7901
- agentUsage: refusing(() => parseAgentUsageArgs(args)),
7902
7976
  dryRun: isDryRun(args),
7903
7977
  parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
7904
7978
  };
7905
7979
  }
7906
7980
 
7907
- function parseAgentUsageArgs(args) {
7908
- const fields = {
7909
- "agent-input-tokens": "input_tokens",
7910
- "agent-output-tokens": "output_tokens",
7911
- "agent-tool-output-tokens": "tool_output_tokens",
7912
- "agent-total-tokens": "total_tokens",
7913
- "agent-elapsed-ms": "elapsed_ms",
7914
- };
7915
- const usage = {};
7916
- for (const [flag, field] of Object.entries(fields)) {
7917
- if (!(flag in args)) continue;
7918
- usage[field] = parseNonNegativeIntegerFlag(args[flag], flag);
7919
- }
7920
- if (isNonEmptyString(args["agent-model"])) usage.model = args["agent-model"].trim();
7921
- if (isNonEmptyString(args["agent-usage-source"])) usage.source = args["agent-usage-source"].trim();
7922
- return Object.keys(usage).length ? usage : null;
7923
- }
7924
-
7925
- function parseNonNegativeIntegerFlag(value, flag) {
7926
- if (value === true || value === false || value == null || String(value).trim() === "") {
7927
- throw new Error(`--${flag} requires a non-negative integer value.`);
7928
- }
7929
- const parsed = Number(value);
7930
- if (!Number.isInteger(parsed) || parsed < 0) {
7931
- throw new Error(`--${flag} must be a non-negative integer.`);
7932
- }
7933
- return parsed;
7934
- }
7935
-
7936
7981
  function packageVersion() {
7937
7982
  return readJson(join(ROOT, "package.json")).version;
7938
7983
  }