@nextcommerce/campaigns-os 1.52.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. 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";
@@ -328,6 +331,8 @@ Usage:
328
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.
329
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.
330
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]
331
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
332
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
333
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
@@ -709,6 +714,10 @@ export function recordQaStageOutcome(args, result) {
709
714
  const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
710
715
  .filter((assertion) => assertion?.status === "fail")
711
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 }));
712
721
  const committed = commitAssemblyReport(workspace, (report) => {
713
722
  if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
714
723
  throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
@@ -734,6 +743,8 @@ export function recordQaStageOutcome(args, result) {
734
743
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
735
744
  // path from a `--test-order off` diagnostic.
736
745
  proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
746
+ stamps: qaStamps,
747
+ inputs: qaInputs,
737
748
  });
738
749
  }, {
739
750
  stage: "qa",
@@ -1438,8 +1449,7 @@ function createInitialAssemblyReportStages({ scaffoldRequired, blockers, outputs
1438
1449
  // (STAGE_TERMINAL_STATUS_PREFIXES): declared out-of-scope pages do not hold
1439
1450
  // the ladder, they are recorded on the stage so downstream consumers can see
1440
1451
  // 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, {
1452
+ stages.prepare_build = createStage("prepare_build", prepareBuildStageStatus({ blockers, declaredScopeSkips }), {
1443
1453
  outputs,
1444
1454
  blockers,
1445
1455
  ...(declaredScopeSkips.length ? { declared_out_of_scope: declaredScopeSkips } : {}),
@@ -1790,6 +1800,10 @@ function prepareBuildUnderLock({
1790
1800
  sourceRoot,
1791
1801
  targetRepo,
1792
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 } } });
1793
1807
  const buildBrief = createCampaignBuildBriefArtifact({
1794
1808
  inputPath: briefDiscovery?.path || null,
1795
1809
  inputSource: briefDiscovery?.source || null,
@@ -1799,10 +1813,19 @@ function prepareBuildUnderLock({
1799
1813
  templateFamily,
1800
1814
  sourceAssetCrawl,
1801
1815
  commerceZoneFindings,
1816
+ boundReportMode: priorBriefInputs.boundReportMode,
1817
+ previousNormalizedBrief: priorBriefInputs.previousNormalizedBrief,
1802
1818
  });
1803
1819
  if (buildBrief.inputPath && buildBrief.artifact?._meta) {
1804
1820
  buildBrief.artifact._meta.input_path = relFromFile(briefPath, buildBrief.inputPath);
1805
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
+ };
1806
1829
  const buildBriefPrompts = buildBrief.mode === "prepared" ? [] : buildBrief.questions.map((question) => ({
1807
1830
  code: `BUILD_BRIEF_${toConstantCase(question.id)}`,
1808
1831
  stage: "prepare_build",
@@ -1836,6 +1859,11 @@ function prepareBuildUnderLock({
1836
1859
  }))
1837
1860
  : [];
1838
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
+ },
1839
1867
  activePages,
1840
1868
  mappings: matched.mappings,
1841
1869
  manifestResult,
@@ -1887,6 +1915,7 @@ function prepareBuildUnderLock({
1887
1915
  normalized_path: relFromFile(packetPath, briefPath),
1888
1916
  question_count: buildBrief.questions.length,
1889
1917
  gate_count: buildBrief.gates.length,
1918
+ ...briefBindings,
1890
1919
  },
1891
1920
  assembly: {
1892
1921
  implementation: "next-campaigns-build",
@@ -1914,8 +1943,7 @@ function prepareBuildUnderLock({
1914
1943
  compatible_outputs: ["static-html", "campaign-cart-sdk"],
1915
1944
  },
1916
1945
  deploy: {
1917
- target: optionalString(args["deploy-target"], "unknown"),
1918
- preview_url: optionalString(args["preview-url"]),
1946
+ ...deploySettings,
1919
1947
  production_url: optionalString(args["production-url"]),
1920
1948
  live_url_path: liveUrlPath,
1921
1949
  },
@@ -2002,6 +2030,7 @@ function prepareBuildUnderLock({
2002
2030
  gate_count: buildBrief.gates.length,
2003
2031
  questions: buildBrief.questions,
2004
2032
  gates: buildBrief.gates,
2033
+ ...briefBindings,
2005
2034
  },
2006
2035
  page_map: matched.mappings.map((mapping) => ({
2007
2036
  page_id: mapping.page_id,
@@ -2055,7 +2084,8 @@ function prepareBuildUnderLock({
2055
2084
 
2056
2085
  // The top-level status/next/blockers are derived from the stages by the same
2057
2086
  // 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.
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.
2059
2089
  const report = applyDerivedAssemblyReportSummary(createAssemblyReport({
2060
2090
  packetPath,
2061
2091
  contextPath,
@@ -2072,7 +2102,7 @@ function prepareBuildUnderLock({
2072
2102
  buildScopeReasonsInvalid,
2073
2103
  templateSelection,
2074
2104
  evidence: strippedSpecBytes == null ? [] : hostStripped.evidence,
2075
- }));
2105
+ }), { briefMaterial: briefBindings.material, specMaterial: specMaterialHash(spec) });
2076
2106
 
2077
2107
  // Values a spec left as it is still holds that doctor blocks on. An
2078
2108
  // absolute http(s) page_url is not among them: projection takes its path.
@@ -2643,7 +2673,7 @@ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRu
2643
2673
  // run's own "do not write" is this wrapper's null below, not the mutator's.
2644
2674
  if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
2645
2675
  if (!dryRun) return mutated;
2646
- applyDerivedAssemblyReportSummary(mutated);
2676
+ applyDerivedAssemblyReportSummary(mutated, currentPacketInputs({ packet: workspace.packet, packetPath: workspace.packetPath }));
2647
2677
  return null;
2648
2678
  };
2649
2679
  // A preview writes nothing, so it does not take the target lock either: the
@@ -2698,7 +2728,19 @@ export async function polishCaptureCommand(args, options = {}) {
2698
2728
  throw new Error(`polish capture needs an existing Assembly Report at ${reportPath}; run prepare-build/start first.`);
2699
2729
  }
2700
2730
  requireValidPolishCaptureReport(report, reportPath);
2701
- 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);
2702
2744
  const initialBinding = createPolishCaptureBinding({
2703
2745
  packet,
2704
2746
  report,
@@ -2711,7 +2753,7 @@ export async function polishCaptureCommand(args, options = {}) {
2711
2753
  if (typeof createBrowserAdapter !== "function") {
2712
2754
  ({ createPolishBrowserAdapter: createBrowserAdapter } = await import("./polish-browser.mjs"));
2713
2755
  }
2714
- const capture = await capturePolishPageLoad({
2756
+ const capture = await (heldPlanError ? capturePolishReadability : capturePolishPageLoad)({
2715
2757
  packet,
2716
2758
  report,
2717
2759
  baseUrl,
@@ -2721,7 +2763,11 @@ export async function polishCaptureCommand(args, options = {}) {
2721
2763
  adapterStartupDeadlineMs: options.adapterStartupDeadlineMs,
2722
2764
  captureCellDeadlineMs: options.captureCellDeadlineMs,
2723
2765
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2766
+ probeClock: options.probeClock,
2767
+ targetRepo,
2724
2768
  });
2769
+ // Nothing was measured: the held plan error stands.
2770
+ if (heldPlanError && !capture.readability) throw heldPlanError;
2725
2771
 
2726
2772
  if (typeof options.afterCapture === "function") {
2727
2773
  await options.afterCapture({ packetPath, reportPath, targetRepo, capture });
@@ -2734,7 +2780,7 @@ export async function polishCaptureCommand(args, options = {}) {
2734
2780
  let checkpoint = null;
2735
2781
  commitAssemblyReport(workspace, (currentReport) => {
2736
2782
  requireValidPolishCaptureReport(currentReport, reportPath);
2737
- const currentPlan = planPolishCapture({ packet: currentPacket, baseUrl });
2783
+ const { plan: currentPlan } = planOrHeld(currentPacket);
2738
2784
  const currentBinding = createPolishCaptureBinding({
2739
2785
  packet: currentPacket,
2740
2786
  report: currentReport,
@@ -2744,7 +2790,12 @@ export async function polishCaptureCommand(args, options = {}) {
2744
2790
  });
2745
2791
  assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
2746
2792
 
2747
- const merged = mergePolishCaptureEvidence(currentReport, { pageLoad: capture.page_load, mediaWeight: capture.media_weight });
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
+ });
2748
2799
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
2749
2800
  packet: currentPacket,
2750
2801
  report: merged,
@@ -2755,19 +2806,39 @@ export async function polishCaptureCommand(args, options = {}) {
2755
2806
  staleReason: `Package-owned polish page-load evidence changed after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
2756
2807
  });
2757
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
+ }
2758
2829
  const ok = checkpoint.status === "pass" || checkpoint.status === "waived";
2759
2830
  return {
2760
2831
  ok,
2761
2832
  status: ok ? (checkpoint.status === "waived" ? "ready_with_waivers" : "ready") : "blocked",
2762
2833
  action: "polish-capture",
2763
2834
  report_path: reportPath,
2764
- measurement: capture.page_load.measurement,
2835
+ measurement: capture.page_load?.measurement ?? null,
2765
2836
  checkpoint,
2766
- observed_findings: capture.page_load.findings,
2837
+ observed_findings: capture.page_load?.findings ?? [],
2767
2838
  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),
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),
2771
2842
  },
2772
2843
  };
2773
2844
  }
@@ -3509,7 +3580,6 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3509
3580
  store: storeFlags
3510
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 }
3511
3582
  : null,
3512
- rebound: { build_context: null, assembly_report: null },
3513
3583
  errors: [],
3514
3584
  warnings: [],
3515
3585
  next: `${cmd("doctor")} --packet ${shellToken(packetPath)}`,
@@ -3743,17 +3813,15 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3743
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.`);
3744
3814
  }
3745
3815
  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)}`;
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.`);
3748
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)}`;
3749
3822
  }
3750
3823
 
3751
3824
  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
3825
  // The plan already refused every path its containers cannot take, so a
3758
3826
  // throw here is a defect in this toolkit rather than in the spec; it is
3759
3827
  // still a structured error, never a crash past the --json contract.
@@ -3791,82 +3859,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3791
3859
  rmSync(tmpPath, { force: true });
3792
3860
  }
3793
3861
  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
3862
  // 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
3863
  try {
3871
3864
  markDoctorSidecarStale(targetRepo, {
3872
3865
  command: "spec derive",
@@ -4245,6 +4238,8 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4245
4238
  followContextPointer: true,
4246
4239
  });
4247
4240
  const report = readJsonIfExists(reportPath);
4241
+ // The campaign intent summary, computed once per call and only printed.
4242
+ const intentSummary = readIntentSummary(packetPath, packet, contextPath);
4248
4243
  // Doctor reads the same sidecars through the same resolver (an operator's
4249
4244
  // explicit --context / --report passed through, the defaults derived), so
4250
4245
  // its prepare-build gate and its stage pick are this command's: `next`
@@ -4330,6 +4325,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4330
4325
  } });
4331
4326
  if (divergences.length) result.divergences = divergences;
4332
4327
  result.gates = buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate, packetPath });
4328
+ if (doctor.derived?.input_currency) result.input_currency = doctor.derived.input_currency;
4333
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 });
4334
4330
  // The QC handoff: read from data already loaded plus the
4335
4331
  // full QA verdict the QA stage names. It adds nothing to errors[],
@@ -4338,7 +4334,11 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4338
4334
  // one (from --report or the Build Context pointer), so it records there.
4339
4335
  const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4340
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}"`);
4341
4340
  recordNextRecommendation(ambient, result);
4341
+ result.intent_summary = intentSummary;
4342
4342
  return result;
4343
4343
  };
4344
4344
  const doctorHasOnlyPolishGateErrors = doctorErrorsAreOnlyPolishGate(doctor.errors);
@@ -4430,12 +4430,12 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4430
4430
  if (stage === "setup") {
4431
4431
  addPrepareBuildGateErrors(errors, report);
4432
4432
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.setup.doctor", "Doctor is blocked; resolve packet errors before setup.");
4433
- prompt = setupPrompt(packetPath, contextPath, reportPath, packet);
4433
+ prompt = setupPrompt(packetPath, contextPath, reportPath, packet, intentSummary);
4434
4434
  } else if (stage === "build") {
4435
4435
  addPrepareBuildGateErrors(errors, report);
4436
4436
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.build.doctor", "Doctor is blocked; resolve packet errors before build.");
4437
4437
  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);
4438
+ prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived, intentSummary);
4439
4439
  } else if (stage === "polish") {
4440
4440
  addPrepareBuildGateErrors(errors, report);
4441
4441
  if (!report) addIssue(errors, "next.polish.report", "Assembly report is required before polish.");
@@ -4443,7 +4443,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4443
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.`);
4444
4444
  if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
4445
4445
  addThemeGateErrors(errors, themeGate, "polish");
4446
- prompt = polishPrompt(packetPath, reportPath, packet);
4446
+ prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
4447
4447
  } else if (stage === "deploy") {
4448
4448
  addPrepareBuildGateErrors(errors, report);
4449
4449
  // Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
@@ -4462,7 +4462,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4462
4462
  addPolishGateErrors(errors, polishGate, "qa");
4463
4463
  addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
4464
4464
  addThemeGateErrors(errors, themeGate, "qa");
4465
- prompt = qaPrompt(packetPath, reportPath, packet, spec);
4465
+ prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary);
4466
4466
  }
4467
4467
  const status = errors.length
4468
4468
  ? "blocked"
@@ -4757,6 +4757,11 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4757
4757
  push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
4758
4758
  return actions;
4759
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
+ }
4760
4765
  if (result.stage === "doctor-blocked") {
4761
4766
  const checkpointGates = (result.gates || []).filter(
4762
4767
  (gate) => gate?.status === "blocked" && Object.hasOwn(CHECKPOINT_EVALUATORS, gate?.id || ""),
@@ -5008,9 +5013,9 @@ function templateStockPromptLine(packet, derived = {}) {
5008
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.`;
5009
5014
  }
5010
5015
 
5011
- function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}) {
5016
+ function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
5012
5017
  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.
5018
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
5014
5019
 
5015
5020
  Read first:
5016
5021
  - Build Packet: ${packetPath}
@@ -5023,6 +5028,7 @@ Read first:
5023
5028
  Rules:
5024
5029
  - Treat CampaignSpec/API as the source for package, shipping, voucher, payment, tracking, footer, and SEO values.
5025
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.
5026
5032
  - Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
5027
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.
5028
5034
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
@@ -5049,9 +5055,44 @@ function localProofPromptLines(packet, packetPath = "<packet>") {
5049
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}`;
5050
5056
  }
5051
5057
 
5052
- function setupPrompt(packetPath, contextPath, reportPath, packet) {
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
+ `;
5091
+ }
5092
+
5093
+ function setupPrompt(packetPath, contextPath, reportPath, packet, intent) {
5053
5094
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5054
- 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.
5055
5096
 
5056
5097
  Read first:
5057
5098
  - Build Packet: ${packetPath}
@@ -5070,9 +5111,9 @@ When copying a starter template family, copy the family as an atomic page-kit sl
5070
5111
  Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
5071
5112
  }
5072
5113
 
5073
- function polishPrompt(packetPath, reportPath, packet) {
5114
+ function polishPrompt(packetPath, reportPath, packet, intent) {
5074
5115
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5075
- return `Use next-campaigns-polish for this built campaign.
5116
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
5076
5117
 
5077
5118
  Read first:
5078
5119
  - Build Packet: ${packetPath}
@@ -5215,14 +5256,14 @@ function orderBumpRunReason(bumpCart) {
5215
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.`;
5216
5257
  }
5217
5258
 
5218
- function qaPrompt(packetPath, reportPath, packet, spec = null) {
5259
+ function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
5219
5260
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5220
5261
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
5221
5262
  const bumpCart = checkoutOrderBumpCart(spec);
5222
5263
  const bumpCommand = bumpCart
5223
5264
  ? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
5224
5265
  : "";
5225
- return `Use next-campaigns-qa for this deployed campaign.
5266
+ return `${campaignIntentPromptHeader(intent)}Use next-campaigns-qa for this deployed campaign.
5226
5267
 
5227
5268
  ${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
5228
5269
  Base URL: ${url}
@@ -6396,7 +6437,7 @@ function safePolishByteCount(value) {
6396
6437
  }
6397
6438
 
6398
6439
  export function formatPolishCaptureText(result) {
6399
- const status = ["ready", "ready_with_waivers", "blocked"].includes(result?.status)
6440
+ const status = ["ready", "ready_with_waivers", "captured_with_warnings", "blocked"].includes(result?.status)
6400
6441
  ? result.status
6401
6442
  : "unknown";
6402
6443
  const measurementStatus = ["complete", "incomplete"].includes(result?.measurement?.status)
@@ -6410,6 +6451,11 @@ export function formatPolishCaptureText(result) {
6410
6451
  `Status: ${status.toUpperCase()}`,
6411
6452
  `Measurement: ${measurementStatus.toUpperCase()}`,
6412
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
+ }
6413
6459
  const incomplete = Array.isArray(result?.measurement?.incomplete)
6414
6460
  ? result.measurement.incomplete
6415
6461
  : [];
@@ -8411,6 +8457,8 @@ const WAIVE_ACTIONS = new Set(["theme-waive", "checkpoint-waive"]);
8411
8457
  // check's readiness line); they are text-only and never enter the JSON result.
8412
8458
  export function resultTextLines(result, { headerLines = [] } = {}) {
8413
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"));
8414
8462
  // A waive command's second line names what it recorded; the third is the
8415
8463
  // stage doctor now picks for the report the waiver was written to.
8416
8464
  if (WAIVE_ACTIONS.has(result.action) && result.gate) {