@nextcommerce/campaigns-os 1.50.0 → 1.52.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 (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
package/src/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  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
+ import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
23
24
  import { diagnosticExport, diagnosticTextLines } from "./diagnostic.mjs";
24
25
  import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
25
26
  import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
@@ -75,7 +76,10 @@ import {
75
76
  isWrapperPolicy,
76
77
  } from "./adapter-decision-contract.mjs";
77
78
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
78
- import { campaignSidecarPaths, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
79
+ import { QcAcceptRefusal, buildQcHandoff, parseQcResultRef, planQcAccepts, projectQcAccept, qcAcceptAttribution, qcHandoffTextLines } from "./qc-accept.mjs";
80
+ import { loadQcRederivers } from "./qc-check-registry.mjs";
81
+ import { fingerprint12, readCurrentQcResults } from "./qc-results.mjs";
82
+ import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
79
83
  import { canonicalPath, sameFile } from "./fs-identity.mjs";
80
84
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
81
85
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
@@ -119,6 +123,7 @@ import { crawlSourceAssetPaths } from "./source-asset-crawl.mjs";
119
123
  import {
120
124
  THEME_POLICIES,
121
125
  inspectBrandTheme,
126
+ themeIssueForReport,
122
127
  writeThemeArtifacts,
123
128
  } from "./brand-theme.mjs";
124
129
  import {
@@ -144,6 +149,7 @@ import {
144
149
  LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
145
150
  LOCAL_PROOF_NEVER_EDIT_RULE,
146
151
  LOCAL_PROOF_PARITY_COMMAND,
152
+ LOCAL_PROOF_RECORD_BUILD_COMMAND,
147
153
  LOCAL_PROOF_PARITY_FIELD,
148
154
  LOCAL_PROOF_PARITY_SCOPE,
149
155
  recordedBuildEnvironment,
@@ -194,7 +200,7 @@ import {
194
200
  capturePolishPageLoad,
195
201
  createPolishCaptureBinding,
196
202
  evaluateRecordedHiddenEagerMediaCheckpoint,
197
- mergePolishPageLoadEvidence,
203
+ mergePolishCaptureEvidence,
198
204
  planPolishCapture,
199
205
  } from "./polish-node.mjs";
200
206
  import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
@@ -317,12 +323,13 @@ Usage:
317
323
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
318
324
  campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--dry-run] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused. --dry-run validates the same way and prints the waiver it would write, without touching the report
319
325
  campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
326
+ campaigns-os checkpoint accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result ...] --reason "<the operator's reason>" --accepted-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # record the operator's accept of a measured QC warning, as the QC handoff of next prints it; changes no readiness and lapses when the measured state or the build changes. --dry-run runs every check and prints the accepts it would record, without touching the report
320
327
  campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
321
328
  campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
322
329
  campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
323
330
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
324
331
  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
325
- campaigns-os record build --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: 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
332
+ 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
326
333
  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
327
334
  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
328
335
  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; 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
@@ -458,9 +465,12 @@ function closestCommand(input) {
458
465
  // invocation policy: src/invocation.mjs owns it and main() only hands over the
459
466
  // mechanisms. The raw argv rides along for the handlers that must see it
460
467
  // (authentication, and the two raw-token validators).
461
- export async function main(argv, { authentication } = {}) {
468
+ // `qcStandIns` is the in-process QC stand-in override the QC tests pass
469
+ // (src/qc-test-factories.mjs). The bin entry point never passes it, and no
470
+ // flag, environment variable or file can supply one.
471
+ export async function main(argv, { authentication, qcStandIns } = {}) {
462
472
  return runInvocation(parseArgs(argv), {
463
- dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication }),
473
+ dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
464
474
  closeOutStaleRunSessions,
465
475
  ambientRunSession,
466
476
  lifecycleIdentity,
@@ -719,7 +729,7 @@ export function recordQaStageOutcome(args, result) {
719
729
  // Which build this verdict judged, and the gates whose browser outcome
720
730
  // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
721
731
  // gate that never ran is left out, so silence never reads as a pass.
722
- evidence: qaStageGateEvidence(verdict, report),
732
+ evidence: qaStageGateEvidence(verdict, report, result),
723
733
  // Counts-only: never order ids, refs, emails or URLs (see
724
734
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
725
735
  // path from a `--test-order off` diagnostic.
@@ -755,12 +765,21 @@ export function recordQaStageOutcome(args, result) {
755
765
  }
756
766
  }
757
767
 
758
- function qaStageGateEvidence(verdict, report) {
768
+ // QC results are written on every QA record, with the build
769
+ // they were measured against, whether or not a gate outcome is recorded: a
770
+ // record without qc_results reads as captured by an earlier version. The rows
771
+ // come from the QA run's own result; readers re-derive them from the full
772
+ // verdict's qc.* assertions.
773
+ function qaStageGateEvidence(verdict, report, result = null) {
759
774
  const gates = {};
760
775
  const placeholderText = summarizePlaceholderTextGate(verdict);
761
776
  if (placeholderText) gates[QA_GATE_PLACEHOLDER_TEXT_RESIDUE] = placeholderText;
762
- if (!Object.keys(gates).length) return null;
763
- return { source_build_fingerprint: currentBuildFingerprint(report), gates };
777
+ const qc = {
778
+ qc_results: Array.isArray(result?.qc_results) ? result.qc_results : [],
779
+ qc_build_fingerprint: currentBuildFingerprint(report),
780
+ };
781
+ if (!Object.keys(gates).length) return qc;
782
+ return { source_build_fingerprint: currentBuildFingerprint(report), gates, ...qc };
764
783
  }
765
784
 
766
785
  // What the auto-end says when the attempt does NOT end the session. Every
@@ -892,7 +911,7 @@ const PREPARE_MODES = Object.freeze({
892
911
  // no ambient session, and ahead of help routing, so `login --help`, `demo
893
912
  // --help` and `tooling setup --help` reach their own handlers. `argv` is the
894
913
  // unparsed argv, for the handlers that must see repeated tokens.
895
- async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication } = {}) {
914
+ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication, qcStandIns } = {}) {
896
915
  // Authentication never recovers/remits run sessions or records argv in a
897
916
  // lifecycle journal. Credentials belong only in the user credential store.
898
917
  if (command === "login" || command === "logout") {
@@ -1000,7 +1019,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1000
1019
  // The live campaign ref check's one read (#533), made before the
1001
1020
  // synchronous inspection; see readDoctorLiveCampaign.
1002
1021
  const liveCampaign = await readDoctorLiveCampaign(args);
1003
- const result = doctorCommand(args, { liveCampaign });
1022
+ const result = doctorCommand(args, { liveCampaign, qcStandIns });
1004
1023
  writeResult(result, args, result.ok ? 0 : 2);
1005
1024
  printDoctorTinyPrompt(result, args);
1006
1025
  return;
@@ -1055,6 +1074,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1055
1074
  }
1056
1075
 
1057
1076
  if (command === "checkpoint") {
1077
+ if (args._[1] === "accept") {
1078
+ const result = await checkpointAcceptCommand(args, { argv, qcStandIns });
1079
+ if (!result) return;
1080
+ writeResult(result, args, result.ok ? 0 : 2);
1081
+ return;
1082
+ }
1058
1083
  const result = waiveOrRefuse(args, () => checkpointCommand(args), {
1059
1084
  gate: optionalString(args.gate) || null,
1060
1085
  registeredGates: Object.keys(CHECKPOINT_EVALUATORS),
@@ -1154,7 +1179,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1154
1179
  // stage from the current report + doctor state. Existing form with an
1155
1180
  // explicit stage (`next build`, `next polish`, etc.) is unchanged.
1156
1181
  const stage = args._[1] || null;
1157
- const result = nextStage(stage, args, ambient);
1182
+ // The QC handoff re-derives QA and Polish results through the check
1183
+ // registry, whose check modules load lazily, so they are loaded here.
1184
+ const qcRederivers = await loadQcRederivers();
1185
+ const result = nextStage(stage, args, ambient, { qcStandIns, qcRederivers });
1158
1186
  await observeProgress(args, result, { packageVersion: packageVersion(), resolveKey: resolveCampaignsApiKeySource });
1159
1187
  writeResult(result, args, result.ok ? 0 : 2);
1160
1188
  printNextTinyPrompt(result, args);
@@ -1893,7 +1921,7 @@ function prepareBuildUnderLock({
1893
1921
  },
1894
1922
  qa: {
1895
1923
  proof_policy: proofPolicy,
1896
- 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_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.",
1924
+ 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.",
1897
1925
  },
1898
1926
  notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
1899
1927
  };
@@ -2021,7 +2049,7 @@ function prepareBuildUnderLock({
2021
2049
  if (!writtenTheme.ok && Array.isArray(writtenTheme.errors) && writtenTheme.errors.length > 0) {
2022
2050
  context.theme.warnings = [
2023
2051
  ...(context.theme.warnings || []),
2024
- ...writtenTheme.errors.map((error) => ({ code: error.code, message: error.message, detail: error.detail || null })),
2052
+ ...writtenTheme.errors.map(themeIssueForReport),
2025
2053
  ];
2026
2054
  }
2027
2055
 
@@ -2716,7 +2744,7 @@ export async function polishCaptureCommand(args, options = {}) {
2716
2744
  });
2717
2745
  assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
2718
2746
 
2719
- const merged = mergePolishPageLoadEvidence(currentReport, capture.page_load);
2747
+ const merged = mergePolishCaptureEvidence(currentReport, { pageLoad: capture.page_load, mediaWeight: capture.media_weight });
2720
2748
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
2721
2749
  packet: currentPacket,
2722
2750
  report: merged,
@@ -2811,7 +2839,7 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
2811
2839
  function checkpointCommand(args) {
2812
2840
  const subcommand = args._[1] || "help";
2813
2841
  if (subcommand !== "waive") {
2814
- throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
2842
+ throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}. Or: ${cmd("checkpoint")} accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result …] --reason "<the operator's reason>" --accepted-by "<operator's name>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run].`);
2815
2843
  }
2816
2844
  return checkpointWaive(args);
2817
2845
  }
@@ -2934,6 +2962,170 @@ export function checkpointWaive(args) {
2934
2962
  };
2935
2963
  }
2936
2964
 
2965
+ // `checkpoint accept`: records an operator's accept of a
2966
+ // measured QC warning in report.qc_accepts[], beside the unchanged
2967
+ // measurement. It reads only what is already on disk (doctor recomputed
2968
+ // offline, the persisted doctor sidecar, the Assembly Report and the full QA
2969
+ // verdict the QA stage names) and sends nothing. Every listed result must be a
2970
+ // current accept-eligible warning that was on record before this command;
2971
+ // the check runs again under the report lock immediately before the one
2972
+ // write, and if any ref fails nothing is written.
2973
+ const CHECKPOINT_ACCEPT_VALUE_FLAGS = Object.freeze({
2974
+ packet: "<campaign-runtime.build.json>",
2975
+ result: "<result_id>@<fingerprint12>",
2976
+ reason: "\"<the operator's reason>\"",
2977
+ "expires-at": "<ISO>",
2978
+ "review-condition": "\"<trigger>\"",
2979
+ report: "<json>",
2980
+ });
2981
+
2982
+ // Every value of a flag the operator may repeat, from the raw argv: the shared
2983
+ // parser keeps only the last.
2984
+ function repeatedFlagValues(argv, flag) {
2985
+ const values = [];
2986
+ const tokens = Array.isArray(argv) ? argv : [];
2987
+ for (let index = 0; index < tokens.length; index += 1) {
2988
+ if (tokens[index] !== `--${flag}`) continue;
2989
+ const next = tokens[index + 1];
2990
+ values.push(next === undefined || next.startsWith("--") ? null : next);
2991
+ }
2992
+ return values;
2993
+ }
2994
+
2995
+ function qcAcceptRefusalEnvelope(error) {
2996
+ return {
2997
+ ok: false,
2998
+ refusal_code: error.code,
2999
+ error: error.message,
3000
+ ...(error.refused?.length ? { refused: error.refused.map(({ result_ref, refusal_code }) => ({ result_ref, refusal_code })) } : {}),
3001
+ };
3002
+ }
3003
+
3004
+ export async function checkpointAcceptCommand(args, { argv = [], qcStandIns = null } = {}) {
3005
+ try {
3006
+ const qcRederivers = await loadQcRederivers();
3007
+ return checkpointAccept(args, { argv, qcStandIns, qcRederivers });
3008
+ } catch (error) {
3009
+ // A typed refusal is an invocation refusal like every untyped one
3010
+ // (refused() marks this invocation's refusal scope), so a refused accept
3011
+ // journals nothing, rendered under --json or thrown without it.
3012
+ const refusal = error instanceof QcAcceptRefusal ? refused(`${error.message} (${error.code})`) : error;
3013
+ if (args.json !== true) throw refusal;
3014
+ const envelope = error instanceof QcAcceptRefusal ? qcAcceptRefusalEnvelope(error) : { ok: false, error: String(error?.message ?? error) };
3015
+ console.log(JSON.stringify(envelope, null, 2));
3016
+ console.error(`campaigns-os: ${envelope.error}`);
3017
+ process.exitCode = 1;
3018
+ return null;
3019
+ }
3020
+ }
3021
+
3022
+ export function checkpointAccept(args, { argv = [], qcStandIns = null, qcRederivers = null } = {}) {
3023
+ // The command clock; no flag sets accepted_at.
3024
+ const now = new Date().toISOString();
3025
+ const acceptedByValues = repeatedFlagValues(argv, "accepted-by");
3026
+ const acceptedBy = acceptedByValues.length === 1 && isNonEmptyString(args["accepted-by"]) ? args["accepted-by"] : null;
3027
+ if (acceptedByValues.length > 1) throw refused("checkpoint accept takes one --accepted-by: one operator decides one accept command.");
3028
+ for (const [key, placeholder] of Object.entries(CHECKPOINT_ACCEPT_VALUE_FLAGS)) {
3029
+ if (!Object.hasOwn(args, key) || args[key] == null) continue;
3030
+ if (!isNonEmptyString(args[key])) throw refused(`--${key} needs a value: pass --${key} ${placeholder}.`);
3031
+ }
3032
+ if (repeatedFlagValues(argv, "reason").length > 1) throw refused("checkpoint accept takes one --reason per invocation: the operator's reason, verbatim.");
3033
+ const packetPath = resolve(requireArg(args, "packet"));
3034
+ const dryRun = isDryRun(args);
3035
+ const refValues = repeatedFlagValues(argv, "result");
3036
+ if (!refValues.length) throw refused(`checkpoint accept needs at least one --result <result_id>@<fingerprint12>, as the QC handoff of \`${cmd("next")}\` prints it.`);
3037
+ const refs = refValues.map((value) => {
3038
+ const parsed = parseQcResultRef(value);
3039
+ if (!parsed) throw refused(`--result ${JSON.stringify(value)} is not <result_id>@<fingerprint12>; copy the ref the QC handoff of \`${cmd("next")}\` prints.`);
3040
+ return parsed;
3041
+ });
3042
+ const duplicate = refs.find((ref, index) => refs.findIndex((other) => other.id === ref.id) !== index);
3043
+ if (duplicate) throw refused(`--result names "${duplicate.id}" more than once; list each result once.`);
3044
+ const attribution = qcAcceptAttribution({
3045
+ reason: args.reason,
3046
+ acceptedBy,
3047
+ now,
3048
+ expiresAt: args["expires-at"] ?? null,
3049
+ reviewCondition: args["review-condition"] ?? null,
3050
+ });
3051
+
3052
+ const packet = readJson(packetPath);
3053
+ const workspace = resolveCampaignWorkspace(packetPath, {
3054
+ packet,
3055
+ reportPath: args.report == null ? undefined : resolve(args.report),
3056
+ followContextPointer: false,
3057
+ });
3058
+ const { reportPath, targetRepo, doctorOutPath } = workspace;
3059
+ if (!existsSync(reportPath)) throw refused(`checkpoint accept needs an assembly report at ${reportPath}; run prepare-build/start first.`);
3060
+ // An accept only appends. A qc_accepts or evidence value that is not an
3061
+ // array is refused, never replaced, so the value already there stays as it
3062
+ // is (checked on the read before the lock and again under it).
3063
+ const refuseUnappendable = (report) => {
3064
+ for (const field of ["qc_accepts", "evidence"]) {
3065
+ if (isPlainObject(report) && Object.hasOwn(report, field) && !Array.isArray(report[field])) {
3066
+ throw refused(`checkpoint accept refused; nothing was written: report.${field} in ${reportPath} is not an array, and an accept only appends to it. It was left as it is; repair it before accepting.`);
3067
+ }
3068
+ }
3069
+ };
3070
+
3071
+ // Doctor is recomputed offline (no live refs), the spec read the way `next`
3072
+ // reads it, and every leg's results taken from its reader site.
3073
+ const plan = (report) => {
3074
+ const doctor = doctorPacket(packetPath, { reportPath, ...(qcStandIns ? { qcStandIns } : {}) });
3075
+ let spec = null;
3076
+ try {
3077
+ spec = readJsonIfExists(doctor.derived?.spec_path || null);
3078
+ } catch {
3079
+ spec = null;
3080
+ }
3081
+ const { results } = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
3082
+ return planQcAccepts({ refs, results, sidecarPath: doctorOutPath, now, attribution });
3083
+ };
3084
+ // Checked once before taking the lock, so a refusal touches nothing, and
3085
+ // again on the report read under the lock, immediately before the write.
3086
+ const unlocked = readJson(reportPath);
3087
+ refuseUnappendable(unlocked);
3088
+ plan(unlocked);
3089
+ let records = [];
3090
+ const recordAccepts = (report) => {
3091
+ refuseUnappendable(report);
3092
+ records = plan(report);
3093
+ return {
3094
+ ...report,
3095
+ qc_accepts: [...(report.qc_accepts ?? []), ...records],
3096
+ evidence: [
3097
+ ...(report.evidence ?? []),
3098
+ ...records.map((record) => `QC accept: ${record.result_id}@${fingerprint12(record.state_fingerprint)} (${record.leg}) accepted by ${record.accepted_by} at ${record.accepted_at}: ${record.reason}`),
3099
+ ],
3100
+ };
3101
+ };
3102
+ commitWaiverToAssemblyReport(workspace, recordAccepts, {
3103
+ command: "checkpoint accept",
3104
+ staleReason: `A QC accept was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3105
+ }, { dryRun });
3106
+ const accepts = records.map(projectQcAccept);
3107
+ if (dryRun) {
3108
+ return {
3109
+ ok: true,
3110
+ status: "dry_run",
3111
+ dry_run: true,
3112
+ action: "checkpoint-accept",
3113
+ accepts,
3114
+ report_path: reportPath,
3115
+ would_write: reportPath,
3116
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record these accepts.",
3117
+ };
3118
+ }
3119
+ return {
3120
+ ok: true,
3121
+ status: "recorded",
3122
+ action: "checkpoint-accept",
3123
+ accepts,
3124
+ report_path: reportPath,
3125
+ note: "Each accept changes only the result's disposition to operator_accepted. The warning, doctor status, next status and QA disposition are unchanged, and the accept lapses when the measured state or the build changes.",
3126
+ };
3127
+ }
3128
+
2937
3129
  // `page-kit sync`: write the CampaignSpec's Store Profile fields and SDK pin
2938
3130
  // into the target's _data/campaigns.json entry for the packet's route. This is
2939
3131
  // the recovery the two page-kit gates name: a fresh scaffold seeds the entry
@@ -3810,8 +4002,8 @@ export function pageKitParityCommand(args, options = {}) {
3810
4002
  const environment = recordedBuildEnvironment(report);
3811
4003
  if (environment !== LOCAL_PROOF_BUILD_ENVIRONMENT) {
3812
4004
  addIssue(result.warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
3813
- ? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}", not "${LOCAL_PROOF_BUILD_ENVIRONMENT}". If _site/ is a production build, the comparison below fails as proven_output_stale on the first environment-gated line; rebuild with ${LOCAL_PROOF_BUILD_COMMAND} and record the environment.`
3814
- : `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded. The build stage under local-serve renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records it there; the comparison below assumes _site/ is that render.`);
4005
+ ? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}", not "${LOCAL_PROOF_BUILD_ENVIRONMENT}". If _site/ is a production build, the comparison below fails as proven_output_stale on the first environment-gated line; rebuild with ${LOCAL_PROOF_BUILD_COMMAND} and record it with ${asInvocation(substitutePacket(LOCAL_PROOF_RECORD_BUILD_COMMAND, packetPath))}.`
4006
+ : `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded. The build stage under local-serve renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records it with ${asInvocation(substitutePacket(LOCAL_PROOF_RECORD_BUILD_COMMAND, packetPath))}; the comparison below assumes _site/ is that render.`);
3815
4007
  }
3816
4008
  const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
3817
4009
  const expectedSdkVersion = load.status === "ok" && typeof load.entry?.sdk_version === "string" ? load.entry.sdk_version : null;
@@ -4038,7 +4230,7 @@ function describeDeclaredDepth(purchaseProof) {
4038
4230
  return `The declared order path depth is "${purchaseProof?.declared_depth || "unspecified"}"`;
4039
4231
  }
4040
4232
 
4041
- export function nextStage(stage, args, ambient = null) {
4233
+ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRederivers = null } = {}) {
4042
4234
  if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
4043
4235
  throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
4044
4236
  }
@@ -4060,8 +4252,19 @@ export function nextStage(stage, args, ambient = null) {
4060
4252
  const doctor = doctorPacket(packetPath, {
4061
4253
  contextPath: args.context ? resolve(args.context) : undefined,
4062
4254
  reportPath: args.report ? resolve(args.report) : undefined,
4255
+ ...(qcStandIns ? { qcStandIns } : {}),
4063
4256
  });
4064
4257
  const prepareBuildGate = doctor.derived?.prepare_build_gate || null;
4258
+ // The CampaignSpec doctor just read (spec.local_path), for the QA stage's
4259
+ // order-bump command. Best-effort: doctor reports an unreadable spec itself.
4260
+ let spec = null;
4261
+ let specReadError = null;
4262
+ try {
4263
+ spec = readJsonIfExists(doctor.derived?.spec_path || null);
4264
+ } catch (error) {
4265
+ spec = null;
4266
+ specReadError = error;
4267
+ }
4065
4268
  // #171: `next` recomputes doctor state on every call; persist that fresh
4066
4269
  // snapshot so the retained sidecar can never stay a green lie from an
4067
4270
  // earlier stage while the campaign degrades (the dogfood target sat
@@ -4127,13 +4330,26 @@ export function nextStage(stage, args, ambient = null) {
4127
4330
  } });
4128
4331
  if (divergences.length) result.divergences = divergences;
4129
4332
  result.gates = buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate, packetPath });
4130
- result.next_actions = buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout, purchaseProof, brandContract: doctor.derived?.brand_contract || null, context: readJsonIfExists(contextPath), targetRepo });
4333
+ result.next_actions = buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout, purchaseProof, brandContract: doctor.derived?.brand_contract || null, context: readJsonIfExists(contextPath), targetRepo, spec });
4334
+ // The QC handoff: read from data already loaded plus the
4335
+ // full QA verdict the QA stage names. It adds nothing to errors[],
4336
+ // warnings[] or ready[], and `status` above never reads it. Its accept
4337
+ // command names the report this run read when that is not the default
4338
+ // one (from --report or the Build Context pointer), so it records there.
4339
+ const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4340
+ result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
4131
4341
  recordNextRecommendation(ambient, result);
