@nextcommerce/campaigns-os 1.61.0 → 1.63.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 (129) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +3 -0
  22. package/campaign-spec/dist/rules/index.js +9 -0
  23. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  24. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  25. package/campaign-spec/dist/types.d.ts +59 -9
  26. package/compatibility.json +1 -1
  27. package/contracts/commerce-surface-catalog.json +59 -53
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
  44. package/contracts/release-ledger.json +1260 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
  46. package/contracts/supported-surface.json +8 -8
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/build-packet.md +430 -37
  49. package/docs/campaign-standardization-report.md +4 -4
  50. package/docs/campaigns-os-build-flow.md +54 -3
  51. package/docs/campaignspec-authoring-examples.md +59 -4
  52. package/docs/design-source-package.md +18 -6
  53. package/docs/effects.md +17 -9
  54. package/docs/local-setup.md +1 -1
  55. package/docs/orientation-contract-reference.md +1 -1
  56. package/docs/polish-evidence.md +2 -0
  57. package/docs/progress-snapshots.md +7 -4
  58. package/docs/qa-and-test-orders.md +147 -1
  59. package/docs/readback.md +40 -5
  60. package/docs/runtime-readiness.md +7 -3
  61. package/docs/sdk-storage-compatibility.md +1 -1
  62. package/docs/skills-revision.md +10 -10
  63. package/package.json +1 -1
  64. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  65. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  66. package/schemas/campaign-spec.v4.schema.json +34 -3
  67. package/schemas/campaign-spec.v5.schema.json +34 -3
  68. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  69. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  70. package/skills/campaign-readback-classification/SKILL.md +3 -3
  71. package/skills/campaign-run-evidence/SKILL.md +3 -3
  72. package/skills/contribution-intake/SKILL.md +3 -3
  73. package/skills/next-campaigns-build/SKILL.md +4 -3
  74. package/skills/next-campaigns-os/SKILL.md +6 -4
  75. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  76. package/skills/next-campaigns-polish/SKILL.md +5 -4
  77. package/skills/next-campaigns-qa/SKILL.md +7 -4
  78. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  79. package/skills.json +11 -11
  80. package/src/brand-theme.mjs +2 -1
  81. package/src/build-brief.mjs +4 -1
  82. package/src/campaign-workspace.mjs +288 -15
  83. package/src/change-scope.mjs +284 -0
  84. package/src/cli.mjs +308 -86
  85. package/src/commercial-journey.mjs +16 -3
  86. package/src/commercial-parity.mjs +15 -7
  87. package/src/content-residue.mjs +4 -2
  88. package/src/design-source-package.mjs +36 -0
  89. package/src/design-source-publication.mjs +9 -6
  90. package/src/directory-lock.mjs +73 -19
  91. package/src/doctor/checks.mjs +79 -7
  92. package/src/doctor/ci.mjs +75 -10
  93. package/src/doctor/inspect.mjs +23 -6
  94. package/src/doctor/next-step.mjs +7 -0
  95. package/src/doctor-sidecar.mjs +3 -2
  96. package/src/evidence-debt.mjs +47 -7
  97. package/src/finding-cause.mjs +12 -11
  98. package/src/input-refresh.mjs +339 -32
  99. package/src/invocation.mjs +7 -1
  100. package/src/live-campaign-refs.mjs +7 -2
  101. package/src/local-preview-policy.mjs +35 -2
  102. package/src/page-kit-analytics-vendors.mjs +132 -0
  103. package/src/polish-browser.mjs +3 -1
  104. package/src/polish-readability.mjs +221 -9
  105. package/src/polish-visual.mjs +9 -2
  106. package/src/progress-node.mjs +3 -2
  107. package/src/qa-analytics-parity.mjs +17 -1
  108. package/src/qa-browser.mjs +40 -4
  109. package/src/qa-commercial-parity.mjs +50 -3
  110. package/src/qa-node.mjs +196 -16
  111. package/src/qa-publish.mjs +9 -8
  112. package/src/qa-sidecar.mjs +16 -4
  113. package/src/qa-vendor-beacons.mjs +232 -0
  114. package/src/qa-vendor-endpoints.mjs +306 -0
  115. package/src/qc-accept.mjs +29 -1
  116. package/src/qc-check-registry.mjs +5 -1
  117. package/src/qc-results.mjs +73 -26
  118. package/src/readback.mjs +58 -10
  119. package/src/route-identity.mjs +10 -0
  120. package/src/run-record.mjs +18 -10
  121. package/src/runtime-state-ignore.mjs +6 -2
  122. package/src/sdk-storage-compatibility.mjs +21 -2
  123. package/src/source-html-intake.mjs +15 -1
  124. package/src/spec-derive-campaign.mjs +216 -0
  125. package/src/spec-derive.mjs +473 -53
  126. package/src/spec-identity.mjs +14 -0
  127. package/src/stage-ledger.mjs +40 -1
  128. package/src/stage-record.mjs +58 -13
  129. package/src/target-lock.mjs +11 -1
@@ -12,11 +12,16 @@
12
12
  // brief from the brief file last saved by intake or record brief (never by
13
13
  // discovery), and makes build, Polish and QA owed again unless a stage was
14
14
  // already recorded against the new content; waivers, accepts, the theme,
