@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
package/src/cli.mjs CHANGED
@@ -120,6 +120,7 @@ import {
120
120
  SOURCE_HTML_MANIFEST_REL_PATH,
121
121
  } from "./source-html-manifest.mjs";
122
122
  import { crawlSourceAssetPaths } from "./source-asset-crawl.mjs";
123
+ import { summarizeCampaignBrief } from "./brief-summary.mjs";
123
124
  import {
124
125
  THEME_POLICIES,
125
126
  inspectBrandTheme,
@@ -174,6 +175,8 @@ import {
174
175
  createCampaignBuildBriefArtifact,
175
176
  inferBuildBriefPath,
176
177
  } from "./build-brief.mjs";
178
+ import { briefIntakeBindings, currentPacketInputs, inputRefreshCommands, inputStamps, stageWriteInputs } from "./input-currency.mjs";
179
+ import { prepareBuildStageStatus, priorBriefInputsForIntake } from "./input-refresh.mjs";
177
180
  import {
178
181
  DESIGN_SOURCE_PACKAGE_REL_PATH,
179
182
  } from "./design-source-package.mjs";
@@ -315,9 +318,10 @@ Usage:
315
318
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
316
319
  [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
317
320
  campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
318
- campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet
321
+ campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
319
322
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
320
323
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
324
+ campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json] # rewrite the version segment of each semver-pinned Campaign Cart loader.js / campaign-cart.css URL (the references standardize's scan finds, @vX.Y.Z or @X.Y.Z) below the target, default the SDK support policy's preferred_minimum; @latest, @main, commit and prerelease refs, and pins at or above the target, are reported and left alone. Preview by default (path:line, old -> new URL, nothing written); --apply writes the files and a change record at .campaign-runtime/sdk-repin.json (campaigns-os-sdk-repin/v0: files touched, reference count, from/to versions) a Run Record can cite. A repo with _data/campaigns.json is refused (exit 2): page-kit sync owns that pin
321
325
  campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
322
326
  campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
323
327
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
@@ -328,11 +332,13 @@ Usage:
328
332
  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.
329
333
  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.
330
334
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
335
+ campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
336
+ campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
331
337
  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
332
338
  campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (--build-environment records stages.assembly.evidence.build_environment; local proof mode records development): stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
333
339
  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
334
340
  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
335
- campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs; refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
341
+ campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again); refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
336
342
  campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