4132
4342
  return result;
4133
4343
  };
4134
4344
  const doctorHasOnlyPolishGateErrors = doctorErrorsAreOnlyPolishGate(doctor.errors);
4135
4345
  const errors = [];
4136
4346
  const warnings = doctor.warnings.map((issue) => withPacketSubstitutedIssue(issue, packetPath));
4347
+ if (specReadError) {
4348
+ warnings.push({
4349
+ code: "next.order_bump_spec_unreadable",
4350
+ message: `The CampaignSpec at ${doctor.derived?.spec_path} could not be read (${singleLineField(specReadError.message)}), so next cannot tell whether the checkout declares an order bump and names no order-bump QA command. Fix the spec and run next again.`,
4351
+ });
4352
+ }
4137
4353
  const ready = [...doctor.ready];
4138
4354
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
4139
4355
 
@@ -4246,7 +4462,7 @@ export function nextStage(stage, args, ambient = null) {
4246
4462
  addPolishGateErrors(errors, polishGate, "qa");
4247
4463
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
4248
4464
  addThemeGateErrors(errors, themeGate, "qa");
4249
- prompt = qaPrompt(packetPath, reportPath, packet);
4465
+ prompt = qaPrompt(packetPath, reportPath, packet, spec);
4250
4466
  }
4251
4467
  const status = errors.length