15
- // the packet and the setup and deploy records are never touched. Refusals
16
- // are checked in a fixed order before anything is written; --dry-run runs
17
- // every check and writes nothing.
18
- import { createHash } from "node:crypto";
19
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
+ // the packet and the setup and deploy records are never touched, except when
16
+ // the active pages changed in a way record spec binds (#531, #721): a move of
17
+ // routes alone, or on a handed-off campaign a page added or removed. Then the
18
+ // packet's and the Build Context's page-kit projection, the Design Source
19
+ // Package's page surfaces and the three references to that package are
20
+ // rewritten too, and deploy is owed again when the set of built pages
21
+ // changes. Refusals are checked in a fixed order before anything is written;
22
+ // --dry-run runs every check and writes nothing.
23
+ import { createHash, randomUUID } from "node:crypto";
24
+ import { existsSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
20
25
  import { dirname, join, relative, resolve } from "node:path";
21
26
 
22
27
  import {
@@ -27,6 +32,8 @@ import {
27
32
  } from "./build-brief.mjs";
28
33
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
29
34
  import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
35
+ import { createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, rebindDesignSourcePackagePages, serializeDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
36
+ import { readPriorDesignSourceProvenance, recordPendingDesignSourceProvenance } from "./design-source-publication.mjs";
30
37
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
31
38
  import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
32
39
  import {
@@ -42,9 +49,11 @@ import {
42
49
  wellFormedBriefMaterial,
43
50
  } from "./input-currency.mjs";
44
51
  import { cmd } from "./install-invocation.mjs";
45
- import { publicRouteForPage } from "./source-html-intake.mjs";
46
- import { specHashesMatch } from "./spec-identity.mjs";
47
- import { archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
52
+ import { campaignRouteRoot, intakeRouteRoot, normalizePublicRouteSlug, specPublicRouteSlug } from "./route-identity.mjs";
53
+ import { projectPageKitTargets, publicRouteForPage } from "./source-html-intake.mjs";
54
+ import { staleRoutingHints } from "./spec-derive.mjs";
55
+ import { routeMaskedSpecMaterialHash, specHashesMatch } from "./spec-identity.mjs";
56
+ import { archiveSupersededRecord, assemblyReportMatchesPacket, campaignHandedOff, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
48
57
  import { refuseRecord } from "./stage-record.mjs";
49
58
  import { withTargetLockSync } from "./target-lock.mjs";
50
59
 
@@ -219,14 +228,33 @@ function briefTooLarge(path) {
219
228
  return entries > BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES ? `${path} has ${entries} design_authority entries; the limit is ${BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES}.` : null;
220
229
  }
221
230
 
222
- const sameActivePages = (a, b) => a.length === b.length && a.every((page, index) => page.id === b[index]?.id && (page.page_url ?? null) === (b[index]?.page_url ?? null));
231
+ // An active page as the Build Context records it.
232
+ const recordedActivePage = (page) => ({ id: page.id, type: page.type || null, label: page.label || null, page_url: publicRouteForPage(page) });
223
233
 
224
- // True when the CampaignSpec's active pages (ids, order or routes) differ
225
- // from the ones intake recorded in the Build Context (or none are recorded).
226
- function pageScopeChanged(spec, context) {
234
+ /**
235
+ * How the CampaignSpec's active pages differ from the ones the Build Context
236
+ * records, split the way record spec binds them (#531, #721): page identity
237
+ * (`added` and `removed` ids, and whether the pages both lists keep are in
238
+ * another order) and routes (`moved`: kept pages whose route changed).
239
+ * `changed` is true when anything differs, or when the context records no
240
+ * pages, or a page id twice (then `unbindable`, as a reorder is).
241
+ */
242
+ export function pageScopeChange(spec, context) {
227
243
  const recorded = Array.isArray(context?.spec?.active_pages) ? context.spec.active_pages : null;
228
- const derived = activeSpecPages(spec).map((page) => ({ id: page.id, page_url: publicRouteForPage(page) }));
229
- return !recorded || !sameActivePages(derived, recorded);
244
+ const current = activeSpecPages(spec).map(recordedActivePage);
245
+ if (!recorded) return { recorded: false, current, added: [], removed: [], moved: [], reordered: false, unbindable: true, changed: true };
246
+ const recordedIds = recorded.map((page) => page?.id);
247
+ const currentIds = current.map((page) => page.id);
248
+ const duplicate = new Set(recordedIds).size !== recordedIds.length || new Set(currentIds).size !== currentIds.length;
249
+ const added = currentIds.filter((id) => !recordedIds.includes(id));
250
+ const removed = recordedIds.filter((id) => !currentIds.includes(id));
251
+ const keptRecorded = recordedIds.filter((id) => currentIds.includes(id));
252
+ const keptCurrent = currentIds.filter((id) => recordedIds.includes(id));
253
+ const reordered = keptRecorded.length !== keptCurrent.length || keptRecorded.some((id, index) => id !== keptCurrent[index]);
254
+ const recordedById = new Map(recorded.map((page) => [page?.id, page]));
255
+ const moved = current.filter((page) => recordedById.has(page.id) && (page.page_url ?? null) !== (recordedById.get(page.id)?.page_url ?? null)).map((page) => page.id);
256
+ const unbindable = duplicate || reordered;
257
+ return { recorded: true, current, added, removed, moved, reordered, unbindable, changed: unbindable || added.length > 0 || removed.length > 0 || moved.length > 0 };
230
258
  }
231
259
 
232
260
  // The files Campaigns OS writes itself, which a brief path must never name.
@@ -292,8 +320,8 @@ function briefRefusal({ candidates, packageArtifacts, context, spec, report }) {
292
320
  if (!current || !bound || current !== `sha256:${bound.replace(/^sha256:/, "")}`) {
293
321
  return ["spec_changed_run_record_spec", `the CampaignSpec material (${current || "unreadable"}) differs from the one the Assembly Report binds (${bound || "none"}); save the spec change with ${cmd("record")} spec first.`];
294
322
  }
295
- if (pageScopeChanged(spec, context)) {
296
- return ["page_scope_changed", "the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages; a page-scope change needs intake."];
323
+ if (pageScopeChange(spec, context).changed) {
324
+ return ["page_scope_changed", `the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages; bind the page change with ${cmd("record")} spec first (a routes-only change, or a page added or removed after handoff), or re-run intake for any other.`];
297
325
  }
298
326
  return null;
299
327
  }
@@ -531,6 +559,17 @@ function readSpecFile(specPath) {
531
559
  }
532
560
  }
533
561
 
562
+ // A text file written whole or not at all: staged beside it, then renamed.
563
+ function writeTextAtomic(path, text) {
564
+ const staged = join(dirname(path), `.${relative(dirname(path), path)}.${randomUUID()}.tmp`);
565
+ try {
566
+ writeFileSync(staged, text, { flag: "wx" });
567
+ renameSync(staged, path);
568
+ } finally {
569
+ rmSync(staged, { force: true });
570
+ }
571
+ }
572
+
534
573
  // The brief file last saved, by intake or record brief: its --brief path,
535
574
  // otherwise the file that save found; never discovery. Null when intake
536
575
  // generated the guided draft and no brief file was saved since.
@@ -544,19 +583,206 @@ function recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) {
544
583
 
545
584
  // Every record spec refusal, in the fixed order: the spec checks first (the
546
585
  // page-scope comparison needs a readable spec of the bound identity), then
547
- // the recorded brief file. Returns [code, detail] or null.
548
- function specRefusal({ read, specPath, packet, report, context, briefFile, packageArtifacts }) {
549
- if (!read) return ["spec_unreadable", `the CampaignSpec at ${specPath || "(the packet names no spec.local_path)"} could not be read as a JSON object; restore it, then run ${cmd("record")} spec again.`];
586
+ // the page rebind's own checks, then the recorded brief file. Returns
587
+ // { refusal: [code, detail] | null, rebind } where `rebind` is the page
588
+ // rebind plan (null when the active pages are the ones recorded).
589
+ function specRefusal({ read, specPath, packet, packetPath, report, reportPath, context, targetRepo, briefFile, packageArtifacts }) {
590
+ const refuse = (code, detail) => ({ refusal: [code, detail], rebind: null });
591
+ if (!read) return refuse("spec_unreadable", `the CampaignSpec at ${specPath || "(the packet names no spec.local_path)"} could not be read as a JSON object; restore it, then run ${cmd("record")} spec again.`);
550
592
  const packetKind = specIdentityMismatch(read.spec, packet.spec);
551
593
  const reportKind = isObject(report.identity) ? specIdentityMismatch(read.spec, report.identity) : null;
552
594
  if (packetKind || reportKind) {
553
- return ["spec_identity_changed", `the CampaignSpec's ${(packetKind || reportKind) === "local_spec" ? "local_spec_id" : "map_id"} names another campaign than the ${packetKind ? "Build Packet" : "Assembly Report"} binds; point spec.local_path at this campaign's spec. A new campaign identity needs intake.`];
595
+ return refuse("spec_identity_changed", `the CampaignSpec's ${(packetKind || reportKind) === "local_spec" ? "local_spec_id" : "map_id"} names another campaign than the ${packetKind ? "Build Packet" : "Assembly Report"} binds; point spec.local_path at this campaign's spec. A new campaign identity needs intake.`);
596
+ }
597
+ const scope = pageScopeChange(read.spec, context);
598
+ const intake = `${cmd("prepare-build")}; with --force it clears recorded stage evidence and operator decisions, archiving the build, Polish and QA records`;
599
+ if (scope.unbindable) {
600
+ return refuse("page_scope_changed", `the CampaignSpec's active pages are ${scope.recorded ? "in another order than" : "not comparable with"} the Build Context's spec.active_pages. record spec binds a move of routes, and after handoff a page added or removed; a reorder needs intake (${intake}).`);
554
601
  }
555
- if (pageScopeChanged(read.spec, context)) {
556
- return ["page_scope_changed", `the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages. The Design Source Package and the source mapping are built per page, so a page-scope change needs intake (${cmd("prepare-build")}); with --force it clears recorded stage evidence and operator decisions, archiving the build, Polish and QA records.`];
602
+ if ((scope.added.length || scope.removed.length) && !campaignHandedOff(report)) {
603
+ return refuse("page_scope_changed", `the CampaignSpec ${[scope.added.length ? `adds ${scope.added.map((id) => `"${id}"`).join(", ")}` : null, scope.removed.length ? `removes ${scope.removed.map((id) => `"${id}"`).join(", ")}` : null].filter(Boolean).join(" and ")} against the Build Context's spec.active_pages. Before handoff (no deploy or QA completed) the design source decides the pages, and the Design Source Package and the source mapping are built per page, so a page added or removed needs intake (${intake}).`);
557
604
  }
558
- if (!briefFile) return null;
559
- return briefFileRefusal([briefFile], packageArtifacts, (file) => `the brief file last saved, ${briefFile.path}, ${file?.why || "does not exist"}; restore it, or save a brief file with ${cmd("record")} brief --brief <file>.`);
605
+ let rebind = null;
606
+ if (scope.changed) {
607
+ const planned = planPageRebind({ spec: read.spec, packet, packetPath, report, reportPath, context, targetRepo, scope });
608
+ if (planned.refusal) return { refusal: planned.refusal, rebind: null };
609
+ rebind = planned;
610
+ }
611
+ if (!briefFile) return { refusal: null, rebind };
612
+ const fileRefusal = briefFileRefusal([briefFile], packageArtifacts, (file) => `the brief file last saved, ${briefFile.path}, ${file?.why || "does not exist"}; restore it, or save a brief file with ${cmd("record")} brief --brief <file>.`);
613
+ return { refusal: fileRefusal, rebind: fileRefusal ? null : rebind };
614
+ }
615
+
616
+ const canonicalJson = (value) => {
617
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
618
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
619
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
620
+ };
621
+
622
+ /**
623
+ * What binding the changed active pages writes (#531, #721), or the refusal
624
+ * that stops it. Every page is re-projected to its page-kit target (a page's
625
+ * next_url reads the routes of the pages it links to). Refused, in order:
626
+ * a move of the route root or slug, a routing hint left stale, two pages on
627
+ * one target, and a Design Source Package record spec cannot rewrite (one
628
+ * prepare-build did not synthesize, or one edited since it was recorded).
629
+ * Returns the next packet pages and page map, the rewritten package (or
630
+ * null when its bytes do not change), whether the build's targets or the set
631
+ * of built pages changed, and whether the change touched routes alone.
632
+ */
633
+ function planPageRebind({ spec, packet, packetPath, report, reportPath, context, targetRepo, scope }) {
634
+ const refuse = (code, detail) => ({ refusal: [code, detail] });
635
+ const slug = normalizePublicRouteSlug(packet.campaign?.public_route_slug);
636
+ const specSlug = specPublicRouteSlug(spec);
637
+ const packetRoot = campaignRouteRoot(packet);
638
+ const declaredRoot = optionalString(spec.spec_identity?.route_root) || optionalString(spec.campaign?.route_root);
639
+ const specRoot = declaredRoot ? intakeRouteRoot(declaredRoot, slug) : null;
640
+ if ((specSlug && specSlug !== slug) || (declaredRoot && specRoot !== packetRoot)) {
641
+ return refuse("route_root_changed", `the CampaignSpec ${specSlug && specSlug !== slug ? `names route slug "${specSlug}"` : `declares route_root ${JSON.stringify(declaredRoot)}`}, but the packet serves the campaign at ${packetRoot || "(no route root)"}; the recorded preview and deploy are rooted there, so moving the route root or slug needs intake (${cmd("prepare-build")}).`);
642
+ }
643
+ const stale = staleRoutingHints(spec, { publicRouteSlug: slug });
644
+ if (stale.length) {
645
+ return refuse("routing_hint_stale", `the CampaignSpec's routing hints name routes their pages no longer have: ${stale.map((hint) => `${hint.page_id} sdk_hints.meta_tags.${hint.tag} ${JSON.stringify(hint.value)} (page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)})`).join("; ")}. The built meta tag follows the hint, so shoppers would be sent to the old path. Edit each hint to the route shown in the spec (${cmd("spec")} derive rewrites the ones on an edge it re-points after handoff; while the Map is still the spec's source, re-saving it regenerates them), then run ${cmd("record")} spec again.`);
646
+ }
647
+
648
+ const activePages = activeSpecPages(spec);
649
+ const pageById = new Map(activePages.map((page) => [page.id, page]));
650
+ const outputDir = optionalString(packet.assembly?.output_dir) || `src/${slug}`;
651
+ const projections = projectPageKitTargets({ specPages: activePages, publicRouteSlug: slug, outputDir });
652
+ const removed = new Set(scope.removed);
653
+ const previousPages = Array.isArray(packet.source_html?.pages) ? packet.source_html.pages : [];
654
+ const nextPages = [];
655
+ for (const entry of previousPages) {
656
+ if (!isObject(entry) || removed.has(entry.page_id)) continue;
657
+ nextPages.push(isObject(entry.page_kit) && projections.has(entry.page_id) ? { ...entry, page_kit: projections.get(entry.page_id) } : entry);
658
+ }
659
+ for (const id of scope.added) {
660
+ nextPages.push({ page_id: id, ...(optionalString(pageById.get(id)?.type) ? { page_type: pageById.get(id).type } : {}), origin: "repo", page_kit: projections.get(id) });
661
+ }
662
+ const byTarget = new Map();
663
+ for (const entry of nextPages) {
664
+ const target = entry.page_kit?.output_path;
665
+ if (!target) continue;
666
+ if (byTarget.has(target)) {
667
+ return refuse("page_kit_target_conflict", `pages "${byTarget.get(target)}" and "${entry.page_id}" both project to page-kit target ${target}; give one page a distinct route, then run ${cmd("record")} spec again.`);
668
+ }
669
+ byTarget.set(target, entry.page_id);
670
+ }
671
+ const kitOf = (entries) => new Map(entries.filter((entry) => isObject(entry?.page_kit)).map((entry) => [entry.page_id, canonicalJson(entry.page_kit)]));
672
+ const differs = (before, after) => before.size !== after.size || [...after].some(([id, kit]) => before.get(id) !== kit);
673
+ const afterKits = kitOf(nextPages);
674
+ // Compared with the Build Context's page map as well as the packet's: a
675
+ // run that stopped after writing the packet leaves the packet already
676
+ // re-projected, and the Context still says what was built.
677
+ const projectionChanged = differs(kitOf(previousPages), afterKits) || differs(kitOf(Array.isArray(context.page_map) ? context.page_map : []), afterKits);
678
+ const routesOf = (entries) => [...new Set(entries.filter((entry) => isObject(entry?.page_kit)).map((entry) => entry.page_kit.public_route))].sort();
679
+ const builtPagesChanged = canonicalJson(routesOf(previousPages)) !== canonicalJson(routesOf(nextPages));
680
+ // A moved page with no page-kit target (out of scope, not built) still
681
+ // moves a URL QA reads, so it counts as a change to what is served.
682
+ const unprojectedMoved = scope.moved.some((id) => !afterKits.has(id));
683
+
684
+ const previousMap = Array.isArray(context.page_map) ? context.page_map : [];
685
+ const nextMap = [];
686
+ for (const entry of previousMap) {
687
+ if (!isObject(entry) || removed.has(entry.page_id)) continue;
688
+ if (isObject(entry.page_kit) && projections.has(entry.page_id)) {
689
+ const kit = projections.get(entry.page_id);
690
+ nextMap.push({ ...entry, output_path: relFromDir(targetRepo, resolve(targetRepo, kit.output_path)), page_kit: kit });
691
+ } else nextMap.push(entry);
692
+ }
693
+ for (const id of scope.added) {
694
+ const kit = projections.get(id);
695
+ nextMap.push({ page_id: id, source_path: null, skip_reason: null, output_path: relFromDir(targetRepo, resolve(targetRepo, kit.output_path)), page_kit: kit, origin: "repo" });
696
+ }
697
+
698
+ let designSource = null;
699
+ const reference = packet.design_source_package;
700
+ if (isObject(reference)) {
701
+ const packagePath = resolveFromFile(packetPath, reference.path);
702
+ let bytes;
703
+ let value;
704
+ try {
705
+ bytes = readFileSync(packagePath);
706
+ value = JSON.parse(bytes.toString("utf8"));
707
+ } catch (error) {
708
+ return refuse("design_source_package_unreadable", `the Design Source Package at ${packagePath} could not be read (${error.message}); restore it, or re-run intake (${cmd("prepare-build")}).`);
709
+ }
710
+ const onDisk = hashSerializedDesignSourcePackage(bytes);
711
+ let rebound;
712
+ let serialized;
713
+ try {
714
+ rebound = rebindDesignSourcePackagePages(value, { activePages, projections });
715
+ serialized = serializeDesignSourcePackage(rebound);
716
+ } catch (error) {
717
+ return refuse("design_source_package_unbindable", `the Design Source Package at ${packagePath} is not a package record spec can re-bind (${error.message}); re-run intake (${cmd("prepare-build")}).`);
718
+ }
719
+ // Only a rebind that changes the package writes it, so only then do the
720
+ // checks on who may rewrite it apply.
721
+ if (serialized !== serializeDesignSourcePackage(value)) {
722
+ const origin = report.design_source_package?.origin ?? null;
723
+ if (origin !== "synthesized") {
724
+ return refuse("design_source_package_operator_supplied", `the Design Source Package at ${packagePath} ${origin === "adopted" ? "was adopted by prepare-build from a package it did not write" : origin ? `has origin ${JSON.stringify(origin)}` : "has no recorded origin (an Assembly Report older than the origin field)"}, so record spec does not rewrite its page routes, which the new pages would change; reconcile it with the new pages and re-run intake (${cmd("prepare-build")}).`);
725
+ }
726
+ const provenance = readPriorDesignSourceProvenance(reportPath, packagePath);
727
+ if (onDisk !== reference.sha256 && !(provenance?.synthesized || []).includes(onDisk)) {
728
+ return refuse("design_source_package_edited", `the Design Source Package at ${packagePath} no longer matches the sha256 the packet records (it was edited since prepare-build wrote it), so record spec does not rewrite it; restore it, or re-run intake (${cmd("prepare-build")} --force regenerates a package it synthesized).`);
729
+ }
730
+ const validation = validateDesignSourcePackage(rebound);
731
+ if (!validation.ok) {
732
+ return refuse("design_source_package_unbindable", `the Design Source Package with the new page routes does not validate (${validation.errors.slice(0, 3).map((error) => `[${error.code}] ${error.message}`).join("; ")}); re-run intake (${cmd("prepare-build")}).`);
733
+ }
734
+ const identity = createDesignSourcePackageArtifactReference(rebound, { serialized });
735
+ designSource = { path: packagePath, serialized, sha256: identity.sha256, material_fingerprint: identity.material_fingerprint };
736
+ }
737
+ }
738
+
739
+ // Routes alone: no page added or removed, and nothing but page_url differs
740
+ // from the material the Build Context recorded at the last bind.
741
+ const recordedMasked = optionalString(context.spec?.route_masked_material_hash);
742
+ const routesOnly = !scope.added.length && !scope.removed.length && Boolean(recordedMasked) && recordedMasked === routeMaskedSpecMaterialHash(spec);
743
+ return {
744
+ refusal: null,
745
+ scope,
746
+ nextPages,
747
+ nextMap,
748
+ designSource,
749
+ projectionChanged,
750
+ builtPagesChanged,
751
+ // Nothing the build wrote changes: the same targets, links and routing
752
+ // meta, the same Design Source Package, and only routes moved.
753
+ builtUnchanged: routesOnly && !projectionChanged && !unprojectedMoved && !designSource,
754
+ };
755
+ }
756
+
757
+ /**
758
+ * The Design Source Package reference a record spec run that stopped part
759
+ * way left behind (#531). The rewritten package is published first and the
760
+ * report last, so a run that dies between them leaves the packet, the Build
761
+ * Context or the Assembly Report naming the old sha256 while the package on
762
+ * disk is the new one. When prepare-build's provenance (the report's
763
+ * synthesized reference or the pending record beside the package) vouches
764
+ * for the bytes on disk, and any of the three references names other bytes,
765
+ * this returns the identity to move them to; otherwise null, and doctor
766
+ * reports a mismatch it cannot vouch for.
767
+ */
768
+ function designSourceReferenceRepair({ packet, packetPath, context, report, reportPath }) {
769
+ const reference = packet.design_source_package;
770
+ if (!isObject(reference) || report.design_source_package?.origin !== "synthesized") return null;
771
+ const packagePath = resolveFromFile(packetPath, reference.path);
772
+ let bytes;
773
+ let value;
774
+ try {
775
+ bytes = readFileSync(packagePath);
776
+ value = JSON.parse(bytes.toString("utf8"));
777
+ } catch {
778
+ return null;
779
+ }
780
+ const onDisk = hashSerializedDesignSourcePackage(bytes);
781
+ const references = [reference, context.design_source_package, report.design_source_package].filter(isObject);
782
+ if (references.every((ref) => ref.sha256 === onDisk)) return null;
783
+ if (!(readPriorDesignSourceProvenance(reportPath, packagePath)?.synthesized || []).includes(onDisk)) return null;
784
+ const identity = createDesignSourcePackageArtifactReference(value, { serialized: bytes });
785
+ return { path: packagePath, sha256: identity.sha256, material_fingerprint: identity.material_fingerprint };
560
786
  }
561
787
 
562
788
  // The currency reasons that mean a stage's stamp differs from the current
@@ -580,7 +806,7 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
580
806
  const normalizedPath = optionalString(packet.build_brief?.normalized_path) ? resolveFromFile(packetPath, packet.build_brief.normalized_path) : null;
581
807
  const previousNormalizedBrief = readJsonOrNull(normalizedPath);
582
808
  const briefFile = normalizedPath ? recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) : null;
583
- const refusal = specRefusal({ read, specPath, packet, report, context, briefFile, packageArtifacts: packageArtifactPaths(workspace, normalizedPath) });
809
+ const { refusal, rebind } = specRefusal({ read, specPath, packet, packetPath, report, reportPath, context, targetRepo, briefFile, packageArtifacts: packageArtifactPaths(workspace, normalizedPath) });
584
810
  if (refusal) throw refuseRecord("spec", [`${refusal[0]}: ${refusal[1]}`]);
585
811
 
586
812
  const { spec, rawHash } = read;
@@ -593,7 +819,14 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
593
819
  const owedOnStamp = STAMPED_STAGES.filter((key) => STAMP_OWED_REASONS.includes(currency.reasons[key]));
594
820
  const notices = bindingUnknownNotices(currency);
595
821
  const specChanged = !specHashesMatch(boundMaterial, material);
596
- const unchanged = !specChanged && !owedOnStamp.length;
822
+ // A package reference an earlier, interrupted run left behind is moved
823
+ // with whatever else this run binds (a rewrite this run makes moves them
824
+ // itself).
825
+ const repair = rebind?.designSource ? null : designSourceReferenceRepair({ packet, packetPath, context, report, reportPath });
826
+ // A recovery run never keeps the build: what the interrupted run changed
827
+ // cannot be told from what this one sees, so the stages are owed again.
828
+ const builtUnchanged = Boolean(rebind?.builtUnchanged) && !repair;
829
+ const unchanged = !specChanged && !owedOnStamp.length && !rebind && !repair;
597
830
 
598
831
  const writes = [];
599
832
  let demoted = [];
@@ -605,12 +838,15 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
605
838
  // normalized brief and its bindings, and a material change also makes
606
839
  // the stages its partitions name owed.
607
840
  let brief = null;
841
+ // The context the brief is re-derived from carries the rebound pages, so
842
+ // a page added after handoff is evaluated as the repo page it is.
843
+ const pageContext = rebind ? { ...context, page_map: rebind.nextMap } : context;
608
844
  if (normalizedPath) {
609
845
  // Checked only when the brief is re-derived: an unchanged result reads
610
846
  // nothing the missing intake products would give.
611
847
  const missing = briefInputsMissing(context);
612
848
  if (missing.length) throw refuseRecord("spec", [`brief_inputs_unavailable: the Build Context lacks ${missing.join(", ")}, which re-deriving the brief needs; re-run intake to restore it.`]);
613
- brief = rederiveBrief({ briefFile, spec, context, packet, report, normalizedPath });
849
+ brief = rederiveBrief({ briefFile, spec, context: pageContext, packet, report, normalizedPath });
614
850
  briefOutcome = briefSaveOutcome(report, brief.buildBrief, brief.bindings);
615
851
  }
616
852
  const briefSaved = briefOutcome && briefOutcome.outcome !== "unchanged";
@@ -620,8 +856,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
620
856
  const briefWritten = Boolean(brief) && (briefSaved || !sameBriefMaterial(brief.bindings.material, currentBrief));
621
857
  const newBrief = brief ? brief.bindings.material : currentBrief;
622
858
  const saved = briefSaved
623
- ? briefSaveArtifacts({ context, report, buildBrief: brief.buildBrief, bindings: brief.bindings, targetRepo })
624
- : { nextContext: context, nextReport: report };
859
+ ? briefSaveArtifacts({ context: pageContext, report, buildBrief: brief.buildBrief, bindings: brief.bindings, targetRepo })
860
+ : { nextContext: pageContext, nextReport: report };
625
861
  const labels = new Map(activeSpecPages(spec).map((page) => [page.id, page.label || null]));
626
862
  const nextContext = {
627
863
  ...saved.nextContext,
@@ -629,10 +865,25 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
629
865
  ...saved.nextContext.spec,
630
866
  hash: rawHash,
631
867
  material_hash: material,
632
- active_pages: saved.nextContext.spec.active_pages.map((page) => ({ ...page, label: labels.has(page.id) ? labels.get(page.id) : page.label ?? null })),
868
+ route_masked_material_hash: routeMaskedSpecMaterialHash(spec),
869
+ active_pages: rebind
870
+ ? rebind.scope.current
871
+ : saved.nextContext.spec.active_pages.map((page) => ({ ...page, label: labels.has(page.id) ? labels.get(page.id) : page.label ?? null })),
633
872
  },
634
873
  };
635
874
  let nextReport = { ...saved.nextReport, identity: { ...saved.nextReport.identity, spec_hash: rawHash, spec_material_hash: material } };
875
+ // The rebound pages' Design Source Package: the same path, new bytes, and
876
+ // each of the three references to it moved together (#531).
877
+ let nextPacket = null;
878
+ if (rebind) nextPacket = { ...packet, source_html: { ...packet.source_html, pages: rebind.nextPages } };
879
+ const movedTo = rebind?.designSource || repair;
880
+ if (movedTo) {
881
+ const moved = { sha256: movedTo.sha256, material_fingerprint: movedTo.material_fingerprint };
882
+ const at = (ref) => ref.sha256 !== moved.sha256 || ref.material_fingerprint !== moved.material_fingerprint;
883
+ if (at(packet.design_source_package)) nextPacket = { ...(nextPacket || packet), design_source_package: { ...packet.design_source_package, ...moved } };
884
+ if (isObject(nextContext.design_source_package) && at(nextContext.design_source_package)) nextContext.design_source_package = { ...nextContext.design_source_package, ...moved };
885
+ if (isObject(nextReport.design_source_package) && at(nextReport.design_source_package)) nextReport.design_source_package = { ...nextReport.design_source_package, ...moved };
886
+ }
636
887
  // A stage is owed on the spec unless its spec stamp already equals the
637
888
  // new material (a build recorded after the edit has recorded the change
638
889
  // itself); a completed stage with no spec stamp is owed on it too. It is
@@ -640,6 +891,22 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
640
891
  // that partition, or when its stamp of that partition differs from the
641
892
  // re-derived brief. The spec reason wins where both apply.
642
893
  const briefCauses = briefOutcome?.outcome === "saved_with_invalidation" ? briefOutcome.causes : [];
894
+ // Routes that moved without moving anything the build wrote (the same
895
+ // targets, links and routing meta, #531): a stage made against the bound
896
+ // spec is restamped with the new material instead of made owed again.
897
+ const keptStages = [];
898
+ if (builtUnchanged && specChanged) {
899
+ for (const key of STAMPED_STAGES) {
900
+ const record = nextReport.stages[key];
901
+ if (!COMPLETED_STATUSES.includes(record?.status) || !specHashesMatch(record.source_spec_material_hash, boundMaterial)) continue;
902
+ const restamped = { ...record, source_spec_material_hash: material };
903
+ if (isObject(record.unchanged_output_reason?.for_inputs) && specHashesMatch(record.unchanged_output_reason.for_inputs.spec_material_hash, boundMaterial)) {
904
+ restamped.unchanged_output_reason = { ...record.unchanged_output_reason, for_inputs: { ...record.unchanged_output_reason.for_inputs, spec_material_hash: material } };
905
+ }
906
+ nextReport = { ...nextReport, stages: { ...nextReport.stages, [key]: restamped } };
907
+ keptStages.push(key);
908
+ }
909
+ }
643
910
  const causesFor = (key) => {
644
911
  const record = nextReport.stages[key];
645
912
  const stamp = record?.source_brief_material;
@@ -662,24 +929,52 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
662
929
  demoted = [...demoted, ...applied.demoted];
663
930
  allCauses.push(...causes);
664
931
  }
932
+ // Deploy evidence is the list of URLs that answered; when the set of
933
+ // built pages changes, the probe is owed again (#531). A deploy bound to
934
+ // the build fingerprint it probed (#619) is demoted by record build when
935
+ // the rebuilt site differs, so only a deploy recorded before that stamp
936
+ // existed is demoted here.
937
+ if (rebind?.builtPagesChanged) {
938
+ const previousDeploy = nextReport.stages.deploy;
939
+ if (isObject(previousDeploy) && COMPLETED_STATUSES.includes(previousDeploy.status) && !optionalString(previousDeploy.source_build_fingerprint)) {
940
+ const demotedDeploy = { ...previousDeploy, stage: "deploy", status: "required", required_by: "spec", required_for: ["qa"] };
941
+ for (const field of ["source_build_fingerprint", "evidence", "outputs", "completed_at", "recorded_by", "performed_by"]) delete demotedDeploy[field];
942
+ nextReport = { ...nextReport, stages: { ...nextReport.stages, deploy: archiveSupersededRecord(demotedDeploy, previousDeploy, { by: "record spec", reason: "spec_material_changed", at: timestamp }) } };
943
+ demoted = [...demoted, "deploy"];
944
+ }
945
+ }
665
946
  reason = changeReason(allCauses);
666
947
  const briefNote = briefSaved
667
948
  ? `; brief re-derived (${briefOutcome.outcome})`
668
949
  : briefWritten ? "; normalized brief re-derived from its source" : "";
950
+ const pageNote = rebind
951
+ ? `; pages ${[rebind.scope.added.length ? `added ${rebind.scope.added.join(", ")}` : null, rebind.scope.removed.length ? `removed ${rebind.scope.removed.join(", ")}` : null, rebind.scope.moved.length ? `moved ${rebind.scope.moved.join(", ")}` : null].filter(Boolean).join("; ")}, page-kit projection ${rebind.projectionChanged ? "rewritten" : "unchanged"}${rebind.designSource ? ", Design Source Package page surfaces rewritten" : ""}${keptStages.length ? `, ${keptStages.join(", ")} kept (nothing built changed)` : ""}`
952
+ : "";
953
+ const repairNote = repair ? `; Design Source Package references moved to ${repair.sha256}, the package an interrupted record spec published` : "";
669
954
  nextReport.evidence = [
670
955
  ...(Array.isArray(report.evidence) ? report.evidence : []),
671
- `${timestamp} record spec: CampaignSpec material ${specChanged ? `${boundMaterial || "(none bound)"} -> ${material}` : `${material} already bound`}${briefNote}; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"}${reason ? ` (${reason})` : ""}.`,
956
+ `${timestamp} record spec: CampaignSpec material ${specChanged ? `${boundMaterial || "(none bound)"} -> ${material}` : `${material} already bound`}${briefNote}${pageNote}${repairNote}; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"}${reason ? ` (${reason})` : ""}.`,
672
957
  ];
673
- writes.push(...(briefWritten ? [normalizedPath] : []), contextPath, reportPath);
958
+ writes.push(...(rebind?.designSource ? [rebind.designSource.path] : []), ...(nextPacket ? [packetPath] : []), ...(briefWritten ? [normalizedPath] : []), contextPath, reportPath);
674
959
  if (!dryRun) {
675
960
  // Intake's publication order, the report last: a failure between files
676
961
  // leaves the report bound to the old spec material, which reads as owed.
962
+ // The rewritten Design Source Package goes first, vouched for by the
963
+ // pending provenance record until the report records its new sha256,
964
+ // so a run that stops between the two leaves a package the next
965
+ // record spec or prepare-build still knows as its own.
966
+ if (rebind?.designSource) {
967
+ recordPendingDesignSourceProvenance(rebind.designSource.path, [rebind.designSource.sha256, packet.design_source_package.sha256]);
968
+ writeTextAtomic(rebind.designSource.path, rebind.designSource.serialized);
969
+ }
970
+ if (nextPacket) writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
677
971
  if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
678
972
  writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
679
973
  commitAssemblyReport(workspace, () => nextReport, {
680
974
  command: "record spec",
681
975
  staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
682
976
  });
977
+ if (movedTo) recordPendingDesignSourceProvenance(movedTo.path, []);
683
978
  }
684
979
  }
685
980
 
@@ -697,6 +992,18 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
697
992
  ...(dryRun ? { would_write: writes } : { written: writes }),
698
993
  demoted,
699
994
  ...(briefOutcome ? { brief: { outcome: briefOutcome.outcome, brief_path: briefFile?.path || null } } : {}),
995
+ ...(rebind ? {
996
+ pages: {
997
+ added: rebind.scope.added,
998
+ removed: rebind.scope.removed,
999
+ moved: rebind.scope.moved,
1000
+ projection_changed: rebind.projectionChanged,
1001
+ design_source_package_rewritten: Boolean(rebind.designSource),
1002
+ built_pages_changed: rebind.builtPagesChanged,
1003
+ built_unchanged: builtUnchanged,
1004
+ },
1005
+ } : {}),
1006
+ ...(repair ? { design_source_references_repaired: { sha256: repair.sha256 } } : {}),
700
1007
  ...(reason ? { input_change: { reason, at: timestamp } } : {}),
701
1008
  notices,
702
1009
  note: outcome === "unchanged"
@@ -111,6 +111,12 @@ export const commandNames = () => Object.keys(COMMANDS);
111
111
 
112
112
  export const subcommandNames = (command) => (Object.hasOwn(COMMANDS, command) && COMMANDS[command].subcommands) || frozen([]);
113
113
 
114
+ // The flag names parseArgs met spelled `--flag=value` (in argv order), kept off
115
+ // the enumerable keys. The invocation is refused at dispatch, and it does
116
+ // nothing first: its values were dropped, so the root the stale sweep would
117
+ // act on, and the --no-remit / --proxy-base it would inherit, are unknown.
118
+ export const JOINED_FLAGS = Symbol("campaigns-os.joined-flags");
119
+
114
120
  // A run opted out of sessions altogether: no stale sweep, and no intake
115
121
  // auto-start (which reads this predicate from here).
116
122
  export const optsOutOfRunSession = (args) => args["no-run-session"] === true;
@@ -133,7 +139,7 @@ export function resolveInvocationPolicy(command, args) {
133
139
  const noWrite = args["no-write"] === true;
134
140
  const implementsDryRun = rule.dryRun === true;
135
141
  const help = Boolean(args.help);
136
- const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
142
+ const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun) || Boolean(args[JOINED_FLAGS]?.length);
137
143
  const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
138
144
  return Object.freeze({
139
145
  class: rule.class,
@@ -209,6 +209,7 @@ export async function readLiveCampaign({
209
209
  timeoutMs = LIVE_CAMPAIGN_TIMEOUT_MS,
210
210
  maxBytes = LIVE_CAMPAIGN_MAX_BYTES,
211
211
  warn = undefined,
212
+ includeCampaign = false,
212
213
  } = {}) {
213
214
  if (typeof apiKey !== "string" || !apiKey.trim()) {
214
215
  return notRun("no_key", "No public Campaigns API key resolved, so the live campaign was not read.");
@@ -307,7 +308,10 @@ export async function readLiveCampaign({
307
308
  if (picked.notRun) return { ...picked.notRun, ...requested };
308
309
  const refs = liveCampaignRefs(picked.campaign);
309
310
  if (!refs) return notRun("unexpected_body", "The live campaign did not carry package and shipping-method lists with a ref on every entry.", requested);
310
- return { status: "read", ...requested, ...refs };
311
+ // `spec derive` refreshes the spec's mirrored offer and package fields from
312
+ // the same read (#721), so it asks for the campaign body as well; doctor and
313
+ // QA compare refs only and never carry the body in their results.
314
+ return { status: "read", ...requested, ...refs, ...(includeCampaign ? { campaign: picked.campaign } : {}) };
311
315
  }
312
316
 
313
317
  // The `--no-live-refs` opt-out on `doctor` and `qa run`: no request, and the
@@ -357,7 +361,7 @@ function readPacketLocalSpec(packet, packetPath) {
357
361
  // and env-name gates apply, plus the stricter env-name gate above), then one
358
362
  // read, for the campaign the CampaignSpec's `campaign.ref_id` names when it
359
363
  // names one.
360
- export async function readLiveCampaignForPacket({ packet, packetPath = null, spec = undefined, env = process.env, fetchImpl = null, proxyBase = null, timeoutMs, warn } = {}) {
364
+ export async function readLiveCampaignForPacket({ packet, packetPath = null, spec = undefined, env = process.env, fetchImpl = null, proxyBase = null, timeoutMs, warn, includeCampaign = false } = {}) {
361
365
  const localSpec = spec !== undefined ? spec : readPacketLocalSpec(packet, packetPath);
362
366
  const resolved = resolveCampaignsApiKeySource(packet, packetPath, env, localSpec === undefined ? {} : { spec: localSpec });
363
367
  const refusedEnv = refusedEnvKeySource(packet, resolved);
@@ -377,6 +381,7 @@ export async function readLiveCampaignForPacket({ packet, packetPath = null, spe
377
381
  fetchImpl,
378
382
  proxyBase,
379
383
  campaignRefId: campaignRefIdFromSpec(localSpec),
384
+ includeCampaign,
380
385
  ...(timeoutMs ? { timeoutMs } : {}),
381
386
  ...(warn ? { warn } : {}),
382
387
  });