337
343
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
338
344
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -709,6 +715,10 @@ export function recordQaStageOutcome(args, result) {
709
715
  const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
710
716
  .filter((assertion) => assertion?.status === "fail")
711
717
  .map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
718
+ // The QA stamps are the verdict's: the brief material qa run bound at run
719
+ // start and the spec material it judged, never the inputs at write time.
720
+ const qaStamps = inputStamps({ briefMaterial: verdict.source_brief_material, specMaterial: verdict.spec_hash });
721
+ const qaInputs = stageWriteInputs("qa", currentPacketInputs({ packet, packetPath }));
712
722
  const committed = commitAssemblyReport(workspace, (report) => {
713
723
  if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
714
724
  throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
@@ -734,6 +744,8 @@ export function recordQaStageOutcome(args, result) {
734
744
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
735
745
  // path from a `--test-order off` diagnostic.
736
746
  proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
747
+ stamps: qaStamps,
748
+ inputs: qaInputs,
737
749
  });
738
750
  }, {
739
751
  stage: "qa",
@@ -1041,7 +1053,25 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1041
1053
  }
1042
1054
 
1043
1055
  if (command === "sdk") {
1044
- if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json].");
1056
+ if (args._[1] === "repin" && args._.length === 2) {
1057
+ const known = new Set(["_", "target", "target-sdk", "apply", "json"]);
1058
+ for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK repin flag: --${key}`);
1059
+ for (const flag of ["apply", "json"]) {
1060
+ if (args[flag] !== undefined && args[flag] !== true) throw refused(`--${flag} is a boolean flag and takes no value.`);
1061
+ }
1062
+ const { runSdkRepin, formatSdkRepinReport, isRepinTargetVersion } = await import("./sdk-repin.mjs");
1063
+ const targetSdk = args["target-sdk"] === undefined ? null : requireArg(args, "target-sdk");
1064
+ if (targetSdk !== null && !isRepinTargetVersion(targetSdk)) throw refused(`--target-sdk ${targetSdk} is not a released SDK version (x.y.z).`);
1065
+ const result = runSdkRepin({
1066
+ targetRepo: requireArg(args, "target"),
1067
+ targetSdk,
1068
+ apply: args.apply === true,
1069
+ });
1070
+ console.log(args.json ? JSON.stringify(result, null, 2) : formatSdkRepinReport(result));
1071
+ process.exitCode = result.ok ? 0 : 2;
1072
+ return;
1073
+ }
1074
+ if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json], or campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json].");
1045
1075
  const known = new Set(["_", "target", "target-sdk", "manifest", "scope", "exclude", "json"]);
1046
1076
  if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
1047
1077
  for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
@@ -1438,8 +1468,7 @@ function createInitialAssemblyReportStages({ scaffoldRequired, blockers, outputs
1438
1468
  // (STAGE_TERMINAL_STATUS_PREFIXES): declared out-of-scope pages do not hold
1439
1469
  // the ladder, they are recorded on the stage so downstream consumers can see
1440
1470
  // exactly which pages assemble from the template family instead of source HTML.
1441
- const terminalStatus = declaredScopeSkips.length ? "completed_partial" : "completed";
1442
- stages.prepare_build = createStage("prepare_build", blockers.length ? "blocked" : terminalStatus, {
1471
+ stages.prepare_build = createStage("prepare_build", prepareBuildStageStatus({ blockers, declaredScopeSkips }), {
1443
1472
  outputs,
1444
1473
  blockers,
1445
1474
  ...(declaredScopeSkips.length ? { declared_out_of_scope: declaredScopeSkips } : {}),
@@ -1790,6 +1819,10 @@ function prepareBuildUnderLock({
1790
1819
  sourceRoot,
1791
1820
  targetRepo,
1792
1821
  });
1822
+ // The report and normalized brief this run replaces: the report's brief
1823
+ // mode keeps an existing prepared campaign prepared (when it is this
1824
+ // campaign's report), and the previous brief carries field provenance.
1825
+ const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
1793
1826
  const buildBrief = createCampaignBuildBriefArtifact({
1794
1827
  inputPath: briefDiscovery?.path || null,
1795
1828
  inputSource: briefDiscovery?.source || null,
@@ -1799,10 +1832,19 @@ function prepareBuildUnderLock({
1799
1832
  templateFamily,
1800
1833
  sourceAssetCrawl,
1801
1834
  commerceZoneFindings,
1835
+ boundReportMode: priorBriefInputs.boundReportMode,
1836
+ previousNormalizedBrief: priorBriefInputs.previousNormalizedBrief,
1802
1837
  });
1803
1838
  if (buildBrief.inputPath && buildBrief.artifact?._meta) {
1804
1839
  buildBrief.artifact._meta.input_path = relFromFile(briefPath, buildBrief.inputPath);
1805
1840
  }
1841
+ const briefBindings = briefIntakeBindings(buildBrief);
1842
+ // The packet settings this run writes, checked against the packet on disk
1843
+ // before anything is published.
1844
+ const deploySettings = {
1845
+ target: optionalString(args["deploy-target"], "unknown"),
1846
+ preview_url: optionalString(args["preview-url"]),
1847
+ };
1806
1848
  const buildBriefPrompts = buildBrief.mode === "prepared" ? [] : buildBrief.questions.map((question) => ({
1807
1849
  code: `BUILD_BRIEF_${toConstantCase(question.id)}`,
1808
1850
  stage: "prepare_build",
@@ -1836,6 +1878,11 @@ function prepareBuildUnderLock({
1836
1878
  }))
1837
1879
  : [];
1838
1880
  const designSourcePackage = publication.package({
1881
+ plannedPacket: {
1882
+ campaign: { allowed_domains_confirmed: args["allowed-domains-confirmed"] === true },
1883
+ deploy: { ...deploySettings, production_url: optionalString(args["production-url"]) },
1884
+ qa: { proof_policy: { order_path_depth: orderPathDepthFlag || "common" } },
1885
+ },
1839
1886
  activePages,
1840
1887
  mappings: matched.mappings,
1841
1888
  manifestResult,
@@ -1887,6 +1934,7 @@ function prepareBuildUnderLock({
1887
1934
  normalized_path: relFromFile(packetPath, briefPath),
1888
1935
  question_count: buildBrief.questions.length,
1889
1936
  gate_count: buildBrief.gates.length,
1937
+ ...briefBindings,
1890
1938
  },
1891
1939
  assembly: {
1892
1940
  implementation: "next-campaigns-build",
@@ -1914,8 +1962,7 @@ function prepareBuildUnderLock({
1914
1962
  compatible_outputs: ["static-html", "campaign-cart-sdk"],
1915
1963
  },
1916
1964
  deploy: {
1917
- target: optionalString(args["deploy-target"], "unknown"),
1918
- preview_url: optionalString(args["preview-url"]),
1965
+ ...deploySettings,
1919
1966
  production_url: optionalString(args["production-url"]),
1920
1967
  live_url_path: liveUrlPath,
1921
1968
  },
@@ -2002,6 +2049,7 @@ function prepareBuildUnderLock({
2002
2049
  gate_count: buildBrief.gates.length,
2003
2050
  questions: buildBrief.questions,
2004
2051
  gates: buildBrief.gates,
2052
+ ...briefBindings,
2005
2053
  },
2006
2054
  page_map: matched.mappings.map((mapping) => ({
2007
2055
  page_id: mapping.page_id,
@@ -2055,7 +2103,8 @@ function prepareBuildUnderLock({
2055
2103
 
2056
2104
  // The top-level status/next/blockers are derived from the stages by the same
2057
2105
  // function every later commit of the report runs (stage-ledger.mjs), so
2058
- // prepare-build's first write and a producer's last write spell them alike.
2106
+ // prepare-build's first write and a producer's last write spell them alike,
2107
+ // against the brief and CampaignSpec material this run binds.
2059
2108
  const report = applyDerivedAssemblyReportSummary(createAssemblyReport({
2060
2109
  packetPath,
2061
2110
  contextPath,
@@ -2072,7 +2121,7 @@ function prepareBuildUnderLock({
2072
2121
  buildScopeReasonsInvalid,
2073
2122
  templateSelection,
2074
2123
  evidence: strippedSpecBytes == null ? [] : hostStripped.evidence,
2075
- }));
2124
+ }), { briefMaterial: briefBindings.material, specMaterial: specMaterialHash(spec) });
2076
2125
 
2077
2126
  // Values a spec left as it is still holds that doctor blocks on. An
2078
2127
  // absolute http(s) page_url is not among them: projection takes its path.
@@ -2643,7 +2692,7 @@ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRu
2643
2692
  // run's own "do not write" is this wrapper's null below, not the mutator's.
2644
2693
  if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
2645
2694
  if (!dryRun) return mutated;
2646
- applyDerivedAssemblyReportSummary(mutated);
2695
+ applyDerivedAssemblyReportSummary(mutated, currentPacketInputs({ packet: workspace.packet, packetPath: workspace.packetPath }));
2647
2696
  return null;
2648
2697
  };
2649
2698
  // A preview writes nothing, so it does not take the target lock either: the
@@ -2698,7 +2747,19 @@ export async function polishCaptureCommand(args, options = {}) {
2698
2747
  throw new Error(`polish capture needs an existing Assembly Report at ${reportPath}; run prepare-build/start first.`);
2699
2748
  }
2700
2749
  requireValidPolishCaptureReport(report, reportPath);
2701
- const plan = planPolishCapture({ packet, baseUrl });
2750
+ // A campaign whose every mapping is template stock has no page_load route
2751
+ // to capture: its plan error is held, and the capture measures readability
2752
+ // on the built pages only, leaving page_load and media_weight as they are.
2753
+ const { capturePolishReadability, isNoCapturableRoutesError } = await import("./polish-node.mjs");
2754
+ const planOrHeld = (currentPacket) => {
2755
+ try {
2756
+ return { plan: planPolishCapture({ packet: currentPacket, baseUrl }), held: null };
2757
+ } catch (error) {
2758
+ if (!isNoCapturableRoutesError(error)) throw error;
2759
+ return { plan: null, held: error };
2760
+ }
2761
+ };
2762
+ const { plan, held: heldPlanError } = planOrHeld(packet);
2702
2763
  const initialBinding = createPolishCaptureBinding({
2703
2764
  packet,
2704
2765
  report,
@@ -2711,7 +2772,7 @@ export async function polishCaptureCommand(args, options = {}) {
2711
2772
  if (typeof createBrowserAdapter !== "function") {
2712
2773
  ({ createPolishBrowserAdapter: createBrowserAdapter } = await import("./polish-browser.mjs"));
2713
2774
  }
2714
- const capture = await capturePolishPageLoad({
2775
+ const capture = await (heldPlanError ? capturePolishReadability : capturePolishPageLoad)({
2715
2776
  packet,
2716
2777
  report,
2717
2778
  baseUrl,
@@ -2721,7 +2782,11 @@ export async function polishCaptureCommand(args, options = {}) {
2721
2782
  adapterStartupDeadlineMs: options.adapterStartupDeadlineMs,
2722
2783
  captureCellDeadlineMs: options.captureCellDeadlineMs,
2723
2784
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2785
+ probeClock: options.probeClock,
2786
+ targetRepo,
2724
2787
  });
2788
+ // Nothing was measured: the held plan error stands.
2789
+ if (heldPlanError && !capture.readability) throw heldPlanError;
2725
2790
 
2726
2791
  if (typeof options.afterCapture === "function") {
2727
2792
  await options.afterCapture({ packetPath, reportPath, targetRepo, capture });
@@ -2734,7 +2799,7 @@ export async function polishCaptureCommand(args, options = {}) {
2734
2799
  let checkpoint = null;
2735
2800
  commitAssemblyReport(workspace, (currentReport) => {
2736
2801
  requireValidPolishCaptureReport(currentReport, reportPath);
2737
- const currentPlan = planPolishCapture({ packet: currentPacket, baseUrl });
2802
+ const { plan: currentPlan } = planOrHeld(currentPacket);
2738
2803
  const currentBinding = createPolishCaptureBinding({
2739
2804
  packet: currentPacket,
2740
2805
  report: currentReport,
@@ -2744,7 +2809,12 @@ export async function polishCaptureCommand(args, options = {}) {
2744
2809
  });
2745
2810
  assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
2746
2811
 
2747
- const merged = mergePolishCaptureEvidence(currentReport, { pageLoad: capture.page_load, mediaWeight: capture.media_weight });
2812
+ const merged = mergePolishCaptureEvidence(currentReport, {
2813
+ pageLoad: capture.page_load,
2814
+ mediaWeight: capture.media_weight,
2815
+ readability: capture.readability,
2816
+ now: options.now ?? new Date(),
2817
+ });
2748
2818
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
2749
2819
  packet: currentPacket,
2750
2820
  report: merged,
@@ -2755,19 +2825,39 @@ export async function polishCaptureCommand(args, options = {}) {
2755
2825
  staleReason: `Package-owned polish page-load evidence changed after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
2756
2826
  });