4252
4468
  ? "blocked"
@@ -4513,7 +4729,7 @@ function divergenceInspectAction(divergences, packetPath) {
4513
4729
 
4514
4730
  // Executable next actions: exact commands (or explicitly-manual steps), never
4515
4731
  // prose-only guidance. Ordering is the execution order an agent should follow.
4516
- export function buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout = null, purchaseProof = null, brandContract = null, context = null, targetRepo = null }) {
4732
+ export function buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout = null, purchaseProof = null, brandContract = null, context = null, targetRepo = null, spec = null }) {
4517
4733
  const actions = [];
4518
4734
  const push = (id, kind, command, description, extras = {}) => actions.push({ id, kind, command, description, stage: result.stage, ...extras });
4519
4735
  const pushPolishCheckpointActions = () => {
@@ -4637,9 +4853,10 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4637
4853
  if (result.stage === "setup") {
4638
4854
  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}.`);
4639
4855
  } else if (result.stage === "build") {
4640
- push("build_skill", "skill", "next-campaigns-build", `Assemble the campaign per the build prompt, run the page-kit build, then record build with ${cmd("record")} build --packet ${packetPath}.`);
4856
+ const recordBuild = `${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
4857
+ push("build_skill", "skill", "next-campaigns-build", `Assemble the campaign per the build prompt, run the page-kit build, then record build with ${recordBuild}.`);
4641
4858
  if (isLocalServePacket(packet)) {
4642
- 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/ and record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} as "${LOCAL_PROOF_BUILD_ENVIRONMENT}". 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}`);
4859
+ 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}`);
4643
4860
  push("build_production_parity", "command", asInvocation(substitutePacket(LOCAL_PROOF_PARITY_COMMAND, packetPath)), "After the development build is proven, assert the production render differs from it only in environment-gated output (same pages, route slugs, Campaign Cart pin and next-api-key) before committing; the PR preview is the second check.");
4644
4861
  }
