@nextcommerce/campaigns-os 1.50.0 → 1.53.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -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 +282 -14
  13. package/contracts/release-ledger.json +1233 -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/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
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";
@@ -116,9 +120,11 @@ import {
116
120
  SOURCE_HTML_MANIFEST_REL_PATH,
117
121
  } from "./source-html-manifest.mjs";
118
122
  import { crawlSourceAssetPaths } from "./source-asset-crawl.mjs";
123
+ import { summarizeCampaignBrief } from "./brief-summary.mjs";
119
124
  import {
120
125
  THEME_POLICIES,
121
126
  inspectBrandTheme,
127
+ themeIssueForReport,
122
128
  writeThemeArtifacts,
123
129
  } from "./brand-theme.mjs";
124
130
  import {
@@ -144,6 +150,7 @@ import {
144
150
  LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
145
151
  LOCAL_PROOF_NEVER_EDIT_RULE,
146
152
  LOCAL_PROOF_PARITY_COMMAND,
153
+ LOCAL_PROOF_RECORD_BUILD_COMMAND,
147
154
  LOCAL_PROOF_PARITY_FIELD,
148
155
  LOCAL_PROOF_PARITY_SCOPE,
149
156
  recordedBuildEnvironment,
@@ -168,6 +175,8 @@ import {
168
175
  createCampaignBuildBriefArtifact,
169
176
  inferBuildBriefPath,
170
177
  } from "./build-brief.mjs";
178
+ import { briefIntakeBindings, currentPacketInputs, inputRefreshCommands, inputStamps, stageWriteInputs } from "./input-currency.mjs";
179
+ import { prepareBuildStageStatus, priorBriefInputsForIntake } from "./input-refresh.mjs";
171
180
  import {
172
181
  DESIGN_SOURCE_PACKAGE_REL_PATH,
173
182
  } from "./design-source-package.mjs";
@@ -194,7 +203,7 @@ import {
194
203
  capturePolishPageLoad,
195
204
  createPolishCaptureBinding,
196
205
  evaluateRecordedHiddenEagerMediaCheckpoint,
197
- mergePolishPageLoadEvidence,
206
+ mergePolishCaptureEvidence,
198
207
  planPolishCapture,
199
208
  } from "./polish-node.mjs";
200
209
  import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
@@ -317,12 +326,15 @@ Usage:
317
326
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
318
327
  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
328
  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
329
+ 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
330
  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
331
  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
332
  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
333
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
334
+ campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
335
+ campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
324
336
  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
337
+ 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
338
  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
339
  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
340
  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 +470,12 @@ function closestCommand(input) {
458
470
  // invocation policy: src/invocation.mjs owns it and main() only hands over the
459
471
  // mechanisms. The raw argv rides along for the handlers that must see it
460
472
  // (authentication, and the two raw-token validators).
461
- export async function main(argv, { authentication } = {}) {
473
+ // `qcStandIns` is the in-process QC stand-in override the QC tests pass
474
+ // (src/qc-test-factories.mjs). The bin entry point never passes it, and no
475
+ // flag, environment variable or file can supply one.
476
+ export async function main(argv, { authentication, qcStandIns } = {}) {
462
477
  return runInvocation(parseArgs(argv), {
463
- dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication }),
478
+ dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
464
479
  closeOutStaleRunSessions,
465
480
  ambientRunSession,
466
481
  lifecycleIdentity,
@@ -699,6 +714,10 @@ export function recordQaStageOutcome(args, result) {
699
714
  const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
700
715
  .filter((assertion) => assertion?.status === "fail")
701
716
  .map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
717
+ // The QA stamps are the verdict's: the brief material qa run bound at run
718
+ // start and the spec material it judged, never the inputs at write time.
719
+ const qaStamps = inputStamps({ briefMaterial: verdict.source_brief_material, specMaterial: verdict.spec_hash });
720
+ const qaInputs = stageWriteInputs("qa", currentPacketInputs({ packet, packetPath }));
702
721
  const committed = commitAssemblyReport(workspace, (report) => {
703
722
  if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
704
723
  throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
@@ -719,11 +738,13 @@ export function recordQaStageOutcome(args, result) {
719
738
  // Which build this verdict judged, and the gates whose browser outcome
720
739
  // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
721
740
  // gate that never ran is left out, so silence never reads as a pass.
722
- evidence: qaStageGateEvidence(verdict, report),
741
+ evidence: qaStageGateEvidence(verdict, report, result),
723
742
  // Counts-only: never order ids, refs, emails or URLs (see
724
743
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
725
744
  // path from a `--test-order off` diagnostic.
726
745
  proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
746
+ stamps: qaStamps,
747
+ inputs: qaInputs,
727
748
  });
728
749
  }, {
729
750
  stage: "qa",
@@ -755,12 +776,21 @@ export function recordQaStageOutcome(args, result) {
755
776
  }
756
777
  }
757
778
 
758
- function qaStageGateEvidence(verdict, report) {
779
+ // QC results are written on every QA record, with the build
780
+ // they were measured against, whether or not a gate outcome is recorded: a
781
+ // record without qc_results reads as captured by an earlier version. The rows
782
+ // come from the QA run's own result; readers re-derive them from the full
783
+ // verdict's qc.* assertions.
784
+ function qaStageGateEvidence(verdict, report, result = null) {
759
785
  const gates = {};
760
786
  const placeholderText = summarizePlaceholderTextGate(verdict);
761
787
  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 };
788
+ const qc = {
789
+ qc_results: Array.isArray(result?.qc_results) ? result.qc_results : [],
790
+ qc_build_fingerprint: currentBuildFingerprint(report),
791
+ };
792
+ if (!Object.keys(gates).length) return qc;
793
+ return { source_build_fingerprint: currentBuildFingerprint(report), gates, ...qc };
764
794
  }
765
795
 
766
796
  // What the auto-end says when the attempt does NOT end the session. Every
@@ -892,7 +922,7 @@ const PREPARE_MODES = Object.freeze({
892
922
  // no ambient session, and ahead of help routing, so `login --help`, `demo
893
923
  // --help` and `tooling setup --help` reach their own handlers. `argv` is the
894
924
  // 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 } = {}) {
925
+ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication, qcStandIns } = {}) {
896
926
  // Authentication never recovers/remits run sessions or records argv in a
897
927
  // lifecycle journal. Credentials belong only in the user credential store.
898
928
  if (command === "login" || command === "logout") {
@@ -1000,7 +1030,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1000
1030
  // The live campaign ref check's one read (#533), made before the
1001
1031
  // synchronous inspection; see readDoctorLiveCampaign.
1002
1032
  const liveCampaign = await readDoctorLiveCampaign(args);
1003
- const result = doctorCommand(args, { liveCampaign });
1033
+ const result = doctorCommand(args, { liveCampaign, qcStandIns });
1004
1034
  writeResult(result, args, result.ok ? 0 : 2);
1005
1035
  printDoctorTinyPrompt(result, args);
1006
1036
  return;
@@ -1055,6 +1085,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1055
1085
  }
1056
1086
 
1057
1087
  if (command === "checkpoint") {
1088
+ if (args._[1] === "accept") {
1089
+ const result = await checkpointAcceptCommand(args, { argv, qcStandIns });
1090
+ if (!result) return;
1091
+ writeResult(result, args, result.ok ? 0 : 2);
1092
+ return;
1093
+ }
1058
1094
  const result = waiveOrRefuse(args, () => checkpointCommand(args), {
1059
1095
  gate: optionalString(args.gate) || null,
1060
1096
  registeredGates: Object.keys(CHECKPOINT_EVALUATORS),
@@ -1154,7 +1190,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1154
1190
  // stage from the current report + doctor state. Existing form with an
1155
1191
  // explicit stage (`next build`, `next polish`, etc.) is unchanged.
1156
1192
  const stage = args._[1] || null;
1157
- const result = nextStage(stage, args, ambient);
1193
+ // The QC handoff re-derives QA and Polish results through the check
1194
+ // registry, whose check modules load lazily, so they are loaded here.
1195
+ const qcRederivers = await loadQcRederivers();
1196
+ const result = nextStage(stage, args, ambient, { qcStandIns, qcRederivers });
1158
1197
  await observeProgress(args, result, { packageVersion: packageVersion(), resolveKey: resolveCampaignsApiKeySource });
1159
1198
  writeResult(result, args, result.ok ? 0 : 2);
1160
1199
  printNextTinyPrompt(result, args);
@@ -1410,8 +1449,7 @@ function createInitialAssemblyReportStages({ scaffoldRequired, blockers, outputs
1410
1449
  // (STAGE_TERMINAL_STATUS_PREFIXES): declared out-of-scope pages do not hold
1411
1450
  // the ladder, they are recorded on the stage so downstream consumers can see
1412
1451
  // exactly which pages assemble from the template family instead of source HTML.
1413
- const terminalStatus = declaredScopeSkips.length ? "completed_partial" : "completed";
1414
- stages.prepare_build = createStage("prepare_build", blockers.length ? "blocked" : terminalStatus, {
1452
+ stages.prepare_build = createStage("prepare_build", prepareBuildStageStatus({ blockers, declaredScopeSkips }), {
1415
1453
  outputs,
1416
1454
  blockers,
1417
1455
  ...(declaredScopeSkips.length ? { declared_out_of_scope: declaredScopeSkips } : {}),
@@ -1762,6 +1800,10 @@ function prepareBuildUnderLock({
1762
1800
  sourceRoot,
1763
1801
  targetRepo,
1764
1802
  });
1803
+ // The report and normalized brief this run replaces: the report's brief
1804
+ // mode keeps an existing prepared campaign prepared (when it is this
1805
+ // campaign's report), and the previous brief carries field provenance.
1806
+ const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
1765
1807
  const buildBrief = createCampaignBuildBriefArtifact({
1766
1808
  inputPath: briefDiscovery?.path || null,
1767
1809
  inputSource: briefDiscovery?.source || null,
@@ -1771,10 +1813,19 @@ function prepareBuildUnderLock({
1771
1813
  templateFamily,
1772
1814
  sourceAssetCrawl,
1773
1815
  commerceZoneFindings,
1816
+ boundReportMode: priorBriefInputs.boundReportMode,
1817
+ previousNormalizedBrief: priorBriefInputs.previousNormalizedBrief,
1774
1818
  });
1775
1819
  if (buildBrief.inputPath && buildBrief.artifact?._meta) {
1776
1820
  buildBrief.artifact._meta.input_path = relFromFile(briefPath, buildBrief.inputPath);
1777
1821
  }
1822
+ const briefBindings = briefIntakeBindings(buildBrief);
1823
+ // The packet settings this run writes, checked against the packet on disk
1824
+ // before anything is published.
1825
+ const deploySettings = {
1826
+ target: optionalString(args["deploy-target"], "unknown"),
1827
+ preview_url: optionalString(args["preview-url"]),
1828
+ };
1778
1829
  const buildBriefPrompts = buildBrief.mode === "prepared" ? [] : buildBrief.questions.map((question) => ({
1779
1830
  code: `BUILD_BRIEF_${toConstantCase(question.id)}`,
1780
1831
  stage: "prepare_build",
@@ -1808,6 +1859,11 @@ function prepareBuildUnderLock({
1808
1859
  }))
1809
1860
  : [];
1810
1861
  const designSourcePackage = publication.package({
1862
+ plannedPacket: {
1863
+ campaign: { allowed_domains_confirmed: args["allowed-domains-confirmed"] === true },
1864
+ deploy: { ...deploySettings, production_url: optionalString(args["production-url"]) },
1865
+ qa: { proof_policy: { order_path_depth: orderPathDepthFlag || "common" } },
1866
+ },
1811
1867
  activePages,
1812
1868
  mappings: matched.mappings,
1813
1869
  manifestResult,
@@ -1859,6 +1915,7 @@ function prepareBuildUnderLock({
1859
1915
  normalized_path: relFromFile(packetPath, briefPath),
1860
1916
  question_count: buildBrief.questions.length,
1861
1917
  gate_count: buildBrief.gates.length,
1918
+ ...briefBindings,
1862
1919
  },
1863
1920
  assembly: {
1864
1921
  implementation: "next-campaigns-build",
@@ -1886,14 +1943,13 @@ function prepareBuildUnderLock({
1886
1943
  compatible_outputs: ["static-html", "campaign-cart-sdk"],
1887
1944
  },
1888
1945
  deploy: {
1889
- target: optionalString(args["deploy-target"], "unknown"),
1890
- preview_url: optionalString(args["preview-url"]),
1946
+ ...deploySettings,
1891
1947
  production_url: optionalString(args["production-url"]),
1892
1948
  live_url_path: liveUrlPath,
1893
1949
  },
1894
1950
  qa: {
1895
1951
  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.",
1952
+ 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
1953
  },
1898
1954
  notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
1899
1955
  };
@@ -1974,6 +2030,7 @@ function prepareBuildUnderLock({
1974
2030
  gate_count: buildBrief.gates.length,
1975
2031
  questions: buildBrief.questions,
1976
2032
  gates: buildBrief.gates,
2033
+ ...briefBindings,
1977
2034
  },
1978
2035
  page_map: matched.mappings.map((mapping) => ({
1979
2036
  page_id: mapping.page_id,
@@ -2021,13 +2078,14 @@ function prepareBuildUnderLock({
2021
2078
  if (!writtenTheme.ok && Array.isArray(writtenTheme.errors) && writtenTheme.errors.length > 0) {
2022
2079
  context.theme.warnings = [
2023
2080
  ...(context.theme.warnings || []),
2024
- ...writtenTheme.errors.map((error) => ({ code: error.code, message: error.message, detail: error.detail || null })),
2081
+ ...writtenTheme.errors.map(themeIssueForReport),
2025
2082
  ];
2026
2083
  }
2027
2084
 
2028
2085
  // The top-level status/next/blockers are derived from the stages by the same
2029
2086
  // function every later commit of the report runs (stage-ledger.mjs), so
2030
- // prepare-build's first write and a producer's last write spell them alike.
2087
+ // prepare-build's first write and a producer's last write spell them alike,
2088
+ // against the brief and CampaignSpec material this run binds.
2031
2089
  const report = applyDerivedAssemblyReportSummary(createAssemblyReport({
2032
2090
  packetPath,
2033
2091
  contextPath,
@@ -2044,7 +2102,7 @@ function prepareBuildUnderLock({
2044
2102
  buildScopeReasonsInvalid,
2045
2103
  templateSelection,
2046
2104
  evidence: strippedSpecBytes == null ? [] : hostStripped.evidence,
2047
- }));
2105
+ }), { briefMaterial: briefBindings.material, specMaterial: specMaterialHash(spec) });
2048
2106
 
2049
2107
  // Values a spec left as it is still holds that doctor blocks on. An
2050
2108
  // absolute http(s) page_url is not among them: projection takes its path.
@@ -2615,7 +2673,7 @@ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRu
2615
2673
  // run's own "do not write" is this wrapper's null below, not the mutator's.
2616
2674
  if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
2617
2675
  if (!dryRun) return mutated;
2618
- applyDerivedAssemblyReportSummary(mutated);
2676
+ applyDerivedAssemblyReportSummary(mutated, currentPacketInputs({ packet: workspace.packet, packetPath: workspace.packetPath }));
2619
2677
  return null;
2620
2678
  };
2621
2679
  // A preview writes nothing, so it does not take the target lock either: the
@@ -2670,7 +2728,19 @@ export async function polishCaptureCommand(args, options = {}) {
2670
2728
  throw new Error(`polish capture needs an existing Assembly Report at ${reportPath}; run prepare-build/start first.`);
2671
2729
  }
2672
2730
  requireValidPolishCaptureReport(report, reportPath);
2673
- const plan = planPolishCapture({ packet, baseUrl });
2731
+ // A campaign whose every mapping is template stock has no page_load route
2732
+ // to capture: its plan error is held, and the capture measures readability
2733
+ // on the built pages only, leaving page_load and media_weight as they are.
2734
+ const { capturePolishReadability, isNoCapturableRoutesError } = await import("./polish-node.mjs");
2735
+ const planOrHeld = (currentPacket) => {
2736
+ try {
2737
+ return { plan: planPolishCapture({ packet: currentPacket, baseUrl }), held: null };
2738
+ } catch (error) {
2739
+ if (!isNoCapturableRoutesError(error)) throw error;
2740
+ return { plan: null, held: error };
2741
+ }
2742
+ };
2743
+ const { plan, held: heldPlanError } = planOrHeld(packet);
2674
2744
  const initialBinding = createPolishCaptureBinding({
2675
2745
  packet,
2676
2746
  report,
@@ -2683,7 +2753,7 @@ export async function polishCaptureCommand(args, options = {}) {
2683
2753
  if (typeof createBrowserAdapter !== "function") {
2684
2754
  ({ createPolishBrowserAdapter: createBrowserAdapter } = await import("./polish-browser.mjs"));
2685
2755
  }
2686
- const capture = await capturePolishPageLoad({
2756
+ const capture = await (heldPlanError ? capturePolishReadability : capturePolishPageLoad)({
2687
2757
  packet,
2688
2758
  report,
2689
2759
  baseUrl,
@@ -2693,7 +2763,11 @@ export async function polishCaptureCommand(args, options = {}) {
2693
2763
  adapterStartupDeadlineMs: options.adapterStartupDeadlineMs,
2694
2764
  captureCellDeadlineMs: options.captureCellDeadlineMs,
2695
2765
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2766
+ probeClock: options.probeClock,
2767
+ targetRepo,
2696
2768
  });
2769
+ // Nothing was measured: the held plan error stands.
2770
+ if (heldPlanError && !capture.readability) throw heldPlanError;
2697
2771
 
2698
2772
  if (typeof options.afterCapture === "function") {
2699
2773
  await options.afterCapture({ packetPath, reportPath, targetRepo, capture });
@@ -2706,7 +2780,7 @@ export async function polishCaptureCommand(args, options = {}) {
2706
2780
  let checkpoint = null;
2707
2781
  commitAssemblyReport(workspace, (currentReport) => {
2708
2782
  requireValidPolishCaptureReport(currentReport, reportPath);
2709
- const currentPlan = planPolishCapture({ packet: currentPacket, baseUrl });
2783
+ const { plan: currentPlan } = planOrHeld(currentPacket);
2710
2784
  const currentBinding = createPolishCaptureBinding({
2711
2785
  packet: currentPacket,
2712
2786
  report: currentReport,
@@ -2716,7 +2790,12 @@ export async function polishCaptureCommand(args, options = {}) {
2716
2790
  });
2717
2791
  assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
2718
2792
 
2719
- const merged = mergePolishPageLoadEvidence(currentReport, capture.page_load);
2793
+ const merged = mergePolishCaptureEvidence(currentReport, {
2794
+ pageLoad: capture.page_load,
2795
+ mediaWeight: capture.media_weight,
2796
+ readability: capture.readability,
2797
+ now: options.now ?? new Date(),
2798
+ });
2720
2799
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
2721
2800
  packet: currentPacket,
2722
2801
  report: merged,
@@ -2727,19 +2806,39 @@ export async function polishCaptureCommand(args, options = {}) {
2727
2806
  staleReason: `Package-owned polish page-load evidence changed after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
2728
2807
  });
2729
2808
 
2809
+ if (heldPlanError) {
2810
+ // The readability capture completed; page_load's own outcome stays the
2811
+ // held no_capturable_routes message, reported as a warning.
2812
+ return {
2813
+ ok: true,
2814
+ status: "captured_with_warnings",
2815
+ action: "polish-capture",
2816
+ report_path: reportPath,
2817
+ measurement: null,
2818
+ checkpoint,
2819
+ observed_findings: [],
2820
+ warnings: [{ code: heldPlanError.code, message: heldPlanError.message }],
2821
+ capture: {
2822
+ route_scope: null,
2823
+ routes: [],
2824
+ viewports: [],
2825
+ readability_routes: capture.readability?.subject?.routes ?? [],
2826
+ },
2827
+ };
2828
+ }
2730
2829
  const ok = checkpoint.status === "pass" || checkpoint.status === "waived";
2731
2830
  return {
2732
2831
  ok,
2733
2832
  status: ok ? (checkpoint.status === "waived" ? "ready_with_waivers" : "ready") : "blocked",
2734
2833
  action: "polish-capture",
2735
2834
  report_path: reportPath,
2736
- measurement: capture.page_load.measurement,
2835
+ measurement: capture.page_load?.measurement ?? null,
2737
2836
  checkpoint,
2738
- observed_findings: capture.page_load.findings,
2837
+ observed_findings: capture.page_load?.findings ?? [],
2739
2838
  capture: {
2740
- route_scope: capture.plan.route_scope,
2741
- routes: capture.plan.routes.map((route) => route.requested_route),
2742
- viewports: capture.plan.viewports.map((viewport) => viewport.key),
2839
+ route_scope: capture.plan?.route_scope ?? null,
2840
+ routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
2841
+ viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
2743
2842
  },
2744
2843
  };
2745
2844
  }
@@ -2811,7 +2910,7 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
2811
2910
  function checkpointCommand(args) {
2812
2911
  const subcommand = args._[1] || "help";
2813
2912
  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(", ")}.`);
2913
+ 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
2914
  }
2816
2915
  return checkpointWaive(args);
2817
2916
  }
@@ -2934,6 +3033,170 @@ export function checkpointWaive(args) {
2934
3033
  };
2935
3034
  }
2936
3035
 
3036
+ // `checkpoint accept`: records an operator's accept of a
3037
+ // measured QC warning in report.qc_accepts[], beside the unchanged
3038
+ // measurement. It reads only what is already on disk (doctor recomputed
3039
+ // offline, the persisted doctor sidecar, the Assembly Report and the full QA
3040
+ // verdict the QA stage names) and sends nothing. Every listed result must be a
3041
+ // current accept-eligible warning that was on record before this command;
3042
+ // the check runs again under the report lock immediately before the one
3043
+ // write, and if any ref fails nothing is written.
3044
+ const CHECKPOINT_ACCEPT_VALUE_FLAGS = Object.freeze({
3045
+ packet: "<campaign-runtime.build.json>",
3046
+ result: "<result_id>@<fingerprint12>",
3047
+ reason: "\"<the operator's reason>\"",
3048
+ "expires-at": "<ISO>",
3049
+ "review-condition": "\"<trigger>\"",
3050
+ report: "<json>",
3051
+ });
3052
+
3053
+ // Every value of a flag the operator may repeat, from the raw argv: the shared
3054
+ // parser keeps only the last.
3055
+ function repeatedFlagValues(argv, flag) {
3056
+ const values = [];
3057
+ const tokens = Array.isArray(argv) ? argv : [];
3058
+ for (let index = 0; index < tokens.length; index += 1) {
3059
+ if (tokens[index] !== `--${flag}`) continue;
3060
+ const next = tokens[index + 1];
3061
+ values.push(next === undefined || next.startsWith("--") ? null : next);
3062
+ }
3063
+ return values;
3064
+ }
3065
+
3066
+ function qcAcceptRefusalEnvelope(error) {
3067
+ return {
3068
+ ok: false,
3069
+ refusal_code: error.code,
3070
+ error: error.message,
3071
+ ...(error.refused?.length ? { refused: error.refused.map(({ result_ref, refusal_code }) => ({ result_ref, refusal_code })) } : {}),
3072
+ };
3073
+ }
3074
+
3075
+ export async function checkpointAcceptCommand(args, { argv = [], qcStandIns = null } = {}) {
3076
+ try {
3077
+ const qcRederivers = await loadQcRederivers();
3078
+ return checkpointAccept(args, { argv, qcStandIns, qcRederivers });
3079
+ } catch (error) {
3080
+ // A typed refusal is an invocation refusal like every untyped one
3081
+ // (refused() marks this invocation's refusal scope), so a refused accept
3082
+ // journals nothing, rendered under --json or thrown without it.
3083
+ const refusal = error instanceof QcAcceptRefusal ? refused(`${error.message} (${error.code})`) : error;
3084
+ if (args.json !== true) throw refusal;
3085
+ const envelope = error instanceof QcAcceptRefusal ? qcAcceptRefusalEnvelope(error) : { ok: false, error: String(error?.message ?? error) };
3086
+ console.log(JSON.stringify(envelope, null, 2));
3087
+ console.error(`campaigns-os: ${envelope.error}`);
3088
+ process.exitCode = 1;
3089
+ return null;
3090
+ }
3091
+ }
3092
+
3093
+ export function checkpointAccept(args, { argv = [], qcStandIns = null, qcRederivers = null } = {}) {
3094
+ // The command clock; no flag sets accepted_at.
3095
+ const now = new Date().toISOString();
3096
+ const acceptedByValues = repeatedFlagValues(argv, "accepted-by");
3097
+ const acceptedBy = acceptedByValues.length === 1 && isNonEmptyString(args["accepted-by"]) ? args["accepted-by"] : null;
3098
+ if (acceptedByValues.length > 1) throw refused("checkpoint accept takes one --accepted-by: one operator decides one accept command.");
3099
+ for (const [key, placeholder] of Object.entries(CHECKPOINT_ACCEPT_VALUE_FLAGS)) {
3100
+ if (!Object.hasOwn(args, key) || args[key] == null) continue;
3101
+ if (!isNonEmptyString(args[key])) throw refused(`--${key} needs a value: pass --${key} ${placeholder}.`);
3102
+ }
3103
+ if (repeatedFlagValues(argv, "reason").length > 1) throw refused("checkpoint accept takes one --reason per invocation: the operator's reason, verbatim.");
3104
+ const packetPath = resolve(requireArg(args, "packet"));
3105
+ const dryRun = isDryRun(args);
3106
+ const refValues = repeatedFlagValues(argv, "result");
3107
+ if (!refValues.length) throw refused(`checkpoint accept needs at least one --result <result_id>@<fingerprint12>, as the QC handoff of \`${cmd("next")}\` prints it.`);
3108
+ const refs = refValues.map((value) => {
3109
+ const parsed = parseQcResultRef(value);
3110
+ if (!parsed) throw refused(`--result ${JSON.stringify(value)} is not <result_id>@<fingerprint12>; copy the ref the QC handoff of \`${cmd("next")}\` prints.`);
3111
+ return parsed;
3112
+ });
3113
+ const duplicate = refs.find((ref, index) => refs.findIndex((other) => other.id === ref.id) !== index);
3114
+ if (duplicate) throw refused(`--result names "${duplicate.id}" more than once; list each result once.`);
3115
+ const attribution = qcAcceptAttribution({
3116
+ reason: args.reason,
3117
+ acceptedBy,
3118
+ now,
3119
+ expiresAt: args["expires-at"] ?? null,
3120
+ reviewCondition: args["review-condition"] ?? null,
3121
+ });
3122
+
3123
+ const packet = readJson(packetPath);
3124
+ const workspace = resolveCampaignWorkspace(packetPath, {
3125
+ packet,
3126
+ reportPath: args.report == null ? undefined : resolve(args.report),
3127
+ followContextPointer: false,
3128
+ });
3129
+ const { reportPath, targetRepo, doctorOutPath } = workspace;
3130
+ if (!existsSync(reportPath)) throw refused(`checkpoint accept needs an assembly report at ${reportPath}; run prepare-build/start first.`);
3131
+ // An accept only appends. A qc_accepts or evidence value that is not an
3132
+ // array is refused, never replaced, so the value already there stays as it
3133
+ // is (checked on the read before the lock and again under it).
3134
+ const refuseUnappendable = (report) => {
3135
+ for (const field of ["qc_accepts", "evidence"]) {
3136
+ if (isPlainObject(report) && Object.hasOwn(report, field) && !Array.isArray(report[field])) {
3137
+ 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.`);
3138
+ }
3139
+ }
3140
+ };
3141
+
3142
+ // Doctor is recomputed offline (no live refs), the spec read the way `next`
3143
+ // reads it, and every leg's results taken from its reader site.
3144
+ const plan = (report) => {
3145
+ const doctor = doctorPacket(packetPath, { reportPath, ...(qcStandIns ? { qcStandIns } : {}) });
3146
+ let spec = null;
3147
+ try {
3148
+ spec = readJsonIfExists(doctor.derived?.spec_path || null);
3149
+ } catch {
3150
+ spec = null;
3151
+ }
3152
+ const { results } = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
3153
+ return planQcAccepts({ refs, results, sidecarPath: doctorOutPath, now, attribution });
3154
+ };
3155
+ // Checked once before taking the lock, so a refusal touches nothing, and
3156
+ // again on the report read under the lock, immediately before the write.
3157
+ const unlocked = readJson(reportPath);
3158
+ refuseUnappendable(unlocked);
3159
+ plan(unlocked);
3160
+ let records = [];
3161
+ const recordAccepts = (report) => {
3162
+ refuseUnappendable(report);
3163
+ records = plan(report);
3164
+ return {
3165
+ ...report,
3166
+ qc_accepts: [...(report.qc_accepts ?? []), ...records],
3167
+ evidence: [
3168
+ ...(report.evidence ?? []),
3169
+ ...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}`),
3170
+ ],
3171
+ };
3172
+ };
3173
+ commitWaiverToAssemblyReport(workspace, recordAccepts, {
3174
+ command: "checkpoint accept",
3175
+ staleReason: `A QC accept was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3176
+ }, { dryRun });
3177
+ const accepts = records.map(projectQcAccept);
3178
+ if (dryRun) {
3179
+ return {
3180
+ ok: true,
3181
+ status: "dry_run",
3182
+ dry_run: true,
3183
+ action: "checkpoint-accept",
3184
+ accepts,
3185
+ report_path: reportPath,
3186
+ would_write: reportPath,
3187
+ note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record these accepts.",
3188
+ };
3189
+ }
3190
+ return {
3191
+ ok: true,
3192
+ status: "recorded",
3193
+ action: "checkpoint-accept",
3194
+ accepts,
3195
+ report_path: reportPath,
3196
+ 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.",
3197
+ };
3198
+ }
3199
+
2937
3200
  // `page-kit sync`: write the CampaignSpec's Store Profile fields and SDK pin
2938
3201
  // into the target's _data/campaigns.json entry for the packet's route. This is
2939
3202
  // the recovery the two page-kit gates name: a fresh scaffold seeds the entry
@@ -3317,7 +3580,6 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3317
3580
  store: storeFlags
3318
3581
  ? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), ...(storeFlags.token_env ? {} : { transport: "gateway", endpoint: "https://mcp.nextcommerce.com/admin/" }), token_source: storeFlags.token_env ? `env:${storeFlags.token_env}` : "gateway:login", store_read: null, pages_read: null, primary_domain: null }
3319
3582
  : null,
3320
- rebound: { build_context: null, assembly_report: null },
3321
3583
  errors: [],
3322
3584
  warnings: [],
3323
3585
  next: `${cmd("doctor")} --packet ${shellToken(packetPath)}`,
@@ -3551,17 +3813,15 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3551
3813
  addIssue(result.warnings, "spec.derive.projection_stale", `A page route ${dryRun ? "would move" : "moved"}, and the packet's page-kit projection (source_html.pages[].page_kit) and the Build Context were prepared from the old routes. Re-run ${cmd("prepare-build")} (or start) before the next build so they describe the routes the spec carries.`);
3552
3814
  }
3553
3815
  if (stageIsTerminal(report?.stages?.assembly?.status)) {
3554
- addIssue(result.warnings, "spec.derive.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}) rendered from the spec ${dryRun ? "this would rewrite" : "just rewritten"}. Re-run the build stage before polish, deploy or QA if a route or the pin moved; QA correlates its verdict against the spec identity the sidecars carry.`);
3555
- 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)}`;
3816
+ addIssue(result.warnings, "spec.derive.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}) rendered from the spec ${dryRun ? "this would rewrite" : "just rewritten"}. Bind the rewritten spec with ${cmd("record")} spec (it keeps stage history); next then routes back to the build, and Polish and QA are owed again.`);
3556
3817
  }
3818
+ // spec derive writes the spec alone: the Build Context and the Assembly
3819
+ // Report keep the identity they bound, so every recorded stage reads its
3820
+ // stamps against the new content and `record spec` is what binds it.
3821
+ if (report) result.next = `${cmd("record")} spec --packet ${shellToken(packetPath)} ${dryRun ? "after the write " : ""}to bind the derived spec (it keeps stage history), then ${cmd("next")} --packet ${shellToken(packetPath)}`;
3557
3822
  }
3558
3823
 
3559
3824
  if (plan.changes.length && !dryRun) {
3560
- // The identity the sidecars bound to the spec BEFORE this write, so the
3561
- // re-bind below can tell "bound to the spec being replaced" from "already
3562
- // drifted" and only ever moves the former.
3563
- const beforeRawHash = createHash("sha256").update(text).digest("hex");
3564
- const beforeMaterialHash = specMaterialHash(spec);
3565
3825
  // The plan already refused every path its containers cannot take, so a
3566
3826
  // throw here is a defect in this toolkit rather than in the spec; it is
3567
3827
  // still a structured error, never a crash past the --json contract.
@@ -3599,82 +3859,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3599
3859
  rmSync(tmpPath, { force: true });
3600
3860
  }
3601
3861
  result.written = true;
3602
- // The Build Context and the Assembly Report carry the spec's identity
3603
- // (raw and material hashes) from prepare-build, and QA's verdict is
3604
- // correlated against the material hash by the bundle check. Each sidecar
3605
- // that was bound to the spec just replaced is re-bound to the new one;
3606
- // one that already carried another identity is left as it is and named.
3607
- const afterRawHash = createHash("sha256").update(serialized).digest("hex");
3608
- const afterMaterialHash = specMaterialHash(spec);
3609
- const boundToOld = (raw, material) => raw === beforeRawHash || material === beforeMaterialHash;
3610
- // A sidecar is re-bound only when it names the spec being written: two
3611
- // packets sharing a target repo can carry byte-identical spec exports,
3612
- // and a matching hash alone would let one packet's derive re-bind the
3613
- // other's sidecar to a spec it never used.
3614
- const namesThisSpec = (recorded) => {
3615
- if (!isNonEmptyString(recorded)) return false;
3616
- try {
3617
- return realpathSync(resolve(targetRepo, recorded)) === realSpecPath;
3618
- } catch {
3619
- return false;
3620
- }
3621
- };
3622
- const contextPath = workspace?.contextPath || null;
3623
- let context = null;
3624
- try {
3625
- context = contextPath ? readJsonIfExists(contextPath) : null;
3626
- } catch (error) {
3627
- result.rebound.build_context = false;
3628
- addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context could not be read (${singleLineDetail(error.message)}); its spec identity was not updated. Re-run prepare-build before QA so the bundle correlates.`);
3629
- }
3630
- if (isObject(context?.spec)) {
3631
- if (!namesThisSpec(context.spec.path)) {
3632
- result.rebound.build_context = false;
3633
- addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Build Context names a different spec file than the one derive wrote (another packet's, or a moved export); it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
3634
- } else if (boundToOld(context.spec.hash, context.spec.material_hash)) {
3635
- try {
3636
- writeJsonAtomic(contextPath, { ...context, spec: { ...context.spec, hash: afterRawHash, material_hash: afterMaterialHash } });
3637
- result.rebound.build_context = true;
3638
- } catch (error) {
3639
- result.rebound.build_context = false;
3640
- addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context's spec identity could not be updated (${singleLineDetail(error.message)}); re-run prepare-build before QA so the bundle correlates.`);
3641
- }
3642
- } else {
3643
- result.rebound.build_context = false;
3644
- addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context's spec identity was already bound to a different spec than the one derive replaced; it was left as it is. Re-run prepare-build before QA so the bundle correlates.`);
3645
- }
3646
- }
3647
- if (report && isObject(report.identity) && workspace) {
3648
- if (!namesThisSpec(report.inputs?.spec_path)) {
3649
- result.rebound.assembly_report = false;
3650
- addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Assembly Report names a different spec file than the one derive wrote (another packet's, or a moved export); it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
3651
- } else if (boundToOld(report.identity.spec_hash, report.identity.spec_material_hash)) {
3652
- try {
3653
- // The identity is re-checked on the report as it is re-read for
3654
- // the commit, so a prepare-build that re-bound it in the meantime
3655
- // is left alone.
3656
- const committed = commitAssemblyReport(workspace, (current) => (
3657
- isObject(current.identity) && boundToOld(current.identity.spec_hash, current.identity.spec_material_hash)
3658
- ? { ...current, identity: { ...current.identity, spec_hash: afterRawHash, spec_material_hash: afterMaterialHash } }
3659
- : null
3660
- ), {
3661
- command: "spec derive",
3662
- staleReason: `spec derive rewrote the CampaignSpec after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3663
- });
3664
- result.rebound.assembly_report = committed.written;
3665
- if (!committed.written) addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Assembly Report's spec identity moved while spec derive was running; it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
3666
- } catch (error) {
3667
- result.rebound.assembly_report = false;
3668
- addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Assembly Report's spec identity could not be updated (${singleLineDetail(error.message)}); re-run prepare-build before QA so the bundle correlates.`);
3669
- }
3670
- } else {
3671
- result.rebound.assembly_report = false;
3672
- addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Assembly Report's spec identity was already bound to a different spec than the one derive replaced; it was left as it is. Re-run prepare-build before QA so the bundle correlates.`);
3673
- }
3674
- }
3675
3862
  // The retained doctor snapshot (if any) now predates the spec it judged.
3676
- // commitAssemblyReport stamps it when the report was re-bound; every
3677
- // other path stamps it here.
3678
3863
  try {
3679
3864
  markDoctorSidecarStale(targetRepo, {
3680
3865
  command: "spec derive",
@@ -3810,8 +3995,8 @@ export function pageKitParityCommand(args, options = {}) {
3810
3995
  const environment = recordedBuildEnvironment(report);
3811
3996
  if (environment !== LOCAL_PROOF_BUILD_ENVIRONMENT) {
3812
3997
  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.`);
3998
+ ? `${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))}.`
3999
+ : `${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
4000
  }
3816
4001
  const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
3817
4002
  const expectedSdkVersion = load.status === "ok" && typeof load.entry?.sdk_version === "string" ? load.entry.sdk_version : null;
@@ -4038,7 +4223,7 @@ function describeDeclaredDepth(purchaseProof) {
4038
4223
  return `The declared order path depth is "${purchaseProof?.declared_depth || "unspecified"}"`;
4039
4224
  }
4040
4225
 
4041
- export function nextStage(stage, args, ambient = null) {
4226
+ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRederivers = null } = {}) {
4042
4227
  if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
4043
4228
  throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
4044
4229
  }
@@ -4053,6 +4238,8 @@ export function nextStage(stage, args, ambient = null) {
4053
4238
  followContextPointer: true,
4054
4239
  });
4055
4240
  const report = readJsonIfExists(reportPath);
4241
+ // The campaign intent summary, computed once per call and only printed.
4242
+ const intentSummary = readIntentSummary(packetPath, packet, contextPath);
4056
4243
  // Doctor reads the same sidecars through the same resolver (an operator's
4057
4244
  // explicit --context / --report passed through, the defaults derived), so
4058
4245
  // its prepare-build gate and its stage pick are this command's: `next`
@@ -4060,8 +4247,19 @@ export function nextStage(stage, args, ambient = null) {
4060
4247
  const doctor = doctorPacket(packetPath, {
4061
4248
  contextPath: args.context ? resolve(args.context) : undefined,
4062
4249
  reportPath: args.report ? resolve(args.report) : undefined,
4250
+ ...(qcStandIns ? { qcStandIns } : {}),
4063
4251
  });
4064
4252
  const prepareBuildGate = doctor.derived?.prepare_build_gate || null;
4253
+ // The CampaignSpec doctor just read (spec.local_path), for the QA stage's
4254
+ // order-bump command. Best-effort: doctor reports an unreadable spec itself.
4255
+ let spec = null;
4256
+ let specReadError = null;
4257
+ try {
4258
+ spec = readJsonIfExists(doctor.derived?.spec_path || null);
4259
+ } catch (error) {
4260
+ spec = null;
4261
+ specReadError = error;
4262
+ }
4065
4263
  // #171: `next` recomputes doctor state on every call; persist that fresh
4066
4264
  // snapshot so the retained sidecar can never stay a green lie from an
4067
4265
  // earlier stage while the campaign degrades (the dogfood target sat
@@ -4127,13 +4325,31 @@ export function nextStage(stage, args, ambient = null) {
4127
4325
  } });
4128
4326
  if (divergences.length) result.divergences = divergences;
4129
4327
  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 });
4328
+ if (doctor.derived?.input_currency) result.input_currency = doctor.derived.input_currency;
4329
+ 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 });
4330
+ // The QC handoff: read from data already loaded plus the
4331
+ // full QA verdict the QA stage names. It adds nothing to errors[],
4332
+ // warnings[] or ready[], and `status` above never reads it. Its accept
4333
+ // command names the report this run read when that is not the default
4334
+ // one (from --report or the Build Context pointer), so it records there.
4335
+ const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4336
+ result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
4337
+ // One note per operator decision that kept the build output unchanged
4338
+ // after an input change, quoting the operator's reason.
4339
+ for (const issue of doctor.warnings) if (issue.code === "assembly.output_unchanged_by_operator_decision") (result.qc_handoff.notes ||= []).push(`Build output kept unchanged after an input change by the operator's decision: "${issue.detail.reason}"`);
4131
4340
  recordNextRecommendation(ambient, result);
4341
+ result.intent_summary = intentSummary;
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
 
@@ -4214,12 +4430,12 @@ export function nextStage(stage, args, ambient = null) {
4214
4430
  if (stage === "setup") {
4215
4431
  addPrepareBuildGateErrors(errors, report);
4216
4432
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.setup.doctor", "Doctor is blocked; resolve packet errors before setup.");
4217
- prompt = setupPrompt(packetPath, contextPath, reportPath, packet);
4433
+ prompt = setupPrompt(packetPath, contextPath, reportPath, packet, intentSummary);
4218
4434
  } else if (stage === "build") {
4219
4435
  addPrepareBuildGateErrors(errors, report);
4220
4436
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.build.doctor", "Doctor is blocked; resolve packet errors before build.");
4221
4437
  if (doctor.derived?.scaffold_required) addIssue(errors, "next.build.setup", doctor.derived.scaffold_reason || "Setup is required before build.");
4222
- prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived);
4438
+ prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived, intentSummary);
4223
4439
  } else if (stage === "polish") {
4224
4440
  addPrepareBuildGateErrors(errors, report);
4225
4441
  if (!report) addIssue(errors, "next.polish.report", "Assembly report is required before polish.");
@@ -4227,7 +4443,7 @@ export function nextStage(stage, args, ambient = null) {
4227
4443
  if (!assemblyStatus.startsWith("completed")) addIssue(errors, "next.polish.assembly", `Assembly status is "${assemblyStatus || "missing"}"; polish expects completed assembly or an explicit blocked/skipped handoff.`);
4228
4444
  if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
4229
4445
  addThemeGateErrors(errors, themeGate, "polish");
4230
- prompt = polishPrompt(packetPath, reportPath, packet);
4446
+ prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
4231
4447
  } else if (stage === "deploy") {
4232
4448
  addPrepareBuildGateErrors(errors, report);
4233
4449
  // Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
@@ -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, intentSummary);
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 = () => {
@@ -4541,6 +4757,11 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4541
4757
  push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
4542
4758
  return actions;
4543
4759
  }
4760
+ for (const subcommand of inputRefreshCommands(result.input_currency)) {
4761
+ push("refresh_inputs", "command", `${cmd("record")} ${subcommand} --packet ${shellToken(packetPath)} --json`, subcommand === "brief"
4762
+ ? "A recorded stage was made against earlier Campaign Build Brief content: save the brief with record brief (keeps stage history), then re-run next."
4763
+ : "A recorded stage was made against earlier CampaignSpec content: bind it with record spec (keeps stage history), then re-run next.");
4764
+ }
4544
4765
  if (result.stage === "doctor-blocked") {
4545
4766
  const checkpointGates = (result.gates || []).filter(
4546
4767
  (gate) => gate?.status === "blocked" && Object.hasOwn(CHECKPOINT_EVALUATORS, gate?.id || ""),
@@ -4637,9 +4858,10 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4637
4858
  if (result.stage === "setup") {
4638
4859
  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
4860
  } 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}.`);
4861
+ const recordBuild = `${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
4862
+ 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
4863
  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}`);
4864
+ 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
4865
  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
4866
  }
4645
4867
  if (themeGate?.status === "blocked") {
@@ -4661,7 +4883,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4661
4883
  } else if (result.stage === "qa") {
4662
4884
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4663
4885
  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.");
4886
+ push("qa_run", "command", qaRunCommand(packetPath, url), "Run browser + typed-card QA and publish the verdict.");
4887
+ const bumpCart = checkoutOrderBumpCart(spec);
4888
+ if (bumpCart) push("qa_run_bump", "command", qaRunCommand(packetPath, url, bumpCart), `Prove the charged add-on. ${orderBumpRunReason(bumpCart)}`);
4665
4889
  } else if (result.stage === "done") {
4666
4890
  // #171: run-record closeout is a REQUIRED terminal action, not an
4667
4891
  // optional nicety — the dogfood run ended at a terminal stage with the
@@ -4789,9 +5013,9 @@ function templateStockPromptLine(packet, derived = {}) {
4789
5013
  return `\n- Template-stock pages (declared out of source scope; no design source exists for them): ${pages.join(", ")}. Keep these routes unbuilt by default, especially presell/landing pages staying on another host; never publish stock placeholder copy to fill a partial build. Materialise a page only with explicit operator opt-in for that page (including a pre-checkout select stand-in required by the runtime). For opted-in pages, use the ${packet.assembly.template_family} family's own page for that role with its dependent _includes, _layouts, and assets, wire it from CampaignSpec, and build a required select step first. Once its built HTML exists, doctor lists it among the previewable routes.`;
4790
5014
  }
4791
5015
 
4792
- function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}) {
5016
+ function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
4793
5017
  const briefPath = packet.build_brief?.normalized_path || "(missing; generate or confirm Campaign Build Brief before business-sensitive assembly)";
4794
- return `Use next-campaigns-build for this Campaigns OS handoff.
5018
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
4795
5019
 
4796
5020
  Read first:
4797
5021
  - Build Packet: ${packetPath}
@@ -4803,7 +5027,8 @@ Read first:
4803
5027
 
4804
5028
  Rules:
4805
5029
  - 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.
5030
+ - 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.
5031
+ - Refresh a changed Campaign Build Brief with \`${cmd("record")} brief --packet ${packetPath}\` and a changed CampaignSpec with \`${cmd("record")} spec --packet ${packetPath}\` before building on the change.
4807
5032
  - Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
4808
5033
  - 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
5034
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
@@ -4817,22 +5042,57 @@ Rules:
4817
5042
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
4818
5043
  - 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
5044
  - 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)}`;
5045
+ - 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.
5046
+ - 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
5047
  }
4823
5048
 
4824
5049
  // Local proof mode lines for the build prompt: under local-serve the build is
4825
5050
  // the development render, recorded as such, and the production render is
4826
5051
  // proven by parity before commit.
4827
- function localProofPromptLines(packet) {
5052
+ function localProofPromptLines(packet, packetPath = "<packet>") {
4828
5053
  if (!isLocalServePacket(packet)) return "";
4829
5054
  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}`;
5055
+ - 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}`;
5056
+ }
5057
+
5058
+ // Read the normalized brief the packet records, resolved against the packet
5059
+ // file as doctor resolves it, and summarize it over the Build Context's active
5060
+ // pages in recorded order. A missing, unreadable or invalid brief reads
5061
+ // unavailable; this never fails `next`. Each read or summarising failure is
5062
+ // named on stderr, one line per failure, never on stdout.
5063
+ function readIntentSummary(packetPath, packet, contextPath) {
5064
+ const reportFailure = (error) => process.stderr.write(`campaigns-os: intent summary unavailable: ${singleLineFragment(error?.message ?? error, "unknown error")}\n`);
5065
+ const normalizedPath = resolveFromFile(packetPath, packet?.build_brief?.normalized_path);
5066
+ let brief = null;
5067
+ try {
5068
+ brief = readJsonIfExists(normalizedPath);
5069
+ } catch (error) {
5070
+ reportFailure(error);
5071
+ brief = null;
5072
+ }
5073
+ let activePageIds = [];
5074
+ try {
5075
+ const activePages = readJsonIfExists(contextPath)?.spec?.active_pages;
5076
+ activePageIds = Array.isArray(activePages) ? activePages.map((page) => page?.id) : [];
5077
+ } catch (error) {
5078
+ reportFailure(error);
5079
+ activePageIds = [];
5080
+ }
5081
+ return { ...summarizeCampaignBrief({ brief, activePageIds, onError: reportFailure }), normalized_path: normalizedPath };
5082
+ }
5083
+
5084
+ // The setup, build, Polish and QA prompts start with the campaign intent
5085
+ // summary, framed as orientation only.
5086
+ function campaignIntentPromptHeader(intent) {
5087
+ return `Campaign intent (from the Campaign Build Brief at ${intent.normalized_path || "(not recorded)"}; orientation only, never a source of prices or commerce behaviour):
5088
+ ${intent.text}
5089
+
5090
+ `;
4831
5091
  }
4832
5092
 
4833
- function setupPrompt(packetPath, contextPath, reportPath, packet) {
5093
+ function setupPrompt(packetPath, contextPath, reportPath, packet, intent) {
4834
5094
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
4835
- return `Use next-campaigns-os-setup for this Campaigns OS handoff.
5095
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-os-setup for this Campaigns OS handoff.
4836
5096
 
4837
5097
  Read first:
4838
5098
  - Build Packet: ${packetPath}
@@ -4851,9 +5111,9 @@ When copying a starter template family, copy the family as an atomic page-kit sl
4851
5111
  Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
4852
5112
  }
4853
5113
 
4854
- function polishPrompt(packetPath, reportPath, packet) {
5114
+ function polishPrompt(packetPath, reportPath, packet, intent) {
4855
5115
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
4856
- return `Use next-campaigns-polish for this built campaign.
5116
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
4857
5117
 
4858
5118
  Read first:
4859
5119
  - Build Packet: ${packetPath}
@@ -4952,10 +5212,58 @@ After deploy succeeds:
4952
5212
  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
5213
  }
4954
5214
 
4955
- function qaPrompt(packetPath, reportPath, packet) {
5215
+ // The QA command the QA stage hands over. Given a bump cart it is the
5216
+ // order-bump run: the same command with --cart selecting the base tier and
5217
+ // toggling each declared bump.
5218
+ function qaRunCommand(packetPath, url, bumpCart = null) {
5219
+ return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
5220
+ }
5221
+
5222
+ // `qa run --test-order common` never puts a checkout order bump in a test
5223
+ // order: the tier planner skips bump rows by design and bump coverage comes
5224
+ // from --cart. So when the spec declares one, `next` names a second run with
5225
+ // the bump in the cart. It reads the one checkout QA drives, with QA's own row
5226
+ // classifiers: QA's test orders run on findPage(topologies, "checkout"), the
5227
+ // first checkout across funnels in array order, each funnel's pages filtered
5228
+ // to enabled and sorted by `order || 0` (extractTopologies). This picks the
5229
+ // same page, and when that checkout declares no bump it returns null rather
5230
+ // than looking at a later funnel: QA never drives a later funnel's checkout,
5231
+ // 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
5232
+ // keeps the page's pre-selected card, which a spec does not name. A checkout
5233
+ // that declares no tier (the cart is filled on an entry page) gets the bump
5234
+ // alone, on whatever selection the entry page made.
5235
+ export function checkoutOrderBumpCart(spec) {
5236
+ const funnels = Array.isArray(spec?.funnels)
5237
+ ? spec.funnels
5238
+ : Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
5239
+ for (const funnel of funnels) {
5240
+ const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
5241
+ .filter((page) => page && page.enabled !== false)
5242
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
5243
+ .find((page) => page.type === "checkout");
5244
+ if (!checkout) continue;
5245
+ const bumps = declaredOrderBumps(checkout);
5246
+ if (!bumps.length) return null;
5247
+ const base = declaredSelectorTiers(checkout)[0]?.ref || null;
5248
+ return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
5249
+ }
5250
+ return null;
5251
+ }
5252
+
5253
+ // Why the order-bump run exists, as one sentence for the action and the prompt.
5254
+ function orderBumpRunReason(bumpCart) {
5255
+ const cart = bumpCart.base ? `base tier ${bumpCart.base} plus the bump` : "the bump";
5256
+ 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.`;
5257
+ }
5258
+
5259
+ function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
4956
5260
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4957
5261
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
4958
- return `Use next-campaigns-qa for this deployed campaign.
5262
+ const bumpCart = checkoutOrderBumpCart(spec);
5263
+ const bumpCommand = bumpCart
5264
+ ? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
5265
+ : "";
5266
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-qa for this deployed campaign.
4959
5267
 
4960
5268
  ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
4961
5269
  Base URL: ${url}
@@ -4966,9 +5274,9 @@ Browser install command:
4966
5274
  ${cmd("qa")} install-browser
4967
5275
 
4968
5276
  Node QA command:
4969
- ${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common
5277
+ ${qaRunCommand(packetPath, url)}${bumpCommand}
4970
5278
 
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.
5279
+ 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
5280
 
4973
5281
  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
5282
 
@@ -5800,7 +6108,8 @@ export async function toolingStatusCommand(args, options = {}) {
5800
6108
  const auth = result.gateway_login;
5801
6109
  if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
5802
6110
  ? "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>.`);
6111
+ : `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_*).`);
6112
+ 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
6113
  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
6114
  return result;
5806
6115
  }
@@ -6128,7 +6437,7 @@ function safePolishByteCount(value) {
6128
6437
  }
6129
6438
 
6130
6439
  export function formatPolishCaptureText(result) {
6131
- const status = ["ready", "ready_with_waivers", "blocked"].includes(result?.status)
6440
+ const status = ["ready", "ready_with_waivers", "captured_with_warnings", "blocked"].includes(result?.status)
6132
6441
  ? result.status
6133
6442
  : "unknown";
6134
6443
  const measurementStatus = ["complete", "incomplete"].includes(result?.measurement?.status)
@@ -6142,6 +6451,11 @@ export function formatPolishCaptureText(result) {
6142
6451
  `Status: ${status.toUpperCase()}`,
6143
6452
  `Measurement: ${measurementStatus.toUpperCase()}`,
6144
6453
  ];
6454
+ // Outcomes that did not stop the capture, printed with their own message
6455
+ // (the held page-load plan message on a campaign with no mapped pages).
6456
+ for (const warning of Array.isArray(result?.warnings) ? result.warnings : []) {
6457
+ if (warning?.code === "no_capturable_routes" && typeof warning.message === "string") lines.push(`Warning: ${warning.message}`);
6458
+ }
6145
6459
  const incomplete = Array.isArray(result?.measurement?.incomplete)
6146
6460
  ? result.measurement.incomplete
6147
6461
  : [];
@@ -8038,6 +8352,8 @@ export function nextTinyPromptLines(result) {
8038
8352
  if (result.stage !== "qa") return lines;
8039
8353
  lines.push("");
8040
8354
  lines.push(`Next expected proof: browser QA + typed-card proof. Run: ${cmd("qa")} run --packet <packet> --base-url <url> --browser --test-order common`);
8355
+ const bumpRun = (result.next_actions || []).find((action) => action?.id === "qa_run_bump");
8356
+ if (bumpRun) lines.push(`That run never orders the checkout order bump. Prove the charged add-on with: ${bumpRun.command}`);
8041
8357
  lines.push("Localhost on any port is a Development domain (SDK allowed, analytics suppressed). Non-localhost origins still need SDK allowlist confirmation.");
8042
8358
  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
8359
  return lines;
@@ -8141,6 +8457,8 @@ const WAIVE_ACTIONS = new Set(["theme-waive", "checkpoint-waive"]);
8141
8457
  // check's readiness line); they are text-only and never enter the JSON result.
8142
8458
  export function resultTextLines(result, { headerLines = [] } = {}) {
8143
8459
  const lines = [`Status: ${String(result.status || "unknown").toUpperCase()}`, ...headerLines];
8460
+ // `next` only: the campaign intent summary, directly under the status.
8461
+ if (result.intent_summary) lines.push("Campaign intent:", ...result.intent_summary.text.split("\n"));
8144
8462
  // A waive command's second line names what it recorded; the third is the
8145
8463
  // stage doctor now picks for the report the waiver was written to.
8146
8464
  if (WAIVE_ACTIONS.has(result.action) && result.gate) {
@@ -8148,6 +8466,10 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
8148
8466
  if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
8149
8467
  if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
8150
8468
  }
8469
+ if (result.action === "checkpoint-accept") {
8470
+ 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}` : ""}`);
8471
+ if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
8472
+ }
8151
8473
  if (result.action === "record") {
8152
8474
  lines.push(`${result.dry_run ? "Would record" : "Recorded"}: ${result.stage}`);
8153
8475
  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 +8511,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
8189
8511
  // Directly under the findings they remediate, above the stage picker's
8190
8512
  // `Next:` block: the operator reads what is wrong, then what clears it.
8191
8513
  lines.push(...doctorRequiredActionLines(result));
8514
+ // `next` only: the QC handoff, after the issues and before the stage picker.
8515
+ if (result.qc_handoff) lines.push(...qcHandoffTextLines(result.qc_handoff));
8192
8516
  if (result.next) {
8193
8517
  lines.push("Next:");
8194
8518
  lines.push(`- ${result.next.stage || "unknown"} (${result.next.owner || result.next.default_skill || "owner unknown"})`);