2757
2827
 
2828
+ if (heldPlanError) {
2829
+ // The readability capture completed; page_load's own outcome stays the
2830
+ // held no_capturable_routes message, reported as a warning.
2831
+ return {
2832
+ ok: true,
2833
+ status: "captured_with_warnings",
2834
+ action: "polish-capture",
2835
+ report_path: reportPath,
2836
+ measurement: null,
2837
+ checkpoint,
2838
+ observed_findings: [],
2839
+ warnings: [{ code: heldPlanError.code, message: heldPlanError.message }],
2840
+ capture: {
2841
+ route_scope: null,
2842
+ routes: [],
2843
+ viewports: [],
2844
+ readability_routes: capture.readability?.subject?.routes ?? [],
2845
+ },
2846
+ };
2847
+ }
2758
2848
  const ok = checkpoint.status === "pass" || checkpoint.status === "waived";
2759
2849
  return {
2760
2850
  ok,
2761
2851
  status: ok ? (checkpoint.status === "waived" ? "ready_with_waivers" : "ready") : "blocked",
2762
2852
  action: "polish-capture",
2763
2853
  report_path: reportPath,
2764
- measurement: capture.page_load.measurement,
2854
+ measurement: capture.page_load?.measurement ?? null,
2765
2855
  checkpoint,
2766
- observed_findings: capture.page_load.findings,
2856
+ observed_findings: capture.page_load?.findings ?? [],
2767
2857
  capture: {
2768
- route_scope: capture.plan.route_scope,
2769
- routes: capture.plan.routes.map((route) => route.requested_route),
2770
- viewports: capture.plan.viewports.map((viewport) => viewport.key),
2858
+ route_scope: capture.plan?.route_scope ?? null,
2859
+ routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
2860
+ viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
2771
2861
  },
2772
2862
  };