4645
4862
  if (themeGate?.status === "blocked") {
@@ -4661,7 +4878,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4661
4878
  } else if (result.stage === "qa") {
4662
4879
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4663
4880
  push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
4664
- push("qa_run", "command", `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common`, "Run browser + typed-card QA and publish the verdict.");
4881
+ push("qa_run", "command", qaRunCommand(packetPath, url), "Run browser + typed-card QA and publish the verdict.");
4882
+ const bumpCart = checkoutOrderBumpCart(spec);
4883
+ if (bumpCart) push("qa_run_bump", "command", qaRunCommand(packetPath, url, bumpCart), `Prove the charged add-on. ${orderBumpRunReason(bumpCart)}`);
4665
4884
  } else if (result.stage === "done") {
4666
4885
  // #171: run-record closeout is a REQUIRED terminal action, not an
4667
4886
  // optional nicety — the dogfood run ended at a terminal stage with the
@@ -4803,7 +5022,7 @@ Read first:
4803
5022
 
4804
5023
  Rules:
4805
5024
  - Treat CampaignSpec/API as the source for package, shipping, voucher, payment, tracking, footer, and SEO values.
4806
- - Treat the Campaign Build Brief as the merchandising/design presentation truth: page authority, palette/CTA style, variant media rules, pricing display strategy, promo/urgency language, payment/trust surfaces, display-name policy, residue policy, and QA expectations. Agents may resolve implementation uncertainty; unresolved brief questions are business uncertainty and should be asked or recorded, not guessed.
5025
+ - Treat the Campaign Build Brief as the merchandising/design presentation truth: page authority, palette/CTA style, variant media rules, pricing display strategy, the template's own promo placeholders, payment/trust surfaces, display-name policy, residue policy, and QA expectations. Agents may resolve implementation uncertainty; unresolved brief questions are business uncertainty and should be asked or recorded, not guessed.
4807
5026
  - Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
4808
5027
  - Prepared AI/exported HTML must be converted into page-kit-ready source first: keep page-owned body markup, strip document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only for page-kit links/assets/includes. Page Kit publishes src/<slug>/assets/config.js as /<slug>/config.js and src/<slug>/assets/products/foo.png as /<slug>/products/foo.png; do not leave raw /assets/... or /<slug>/assets/... references in rendered pages.
4809
5028
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
@@ -4817,17 +5036,17 @@ Rules:
4817
5036
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
4818
5037
  - 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.
4819
5038
  - 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.
4820
- - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}\`. 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.
4821
- - 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)}`;
5039
+ - 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.
5040
+ - 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)}`;
4822
5041
  }
4823
5042
 
4824
5043
  // Local proof mode lines for the build prompt: under local-serve the build is
4825
5044
  // the development render, recorded as such, and the production render is
4826
5045
  // proven by parity before commit.
4827
- function localProofPromptLines(packet) {
5046
+ function localProofPromptLines(packet, packetPath = "<packet>") {
4828
5047
  if (!isLocalServePacket(packet)) return "";
4829
5048
  return `