2773
2863
  }
@@ -3509,7 +3599,6 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3509
3599
  store: storeFlags
3510
3600
  ? { 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 }
3511
3601
  : null,
3512
- rebound: { build_context: null, assembly_report: null },
3513
3602
  errors: [],
3514
3603
  warnings: [],
3515
3604
  next: `${cmd("doctor")} --packet ${shellToken(packetPath)}`,
@@ -3743,17 +3832,15 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3743
3832
  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.`);
3744
3833
  }
3745
3834
  if (stageIsTerminal(report?.stages?.assembly?.status)) {
3746
- 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.`);
3747
- 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)}`;
3835
+ 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.`);
3748
3836
  }
3837
+ // spec derive writes the spec alone: the Build Context and the Assembly
3838
+ // Report keep the identity they bound, so every recorded stage reads its
3839
+ // stamps against the new content and `record spec` is what binds it.
3840
+ 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)}`;
3749
3841
  }
3750
3842
 
3751
3843
  if (plan.changes.length && !dryRun) {
3752
- // The identity the sidecars bound to the spec BEFORE this write, so the
3753
- // re-bind below can tell "bound to the spec being replaced" from "already
3754
- // drifted" and only ever moves the former.
3755
- const beforeRawHash = createHash("sha256").update(text).digest("hex");
3756
- const beforeMaterialHash = specMaterialHash(spec);
3757
3844
  // The plan already refused every path its containers cannot take, so a
3758
3845
  // throw here is a defect in this toolkit rather than in the spec; it is
3759
3846
  // still a structured error, never a crash past the --json contract.
@@ -3791,82 +3878,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3791
3878
  rmSync(tmpPath, { force: true });
3792
3879
  }
3793
3880
  result.written = true;
3794
- // The Build Context and the Assembly Report carry the spec's identity
3795
- // (raw and material hashes) from prepare-build, and QA's verdict is
3796
- // correlated against the material hash by the bundle check. Each sidecar
3797
- // that was bound to the spec just replaced is re-bound to the new one;
3798
- // one that already carried another identity is left as it is and named.
3799
- const afterRawHash = createHash("sha256").update(serialized).digest("hex");
3800
- const afterMaterialHash = specMaterialHash(spec);
3801
- const boundToOld = (raw, material) => raw === beforeRawHash || material === beforeMaterialHash;
3802
- // A sidecar is re-bound only when it names the spec being written: two
3803
- // packets sharing a target repo can carry byte-identical spec exports,
3804
- // and a matching hash alone would let one packet's derive re-bind the
3805
- // other's sidecar to a spec it never used.
3806
- const namesThisSpec = (recorded) => {
3807
- if (!isNonEmptyString(recorded)) return false;
3808
- try {
3809
- return realpathSync(resolve(targetRepo, recorded)) === realSpecPath;
3810
- } catch {
3811
- return false;
3812
- }
3813
- };
3814
- const contextPath = workspace?.contextPath || null;
3815
- let context = null;
3816
- try {
3817
- context = contextPath ? readJsonIfExists(contextPath) : null;
3818
- } catch (error) {
3819
- result.rebound.build_context = false;
3820
- 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.`);
3821
- }
3822
- if (isObject(context?.spec)) {
3823
- if (!namesThisSpec(context.spec.path)) {
3824
- result.rebound.build_context = false;
3825
- 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.");
3826
- } else if (boundToOld(context.spec.hash, context.spec.material_hash)) {
3827
- try {
3828
- writeJsonAtomic(contextPath, { ...context, spec: { ...context.spec, hash: afterRawHash, material_hash: afterMaterialHash } });
3829
- result.rebound.build_context = true;
3830
- } catch (error) {
3831
- result.rebound.build_context = false;
3832
- 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.`);
3833
- }
3834
- } else {
3835
- result.rebound.build_context = false;
3836
- 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.`);
3837
- }
3838
- }
3839
- if (report && isObject(report.identity) && workspace) {
3840
- if (!namesThisSpec(report.inputs?.spec_path)) {
3841
- result.rebound.assembly_report = false;
3842
- 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.");
3843
- } else if (boundToOld(report.identity.spec_hash, report.identity.spec_material_hash)) {
3844
- try {
3845
- // The identity is re-checked on the report as it is re-read for
3846
- // the commit, so a prepare-build that re-bound it in the meantime
3847
- // is left alone.
3848
- const committed = commitAssemblyReport(workspace, (current) => (
3849
- isObject(current.identity) && boundToOld(current.identity.spec_hash, current.identity.spec_material_hash)
3850
- ? { ...current, identity: { ...current.identity, spec_hash: afterRawHash, spec_material_hash: afterMaterialHash } }
3851
- : null
3852
- ), {
3853
- command: "spec derive",
3854
- staleReason: `spec derive rewrote the CampaignSpec after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3855
- });
3856
- result.rebound.assembly_report = committed.written;
3857
- 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.");
3858
- } catch (error) {
3859
- result.rebound.assembly_report = false;
3860
- 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.`);
3861
- }
3862
- } else {
3863
- result.rebound.assembly_report = false;
3864
- 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.`);
3865
- }
3866
- }
3867
3881
  // The retained doctor snapshot (if any) now predates the spec it judged.
3868
- // commitAssemblyReport stamps it when the report was re-bound; every
3869
- // other path stamps it here.
3870
3882
  try {
3871
3883
  markDoctorSidecarStale(targetRepo, {
3872
3884
  command: "spec derive",
@@ -4245,6 +4257,8 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4245
4257
  followContextPointer: true,
4246
4258
  });
4247
4259
  const report = readJsonIfExists(reportPath);
4260
+ // The campaign intent summary, computed once per call and only printed.
4261
+ const intentSummary = readIntentSummary(packetPath, packet, contextPath);
4248
4262
  // Doctor reads the same sidecars through the same resolver (an operator's
4249
4263
  // explicit --context / --report passed through, the defaults derived), so
4250
4264
  // its prepare-build gate and its stage pick are this command's: `next`
@@ -4330,6 +4344,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4330
4344
  } });
4331
4345
  if (divergences.length) result.divergences = divergences;
4332
4346
  result.gates = buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate, packetPath });
4347
+ if (doctor.derived?.input_currency) result.input_currency = doctor.derived.input_currency;
4333
4348
  result.next_actions = buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout, purchaseProof, brandContract: doctor.derived?.brand_contract || null, context: readJsonIfExists(contextPath), targetRepo, spec });
4334
4349
  // The QC handoff: read from data already loaded plus the
4335
4350
  // full QA verdict the QA stage names. It adds nothing to errors[],
@@ -4338,7 +4353,11 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4338
4353
  // one (from --report or the Build Context pointer), so it records there.
4339
4354
  const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4340
4355
  result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
4356
+ // One note per operator decision that kept the build output unchanged
4357
+ // after an input change, quoting the operator's reason.
4358
+ 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}"`);
4341
4359
  recordNextRecommendation(ambient, result);
4360
+ result.intent_summary = intentSummary;
4342
4361
  return result;
4343
4362
  };
4344
4363
  const doctorHasOnlyPolishGateErrors = doctorErrorsAreOnlyPolishGate(doctor.errors);
@@ -4430,12 +4449,12 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4430
4449
  if (stage === "setup") {
4431
4450
  addPrepareBuildGateErrors(errors, report);
4432
4451
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.setup.doctor", "Doctor is blocked; resolve packet errors before setup.");
4433
- prompt = setupPrompt(packetPath, contextPath, reportPath, packet);
4452
+ prompt = setupPrompt(packetPath, contextPath, reportPath, packet, intentSummary);
4434
4453
  } else if (stage === "build") {
4435
4454
  addPrepareBuildGateErrors(errors, report);
4436
4455
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.build.doctor", "Doctor is blocked; resolve packet errors before build.");
4437
4456
  if (doctor.derived?.scaffold_required) addIssue(errors, "next.build.setup", doctor.derived.scaffold_reason || "Setup is required before build.");
4438
- prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived);
4457
+ prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived, intentSummary);
4439
4458
  } else if (stage === "polish") {
4440
4459
  addPrepareBuildGateErrors(errors, report);
4441
4460
  if (!report) addIssue(errors, "next.polish.report", "Assembly report is required before polish.");
@@ -4443,7 +4462,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4443
4462
  if (!assemblyStatus.startsWith("completed")) addIssue(errors, "next.polish.assembly", `Assembly status is "${assemblyStatus || "missing"}"; polish expects completed assembly or an explicit blocked/skipped handoff.`);
4444
4463
  if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
4445
4464
  addThemeGateErrors(errors, themeGate, "polish");
4446
- prompt = polishPrompt(packetPath, reportPath, packet);
4465
+ prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
4447
4466
  } else if (stage === "deploy") {
4448
4467
  addPrepareBuildGateErrors(errors, report);
4449
4468
  // Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
@@ -4462,7 +4481,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4462
4481
  addPolishGateErrors(errors, polishGate, "qa");
4463
4482
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
4464
4483
  addThemeGateErrors(errors, themeGate, "qa");
4465
- prompt = qaPrompt(packetPath, reportPath, packet, spec);
4484
+ prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary);
4466
4485
  }
4467
4486
  const status = errors.length
4468
4487
  ? "blocked"
@@ -4757,6 +4776,11 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4757
4776
  push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
4758
4777
  return actions;
4759
4778
  }
4779
+ for (const subcommand of inputRefreshCommands(result.input_currency)) {
4780
+ push("refresh_inputs", "command", `${cmd("record")} ${subcommand} --packet ${shellToken(packetPath)} --json`, subcommand === "brief"
4781
+ ? "A recorded stage was made against earlier Campaign Build Brief content: save the brief with record brief (keeps stage history), then re-run next."
4782
+ : "A recorded stage was made against earlier CampaignSpec content: bind it with record spec (keeps stage history), then re-run next.");
4783
+ }
4760
4784
  if (result.stage === "doctor-blocked") {
4761
4785
  const checkpointGates = (result.gates || []).filter(
4762
4786
  (gate) => gate?.status === "blocked" && Object.hasOwn(CHECKPOINT_EVALUATORS, gate?.id || ""),
@@ -5008,9 +5032,9 @@ function templateStockPromptLine(packet, derived = {}) {
5008
5032
  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.`;
5009
5033
  }
5010
5034
 
5011
- function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}) {
5035
+ function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
5012
5036
  const briefPath = packet.build_brief?.normalized_path || "(missing; generate or confirm Campaign Build Brief before business-sensitive assembly)";
5013
- return `Use next-campaigns-build for this Campaigns OS handoff.
5037
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
5014
5038
 
5015
5039
  Read first:
5016
5040
  - Build Packet: ${packetPath}
@@ -5023,6 +5047,7 @@ Read first:
5023
5047
  Rules:
5024
5048
  - Treat CampaignSpec/API as the source for package, shipping, voucher, payment, tracking, footer, and SEO values.
5025
5049
  - 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.
5050
+ - 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.
5026
5051
  - Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
5027
5052
  - 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.
5028
5053
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
@@ -5049,9 +5074,44 @@ function localProofPromptLines(packet, packetPath = "<packet>") {
5049
5074
  - 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}`;
5050
5075
  }
5051
5076
 
5052
- function setupPrompt(packetPath, contextPath, reportPath, packet) {
5077
+ // Read the normalized brief the packet records, resolved against the packet
5078
+ // file as doctor resolves it, and summarize it over the Build Context's active
5079
+ // pages in recorded order. A missing, unreadable or invalid brief reads
5080
+ // unavailable; this never fails `next`. Each read or summarising failure is
5081
+ // named on stderr, one line per failure, never on stdout.
5082
+ function readIntentSummary(packetPath, packet, contextPath) {
5083
+ const reportFailure = (error) => process.stderr.write(`campaigns-os: intent summary unavailable: ${singleLineFragment(error?.message ?? error, "unknown error")}\n`);
5084
+ const normalizedPath = resolveFromFile(packetPath, packet?.build_brief?.normalized_path);
5085
+ let brief = null;
5086
+ try {
5087
+ brief = readJsonIfExists(normalizedPath);
5088
+ } catch (error) {
5089
+ reportFailure(error);
5090
+ brief = null;
5091
+ }
5092
+ let activePageIds = [];
5093
+ try {
5094
+ const activePages = readJsonIfExists(contextPath)?.spec?.active_pages;
5095
+ activePageIds = Array.isArray(activePages) ? activePages.map((page) => page?.id) : [];
5096
+ } catch (error) {
5097
+ reportFailure(error);
5098
+ activePageIds = [];
5099
+ }
5100
+ return { ...summarizeCampaignBrief({ brief, activePageIds, onError: reportFailure }), normalized_path: normalizedPath };
5101
+ }
5102
+
5103
+ // The setup, build, Polish and QA prompts start with the campaign intent
5104
+ // summary, framed as orientation only.
5105
+ function campaignIntentPromptHeader(intent) {
5106
+ return `Campaign intent (from the Campaign Build Brief at ${intent.normalized_path || "(not recorded)"}; orientation only, never a source of prices or commerce behaviour):
5107
+ ${intent.text}
5108
+
5109
+ `;
5110
+ }
5111
+
5112
+ function setupPrompt(packetPath, contextPath, reportPath, packet, intent) {
5053
5113
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5054
- return `Use next-campaigns-os-setup for this Campaigns OS handoff.
5114
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-os-setup for this Campaigns OS handoff.
5055
5115
 
5056
5116
  Read first:
5057
5117
  - Build Packet: ${packetPath}
@@ -5070,9 +5130,9 @@ When copying a starter template family, copy the family as an atomic page-kit sl
5070
5130
  Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
5071
5131
  }
5072
5132
 
5073
- function polishPrompt(packetPath, reportPath, packet) {
5133
+ function polishPrompt(packetPath, reportPath, packet, intent) {
5074
5134
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5075
- return `Use next-campaigns-polish for this built campaign.
5135
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
5076
5136
 
5077
5137
  Read first:
5078
5138
  - Build Packet: ${packetPath}
@@ -5215,14 +5275,14 @@ function orderBumpRunReason(bumpCart) {
5215
5275
  return `The checkout declares order bump ${bumpCart.bumps.join(", ")} (is_order_bump or is_upsell), and the default run never puts it in a test order. This run repeats QA with ${cart} in the cart.`;
5216
5276
  }
5217
5277
 
5218
- function qaPrompt(packetPath, reportPath, packet, spec = null) {
5278
+ function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
5219
5279
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5220
5280
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5221
5281
  const bumpCart = checkoutOrderBumpCart(spec);
5222
5282
  const bumpCommand = bumpCart
5223
5283
  ? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
5224
5284
  : "";
5225
- return `Use next-campaigns-qa for this deployed campaign.
5285
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-qa for this deployed campaign.
5226
5286
 
5227
5287
  ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
5228
5288
  Base URL: ${url}
@@ -6396,7 +6456,7 @@ function safePolishByteCount(value) {
6396
6456
  }
6397
6457
 
6398
6458
  export function formatPolishCaptureText(result) {
6399
- const status = ["ready", "ready_with_waivers", "blocked"].includes(result?.status)
6459
+ const status = ["ready", "ready_with_waivers", "captured_with_warnings", "blocked"].includes(result?.status)
6400
6460
  ? result.status
6401
6461
  : "unknown";
6402
6462
  const measurementStatus = ["complete", "incomplete"].includes(result?.measurement?.status)
@@ -6410,6 +6470,11 @@ export function formatPolishCaptureText(result) {
6410
6470
  `Status: ${status.toUpperCase()}`,
6411
6471
  `Measurement: ${measurementStatus.toUpperCase()}`,
6412
6472
  ];
6473
+ // Outcomes that did not stop the capture, printed with their own message
6474
+ // (the held page-load plan message on a campaign with no mapped pages).
6475
+ for (const warning of Array.isArray(result?.warnings) ? result.warnings : []) {
6476
+ if (warning?.code === "no_capturable_routes" && typeof warning.message === "string") lines.push(`Warning: ${warning.message}`);
6477
+ }
6413
6478
  const incomplete = Array.isArray(result?.measurement?.incomplete)
6414
6479
  ? result.measurement.incomplete
6415
6480
  : [];
@@ -8411,6 +8476,8 @@ const WAIVE_ACTIONS = new Set(["theme-waive", "checkpoint-waive"]);
8411
8476
  // check's readiness line); they are text-only and never enter the JSON result.
8412
8477
  export function resultTextLines(result, { headerLines = [] } = {}) {
8413
8478
  const lines = [`Status: ${String(result.status || "unknown").toUpperCase()}`, ...headerLines];
8479
+ // `next` only: the campaign intent summary, directly under the status.
8480
+ if (result.intent_summary) lines.push("Campaign intent:", ...result.intent_summary.text.split("\n"));
8414
8481
  // A waive command's second line names what it recorded; the third is the
8415
8482
  // stage doctor now picks for the report the waiver was written to.
8416
8483
  if (WAIVE_ACTIONS.has(result.action) && result.gate) {