4830
- - Local proof mode (deploy.target is local-serve): run the page-kit build in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment — \`${LOCAL_PROOF_BUILD_COMMAND}\` — so _site/ is the development render, and record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} as "${LOCAL_PROOF_BUILD_ENVIRONMENT}" on the assembly report. The starter templates gate every vendor loader on the environment and several loaders are protocol-relative (//host/...), which fail over a plain-HTTP local serve; the SDK's dl_* events still fire in development. Polish capture, browser QA and typed-card orders run against this served output. Before committing, run \`${asInvocation(LOCAL_PROOF_PARITY_COMMAND)}\` to prove the production render differs only in environment-gated output and pins the same Campaign Cart version; the PR preview is the second check. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
5049
+ - Local proof mode (deploy.target is local-serve): run the page-kit build in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment — \`${LOCAL_PROOF_BUILD_COMMAND}\` — so _site/ is the development render, and record it with \`${cmd("record")} build --packet ${packetPath} --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}\`, which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}" on the assembly report (never hand-edit it). The starter templates gate every vendor loader on the environment and several loaders are protocol-relative (//host/...), which fail over a plain-HTTP local serve; the SDK's dl_* events still fire in development. Polish capture, browser QA and typed-card orders run against this served output. Before committing, run \`${asInvocation(LOCAL_PROOF_PARITY_COMMAND)}\` to prove the production render differs only in environment-gated output and pins the same Campaign Cart version; the PR preview is the second check. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
4831
5050
  }
4832
5051
 
4833
5052
  function setupPrompt(packetPath, contextPath, reportPath, packet) {
@@ -4952,9 +5171,57 @@ After deploy succeeds:
4952
5171
  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.`;
4953
5172
  }
4954
5173
 
4955
- function qaPrompt(packetPath, reportPath, packet) {
5174
+ // The QA command the QA stage hands over. Given a bump cart it is the
5175
+ // order-bump run: the same command with --cart selecting the base tier and
5176
+ // toggling each declared bump.
5177
+ function qaRunCommand(packetPath, url, bumpCart = null) {
5178
+ return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
5179
+ }
5180
+
5181
+ // `qa run --test-order common` never puts a checkout order bump in a test
5182
+ // order: the tier planner skips bump rows by design and bump coverage comes
5183
+ // from --cart. So when the spec declares one, `next` names a second run with
5184
+ // the bump in the cart. It reads the one checkout QA drives, with QA's own row
5185
+ // classifiers: QA's test orders run on findPage(topologies, "checkout"), the
5186
+ // first checkout across funnels in array order, each funnel's pages filtered
5187
+ // to enabled and sorted by `order || 0` (extractTopologies). This picks the
5188
+ // same page, and when that checkout declares no bump it returns null rather
5189
+ // than looking at a later funnel: QA never drives a later funnel's checkout,
5190
+ // 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
5191
+ // keeps the page's pre-selected card, which a spec does not name. A checkout
5192
+ // that declares no tier (the cart is filled on an entry page) gets the bump
5193
+ // alone, on whatever selection the entry page made.
5194
+ export function checkoutOrderBumpCart(spec) {
5195
+ const funnels = Array.isArray(spec?.funnels)
5196
+ ? spec.funnels
5197
+ : Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
5198
+ for (const funnel of funnels) {
5199
+ const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5200
+ .filter((page) => page && page.enabled !== false)
5201
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
5202
+ .find((page) => page.type === "checkout");
5203
+ if (!checkout) continue;
5204
+ const bumps = declaredOrderBumps(checkout);
5205
+ if (!bumps.length) return null;
5206
+ const base = declaredSelectorTiers(checkout)[0]?.ref || null;
5207
+ return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
5208
+ }
5209
+ return null;
5210
+ }
5211
+
5212
+ // Why the order-bump run exists, as one sentence for the action and the prompt.
5213
+ function orderBumpRunReason(bumpCart) {
5214
+ const cart = bumpCart.base ? `base tier ${bumpCart.base} plus the bump` : "the bump";
5215
+ 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.`;
5216
+ }
5217
+
5218
+ function qaPrompt(packetPath, reportPath, packet, spec = null) {
4956
5219
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4957
5220
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5221
+ const bumpCart = checkoutOrderBumpCart(spec);
5222
+ const bumpCommand = bumpCart
5223
+ ? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
5224
+ : "";
4958
5225
  return `Use next-campaigns-qa for this deployed campaign.
4959
5226
 
4960
5227
  ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
@@ -4966,9 +5233,9 @@ Browser install command:
4966
5233
  ${cmd("qa")} install-browser
4967
5234
 
4968
5235
  Node QA command:
4969
- ${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common
5236
+ ${qaRunCommand(packetPath, url)}${bumpCommand}
4970
5237
 
4971
- 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, variant media, promo/urgency copy, pricing presentation, and trust/guarantee claims against the Campaign Build Brief. 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_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.
5238
+ 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.
4972
5239
 
4973
5240
  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.
4974
5241
 
@@ -5800,7 +6067,8 @@ export async function toolingStatusCommand(args, options = {}) {
5800
6067
  const auth = result.gateway_login;
5801
6068
  if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
5802
6069
  ? "Gateway credential storage is unavailable or busy. Check user credential directory permissions and keychain access; wait for another campaigns-os process to finish. See docs/gateway-login.md for interrupted-process recovery."
5803
- : `Gateway login: ${auth.state}. Use ${result.cli.invocation_prefix} login --store <subdomain>.`);
6070
+ : `Gateway login: ${auth.state}. Optional: ${result.cli.invocation_prefix} login --store <subdomain> only lets spec derive --from-store fill the Store Profile fields (campaign.store_*).`);
6071
+ if (!auth.accounts.length && auth.state !== "unavailable") result.warnings.push(`Package, offer and shipping refs never come from the gateway login: read them with the campaign's public Campaigns API key (docs/build-packet.md, "Reading package, offer and shipping refs").`);
5804
6072
  for (const account of auth.accounts) (account.state === "logged_in" ? result.ready : result.warnings).push(`Gateway login: ${account.state}; store ${account.store}; access remaining ${account.remaining_seconds}s; gateway ${auth.gateway}; reported version ${account.gateway_version || "unavailable"} (local credential metadata only).`);
5805
6073
  return result;
5806
6074
  }
@@ -8038,6 +8306,8 @@ export function nextTinyPromptLines(result) {
8038
8306
  if (result.stage !== "qa") return lines;
8039
8307
  lines.push("");
8040
8308
  lines.push(`Next expected proof: browser QA + typed-card proof. Run: ${cmd("qa")} run --packet <packet> --base-url <url> --browser --test-order common`);
8309
+ const bumpRun = (result.next_actions || []).find((action) => action?.id === "qa_run_bump");
8310
+ if (bumpRun) lines.push(`That run never orders the checkout order bump. Prove the charged add-on with: ${bumpRun.command}`);
8041
8311
  lines.push("Localhost on any port is a Development domain (SDK allowed, analytics suppressed). Non-localhost origins still need SDK allowlist confirmation.");
8042
8312
  lines.push(`Build/polish done but no QA verdict yet is a Completeness Signal, not a build failure: ${cmd("findings")} add --stage qa --kind missing_prompt --summary "..."`);
8043
8313
  return lines;
@@ -8148,6 +8418,10 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
8148
8418
  if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
8149
8419
  if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
8150
8420
  }
8421
+ if (result.action === "checkpoint-accept") {
8422
+ for (const accept of result.accepts || []) lines.push(`${result.dry_run ? "Would accept" : "Accepted"}: ${accept.result_ref} by ${accept.accepted_by}${accept.expires_at ? ` until ${accept.expires_at}` : ""}`);
8423
+ if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
8424
+ }
8151
8425
  if (result.action === "record") {
8152
8426
  lines.push(`${result.dry_run ? "Would record" : "Recorded"}: ${result.stage}`);
8153
8427
  for (const path of result.dry_run ? result.would_write : result.written) lines.push(`${result.dry_run ? "Would write" : "Wrote"}: ${path}${result.dry_run ? " (nothing was written)" : ""}`);
@@ -8189,6 +8463,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
8189
8463
  // Directly under the findings they remediate, above the stage picker's
8190
8464
  // `Next:` block: the operator reads what is wrong, then what clears it.
8191
8465
  lines.push(...doctorRequiredActionLines(result));
8466
+ // `next` only: the QC handoff, after the issues and before the stage picker.
8467
+ if (result.qc_handoff) lines.push(...qcHandoffTextLines(result.qc_handoff));
8192
8468
  if (result.next) {
8193
8469
  lines.push("Next:");
8194
8470
  lines.push(`- ${result.next.stage || "unknown"} (${result.next.owner || result.next.default_skill || "owner unknown"})`);