@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  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 +29 -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 +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
package/src/cli.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { withDoctorCiReport } from "./doctor/ci.mjs";
2
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { resolveDesignFileReferences } from "./design-file-reference.mjs";
3
5
  import { withDesignSourcePublication } from "./design-source-publication.mjs";
4
6
  import { createDemo, demoArguments } from "./demo.mjs";
5
7
  import { execFileSync } from "node:child_process";
@@ -26,11 +28,11 @@ import { diagnosticExport, diagnosticTextLines } from "./diagnostic.mjs";
26
28
  import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
27
29
  import { HIDDEN_EAGER_MEDIA_ACTIONS, qaRunCommand, requiredActionText, substitutePacket } from "./gate-actions.mjs";
28
30
  import { orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
29
- import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
31
+ import { routeMaskedSpecMaterialHash, specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
30
32
  // The same predicate stage-ledger.mjs judges a mutator's result with, imported
31
33
  // rather than re-stated so the waive preview and the commit agree by identity.
32
34
  import { isPlainObject } from "./repo-scan.mjs";
33
- import { applyDerivedAssemblyReportSummary, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome } from "./stage-ledger.mjs";
35
+ import { applyDerivedAssemblyReportSummary, campaignHandedOff, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome } from "./stage-ledger.mjs";
34
36
  import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
35
37
  import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
36
38
  import {
@@ -80,7 +82,17 @@ import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./d
80
82
  import { QcAcceptRefusal, buildQcHandoff, parseQcResultRef, planQcAccepts, projectQcAccept, qcAcceptAttribution, qcHandoffTextLines } from "./qc-accept.mjs";
81
83
  import { loadQcRederivers } from "./qc-check-registry.mjs";
82
84
  import { fingerprint12, readCurrentQcResults } from "./qc-results.mjs";
83
- import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
85
+ import {
86
+ DEFAULT_PACKET_FILENAME,
87
+ RUNTIME_DIR_REL_PATH,
88
+ campaignSidecarPaths,
89
+ explicitReportPath,
90
+ packetRuntimeRelPath,
91
+ planFunnelWorkspace,
92
+ resolveCampaignWorkspace,
93
+ soleProjectPacket,
94
+ targetRepoFor,
95
+ } from "./campaign-workspace.mjs";
84
96
  import { canonicalPath, sameFile } from "./fs-identity.mjs";
85
97
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
86
98
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
@@ -99,7 +111,7 @@ import {
99
111
  runWithRefusalScope,
100
112
  timeOperatorWait,
101
113
  } from "./lifecycle.mjs";
102
- import { commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
114
+ import { JOINED_FLAGS, commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
103
115
  import {
104
116
  clearRunSession,
105
117
  findRunSession,
@@ -139,6 +151,7 @@ import { derivePackagePin, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveI
139
151
  import {
140
152
  campaignRouteRoot,
141
153
  normalizePublicRouteSlug,
154
+ specPublicRouteSlug,
142
155
  } from "./route-identity.mjs";
143
156
  import {
144
157
  PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND,
@@ -238,11 +251,13 @@ import {
238
251
  formatDeriveValue,
239
252
  isPageTreeIgnoredDir,
240
253
  isPageTreeIgnoredPath,
241
- frontmatterPermalink,
254
+ frontmatterScalars,
242
255
  pageRouteForFile,
243
256
  permalinkRoute,
244
257
  planSpecDerive,
245
258
  } from "./spec-derive.mjs";
259
+ import { planCampaignMirrorDerive } from "./spec-derive-campaign.mjs";
260
+ import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "./live-campaign-refs.mjs";
246
261
  import {
247
262
  adminApiBaseForStore,
248
263
  normalizeStoreSubdomain,
@@ -309,7 +324,7 @@ Usage:
309
324
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
310
325
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
311
326
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
312
- [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # --force overwrites an assembly report that carries stage evidence (destructive; prints the cleared stage keys) and regenerates a stale Design Source Package an earlier intake synthesized and nobody changed
327
+ [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # --force overwrites an assembly report that carries stage evidence (destructive; prints the cleared stage keys) and regenerates a stale Design Source Package an earlier intake synthesized and nobody changed; with --out naming another route's packet it replaces that funnel (renaming its route)
313
328
  campaigns-os prepare-build (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
314
329
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
315
330
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
@@ -320,7 +335,7 @@ Usage:
320
335
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
321
336
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
322
337
  [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
323
- campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
338
+ campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--ci] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. --ci: Markdown (or JSON ci object), exits 0 current / 3 owed / 4 unknown / 2 packet usage error; other doctor errors do not affect the CI exit. Without --ci: 0 ok / 2 errors. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
324
339
  campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
325
340
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
326
341
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
@@ -332,7 +347,7 @@ Usage:
332
347
  campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
333
348
  campaigns-os checkpoint accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result ...] --reason "<the operator's reason>" --accepted-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # record the operator's accept of a measured QC warning, as the QC handoff of next prints it; changes no readiness and lapses when the measured state or the build changes. --dry-run runs every check and prints the accepts it would record, without touching the report
334
349
  campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
335
- 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.
350
+ campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--no-live-refs] [--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, and each starter-template vendor's id key, such as ga4_id or triplewhale_name -> analytics.vendors.<vendor>.id); prints a field-by-field before -> after diff and writes nothing else. On a handed-off campaign (deploy or QA completed, now or in the stage's history) the page tree also adds pages (a file with a page-kit page_type no spec page binds; id from the file name, appended to the funnel, routing from its next_url/decline_url/next_step frontmatter) and removes pages (one whose built file the packet named is gone), matched by page id; page order is reported as not derived. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves, it also makes the one read-only GET of {proxy-base}/api/campaign doctor makes and refreshes the fields the spec mirrors from the Campaigns App (each page package's name, price, product, variant and availability; the offer records and each page's offer copies; live offers missing from offers[] are added) — a price is never written from the repo; --no-live-refs sends nothing and leaves those fields as they are; --proxy-base names the proxy. Otherwise repo-derived fields only; --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.
336
351
  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.
337
352
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
338
353
  campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
@@ -351,7 +366,7 @@ Usage:
351
366
  campaigns-os logout [--store <subdomain>]
352
367
  campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
353
368
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
354
- campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
369
+ campaigns-os install-agent-context --target <page-kit-dir> [--dry-run] [--json]
355
370
  campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
356
371
  campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
357
372
  campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
@@ -359,7 +374,7 @@ Usage:
359
374
  campaigns-os next deploy --packet <json> --report <json> [--json]
360
375
  campaigns-os next qa --packet <json> --report <json> [--json]
361
376
  campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
362
- campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
377
+ campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--page <page_id>]... [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--test-email <email>] [--test-country <code> --test-address1 <line> --test-city <city> --test-province <code> --test-postal <code>] [--vendor-beacons live] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
363
378
  campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
364
379
  campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
365
380
  campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
@@ -373,7 +388,7 @@ Usage:
373
388
 
374
389
  Commands other than login, logout, demo, and tooling diagnose accept [--lifecycle-journal <path>] (or env CAMPAIGNS_OS_LIFECYCLE_LOG) to append a command-lifecycle entry (command, argv shape, exit status, timing) for the run; pair with --run-id so run-record can embed it.
375
390
  --no-write suppresses that append for every command, however the journal was selected (flag, env, or the active run session); a refused invocation (unknown command, an unknown subcommand refused before its handler runs, or a flag the command refuses up front) and \`run status\` never append one at all.
376
- A refused invocation writes no file of its own. One effect still precedes argument refusal: \`start\`, \`prepare-build\`, \`build\`, \`run start\` and \`run end\` close out a STALE run session at the root they are about to act on (Run Record assembled and remitted under the usual consent, session file cleared) before argv is refused — a declared effect of those commands. --no-write suppresses that closeout too, so a --no-write invocation leaves the target byte-identical.
391
+ A refused invocation writes no file of its own. One effect still precedes argument refusal: \`start\`, \`prepare-build\`, \`build\`, \`run start\` and \`run end\` close out a STALE run session at the root they are about to act on (Run Record assembled and remitted under the usual consent, session file cleared) before argv is refused — a declared effect of those commands. --no-write suppresses that closeout too, so a --no-write invocation leaves the target byte-identical; a --flag=value token, which every command refuses, skips it as well.
377
392
  campaigns-os telemetry status|on [--proxy-base <url>] [--json] # machine-level Run Telemetry consent (gates remit only; capture is always local). \`on\` records consent for ONE endpoint: the canonical NEXT endpoint by default, or the --proxy-base you name (a loopback or staging receiver); \`status\` reports the stored scope and checks it against the canonical endpoint or the --proxy-base you name
378
393
  campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
379
394
  campaigns-os telemetry list [--packet <json> | --admin-key-env <VAR>] [--since <ISO>] [--package <v>] [--surface <s>] [--trusted] [--limit <n>] [--proxy-base <url>] [--trust-proxy-base] [--json] # read stored Run Records: tenant scope via the packet's campaign key, or cross-tenant via the ops admin key (default env CAMPAIGN_OPS_ADMIN_KEY). --proxy-base must be https unless it is a loopback host (allowed over http, with a warning that the credential is in clear).
@@ -484,14 +499,17 @@ export async function main(argv, { authentication, qcStandIns } = {}) {
484
499
  console.log(packageVersion());
485
500
  return;
486
501
  }
487
- return runInvocation(parseArgs(argv), {
488
- dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
502
+ return withDoctorCiReport(parseArgs(argv), (args) => runInvocation(args, {
503
+ dispatch: (command, args, context) => {
504
+ refuseJoinedFlag(args);
505
+ return dispatch(command, args, { ...context, argv, authentication, qcStandIns });
506
+ },
489
507
  closeOutStaleRunSessions,
490
508
  ambientRunSession,
491
509
  lifecycleIdentity,
492
510
  persistLifecycle: persistLifecycleIfRequested,
493
511
  autoEndAfterQa: autoEndRunSessionAfterTerminalQa,
494
- });
512
+ }));
495
513
  }
496
514
 
497
515
  // The lifecycle entry's identity (T6): its argv shape and its run_id. Ambient
@@ -728,10 +746,17 @@ export function recordQaStageOutcome(args, result) {
728
746
  // start and the spec material it judged, never the inputs at write time.
729
747
  const qaStamps = inputStamps({ briefMaterial: verdict.source_brief_material, specMaterial: verdict.spec_hash });
730
748
  const qaInputs = stageWriteInputs("qa", currentPacketInputs({ packet, packetPath }));
749
+ const pageScope = isObject(result.page_scope) ? result.page_scope : null;
731
750
  const committed = commitAssemblyReport(workspace, (report) => {
732
751
  if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
733
752
  throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
734
753
  }
754
+ // A page-scoped run (#720) is bound to the build it checked; a report
755
+ // rebuilt since then keeps the QA it had.
756
+ if (pageScope && currentBuildFingerprint(report) !== pageScope.build_fingerprint) {
757
+ throw new Error("The build was recorded again during this page-scoped QA run; report evidence was not changed. Re-run qa run --page against the recorded build.");
758
+ }
759
+ const evidence = qaStageGateEvidence(verdict, report, result);
735
760
  return recordProducerStageOutcome(report, {
736
761
  stage: "qa",
737
762
  disposition: verdict.disposition,
@@ -748,11 +773,19 @@ export function recordQaStageOutcome(args, result) {
748
773
  // Which build this verdict judged, and the gates whose browser outcome
749
774
  // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
750
775
  // gate that never ran is left out, so silence never reads as a pass.
751
- evidence: qaStageGateEvidence(verdict, report, result),
776
+ // A page-scoped record names its pages, the changed pages it pays
777
+ // for and the build whose full QA it extends.
778
+ evidence: pageScope ? { ...evidence, page_scope: {
779
+ pages: pageScope.pages, owed_pages: pageScope.owed_pages, base_build_fingerprint: pageScope.base_build_fingerprint,
780
+ } } : evidence,
752
781
  // Counts-only: never order ids, refs, emails or URLs (see
753
782
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
754
- // path from a `--test-order off` diagnostic.
755
- proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
783
+ // path from a `--test-order off` diagnostic. A page-scoped run places
784
+ // no orders and the change it pays touches no commerce, so the
785
+ // purchase proof of the full run it extends is kept.
786
+ proof: pageScope && isObject(report.stages?.qa?.purchase_proof)
787
+ ? report.stages.qa.purchase_proof
788
+ : summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
756
789
  stamps: qaStamps,
757
790
  inputs: qaInputs,
758
791
  });
@@ -1022,7 +1055,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1022
1055
  // A --map-id fetch runs here, but the fetched spec is written to the
1023
1056
  // shared cache file inside prepareBuild, under the per-target lock, so it
1024
1057
  // never replaces the spec another run is reading and hashing (#501).
1025
- const { publishSpec, ...resolved } = await recorder.time("resolve-spec", () => resolveSpecPath(args, { deferCacheWrite: true }));
1058
+ const { publishSpec, fetchedSpec, ...resolved } = await recorder.time("resolve-spec", () => resolveSpecPath(args, { deferCacheWrite: true }));
1026
1059
  // The spec as the operator named it, before resolution, so the build
1027
1060
  // context can record which input to replay (a local file, or a map id
1028
1061
  // fetched from a given store).
@@ -1033,7 +1066,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1033
1066
  // Doctor runs inside prepare-build, so its codes are recorded while that
1034
1067
  // phase is active and attach to it.
1035
1068
  const result = await recorder.time("prepare-build", async () => {
1036
- const prepared = await prepareBuild(args, { ...mode, command, specInput, publishSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag });
1069
+ const prepared = await prepareBuild(args, { ...mode, command, specInput, publishSpec, fetchedSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag });
1037
1070
  if (prepared.doctor && !prepared.doctor.ok) recorder.recordFindingCodes(prepared.doctor.errors?.map((issue) => issue.code));
1038
1071
  return prepared;
1039
1072
  });
@@ -1049,8 +1082,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1049
1082
  const liveCampaign = await readDoctorLiveCampaign(args);
1050
1083
  const result = doctorCommand(args, { liveCampaign, qcStandIns });
1051
1084
  if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
1052
- writeResult(result, args, result.ok ? 0 : 2);
1053
- printDoctorTinyPrompt(result, args);
1085
+ if (args.ci === true) {
1086
+ const { formatDoctorCi, DOCTOR_CI_EXIT_CODES } = await import("./doctor/ci.mjs");
1087
+ if (!args.json) console.log(formatDoctorCi(result));
1088
+ else writeResult(result, args, 0);
1089
+ process.exitCode = result.ci?.exit_code ?? result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage;
1090
+ } else {
1091
+ writeResult(result, args, result.ok ? 0 : 2);
1092
+ printDoctorTinyPrompt(result, args);
1093
+ }
1054
1094
  return;
1055
1095
  }
1056
1096
 
@@ -1172,8 +1212,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1172
1212
 
1173
1213
  if (command === "install-agent-context") {
1174
1214
  const target = requireArg(args, "target");
1175
- const result = installAgentContext(resolve(target), Boolean(args["dry-run"]));
1176
- writeResult(result, args, 0);
1215
+ const dryRun = isDryRun(args);
1216
+ const result = installAgentContext(resolve(target), dryRun);
1217
+ if (dryRun && !args.json) {
1218
+ for (const file of result.file_contents) {
1219
+ console.log(`Destination: ${file.destination}\n${file.content}`);
1220
+ }
1221
+ } else {
1222
+ writeResult(result, args, 0);
1223
+ }
1177
1224
  return;
1178
1225
  }
1179
1226
 
@@ -1241,6 +1288,8 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1241
1288
  // The ambient session is handed over, not re-discovered: the closeout
1242
1289
  // command a QA run prints has to agree with the run_id this session will
1243
1290
  // later close and remit under.
1291
+ // --page repeats; the shared parser keeps only the last value.
1292
+ if (args._[1] === "run" && Array.isArray(argv) && argv.includes("--page")) args = { ...args, page: repeatedFlagValues(argv, "page") };
1244
1293
  const result = await runQaCli(args, { ambient });
1245
1294
  if (result?.verdict?.disposition === "blocked") {
1246
1295
  recorder.recordFindingCodes(result.verdict.assertions?.filter((item) => item.status === "fail" || item.severity === "blocker")
@@ -1291,6 +1340,20 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1291
1340
  );
1292
1341
  }
1293
1342
 
1343
+ // A flag takes its value as the next token. `--flag=value` would otherwise be
1344
+ // an unknown key, silently ignored (`qa run --page=x` widening to a full run),
1345
+ // so it is refused at dispatch, like every other argv refusal; the stale
1346
+ // sweep before dispatch is skipped for it (JOINED_FLAGS). Only the name is
1347
+ // quoted: the value can be a secret, such as an --auth-cookie.
1348
+ function refuseJoinedFlag(args) {
1349
+ const flags = args?.[JOINED_FLAGS];
1350
+ if (!flags?.length) return;
1351
+ const [flag] = flags;
1352
+ if (!flag) throw refused("--=… is not accepted: the flag name is missing before =. Write the flag, then its value as the next argument (--flag value).");
1353
+ const name = `--${flag}`;
1354
+ throw refused(`${name}=… is not accepted: a flag takes its value as the next argument, not --flag=value. Write \`${name}\` alone for an on/off flag, or \`${name} <value>\` for a flag that takes a value.`);
1355
+ }
1356
+
1294
1357
  export function parseArgs(argv) {
1295
1358
  const args = { _: [] };
1296
1359
  for (let index = 0; index < argv.length; index += 1) {
@@ -1299,6 +1362,12 @@ export function parseArgs(argv) {
1299
1362
  args._.push(token);
1300
1363
  continue;
1301
1364
  }
1365
+ const equal = token.indexOf("=");
1366
+ if (equal >= 2) {
1367
+ if (!args[JOINED_FLAGS]) Object.defineProperty(args, JOINED_FLAGS, { value: [] });
1368
+ args[JOINED_FLAGS].push(token.slice(2, equal));
1369
+ continue;
1370
+ }
1302
1371
  const key = token.slice(2);
1303
1372
  const next = argv[index + 1];
1304
1373
  if (!next || next.startsWith("--")) {
@@ -1387,7 +1456,9 @@ async function resolveSpecPath(args, opts = {}) {
1387
1456
  return { specPath: cachePath, source: "remote", mapId, proxyBase,
1388
1457
  savedMapRevision: { map_id: mapId, hash: spec.spec_identity?.spec_hash || spec.spec_hash || null,
1389
1458
  algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
1390
- ...(opts.deferCacheWrite ? { publishSpec } : {}),
1459
+ // The fetched spec itself, for a caller that writes the cache later and
1460
+ // must know the funnel's route first. Not part of the reported source.
1461
+ ...(opts.deferCacheWrite ? { publishSpec, fetchedSpec: spec } : {}),
1391
1462
  };
1392
1463
  }
1393
1464
  throw refused(
@@ -1516,11 +1587,13 @@ function createProofPolicy({ orderPathDepth = null } = {}) {
1516
1587
  // channel that supplies it. Source screenshot proof is seeded only by the
1517
1588
  // source-html manifest's pages[].screenshots[], which is documented nowhere in
1518
1589
  // the blocking reason itself.
1519
- const DESIGN_SOURCE_PACKAGE_REMEDY = [
1590
+ // `packagePath` is where this run emitted the package, relative to the target
1591
+ // repo: under the funnel's runtime directory.
1592
+ const designSourcePackageRemedy = (packagePath = DESIGN_SOURCE_PACKAGE_REL_PATH) => [
1520
1593
  `Supply the missing source proof through pages[].screenshots[] in ${SOURCE_HTML_MANIFEST_REL_PATH}`,
1521
1594
  "under the source root, or in a manifest anywhere else named by --design-manifest <path>",
1522
1595
  "(one available desktop record and one available mobile record per renderable page);",
1523
- `then, if no downstream stage has consumed it, remove the Design Source Package this blocked run emitted at ${DESIGN_SOURCE_PACKAGE_REL_PATH}`,
1596
+ `then, if no downstream stage has consumed it, remove the Design Source Package this blocked run emitted at ${packagePath}`,
1524
1597
  "and rerun prepare-build/start.",
1525
1598
  'See "Clearing DESIGN_SOURCE_PACKAGE_NOT_READY" in docs/design-source-package.md.',
1526
1599
  ].join(" ");
@@ -1531,12 +1604,13 @@ const DESIGN_SOURCE_PACKAGE_REMEDY = [
1531
1604
  // exception reasons keep their own wording.
1532
1605
  const DESIGN_SOURCE_PACKAGE_REMEDY_REASON = /source_screenshot proof\.$|^Source TODO "capture-(?![^"]*-template-)[^"]*" is /;
1533
1606
 
1534
- function designSourcePackageBlockers(prepared) {
1607
+ function designSourcePackageBlockers(prepared, targetRepo) {
1535
1608
  if (!["blocked", "pending"].includes(prepared.value?.readiness?.status)) return [];
1609
+ const remedy = designSourcePackageRemedy(prepared.path && targetRepo ? relFromDir(targetRepo, prepared.path) : undefined);
1536
1610
  return (prepared.value.readiness.blocking_reasons || []).map((message, index) => ({
1537
1611
  code: "DESIGN_SOURCE_PACKAGE_NOT_READY",
1538
1612
  stage: "prepare_build",
1539
- message: DESIGN_SOURCE_PACKAGE_REMEDY_REASON.test(message) ? `${message} ${DESIGN_SOURCE_PACKAGE_REMEDY}` : message,
1613
+ message: DESIGN_SOURCE_PACKAGE_REMEDY_REASON.test(message) ? `${message} ${remedy}` : message,
1540
1614
  detail: {
1541
1615
  blocker_index: index,
1542
1616
  readiness_status: prepared.value.readiness.status,
@@ -1641,18 +1715,44 @@ async function prepareBuild(args, options = {}) {
1641
1715
  if (publishSpec && !existsSync(targetRepo)) mkdirSync(targetRepo, { recursive: true });
1642
1716
  if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) throw new Error(`Target repo is not a directory: ${targetRepo}`);
1643
1717
 
1644
- const sidecars = campaignSidecarPaths(targetRepo);
1645
- const packetPath = resolve(args.out || join(targetRepo, "campaign-runtime.build.json"));
1718
+ // Which funnel this is decides where its packet and records go (#719): a
1719
+ // project's first funnel uses the default packet and the project-level
1720
+ // .campaign-runtime/, every later one its own (planFunnelWorkspace). The
1721
+ // route is read here, before the lock, and checked again under it.
1722
+ const plannedRoute = intakeRouteSlug(options.fetchedSpec || readSpecForRoute(specPath), args);
1723
+ const planFunnel = () => plannedRoute
1724
+ ? refusing(() => planFunnelWorkspace(targetRepo, plannedRoute, { packetPath: args.out || null, force: args.force === true }))
1725
+ : { packetPath: resolve(args.out || join(targetRepo, DEFAULT_PACKET_FILENAME)), runtimeRelPath: RUNTIME_DIR_REL_PATH, scoped: false, addedBeside: null };
1726
+ const funnel = planFunnel();
1727
+ const sidecars = campaignSidecarPaths(targetRepo, funnel.runtimeRelPath);
1728
+ const packetPath = funnel.packetPath;
1646
1729
  const contextPath = resolve(args["context-out"] || sidecars.contextPath);
1647
1730
  const reportPath = resolve(args["report-out"] || sidecars.reportPath);
1648
1731
  const doctorOutPath = resolve(args["doctor-out"] || sidecars.doctorOutPath);
1649
- const briefPath = resolve(args["brief-out"] || join(targetRepo, BUILD_BRIEF_NORMALIZED_REL_PATH));
1732
+ const briefPath = resolve(args["brief-out"] || join(sidecars.runtimeDir, relative(RUNTIME_DIR_REL_PATH, BUILD_BRIEF_NORMALIZED_REL_PATH)));
1650
1733
  return withDesignSourcePublication({
1651
1734
  targetRepo,
1735
+ runtimeDir: sidecars.runtimeDir,
1652
1736
  outputs: { packetPath, contextPath, reportPath, doctorOutPath, briefPath },
1653
1737
  force: args.force === true,
1654
1738
  publishSpec,
1655
- }, (publication) => prepareBuildUnderLock({ args, options, specPath, sourceRoot, targetRepo, publication }));
1739
+ }, (publication) => prepareBuildUnderLock({ args, options, specPath, sourceRoot, targetRepo, publication, funnel: { ...funnel, route: plannedRoute }, planFunnel }));
1740
+ }
1741
+
1742
+ // A spec that cannot be read here is read again, and refused, under the lock.
1743
+ function readSpecForRoute(specPath) {
1744
+ try {
1745
+ return readJson(specPath);
1746
+ } catch {
1747
+ return null;
1748
+ }
1749
+ }
1750
+
1751
+ // The public route slug intake will use, from the flag or the spec; null when
1752
+ // neither names one (prepare-build then refuses under the lock).
1753
+ function intakeRouteSlug(spec, args) {
1754
+ if (!isObject(spec)) return optionalString(args["public-route-slug"]) || null;
1755
+ return normalizePublicRouteSlug(campaignIdentity(spec, args).publicRouteSlug) || null;
1656
1756
  }
1657
1757
 
1658
1758
  function prepareBuildUnderLock({
@@ -1662,6 +1762,8 @@ function prepareBuildUnderLock({
1662
1762
  sourceRoot,
1663
1763
  targetRepo,
1664
1764
  publication,
1765
+ funnel,
1766
+ planFunnel,
1665
1767
  }) {
1666
1768
  const { packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath } = publication.paths;
1667
1769
  // #531: a host-prefixed route ("shop.example.com/route/upsell/") in a Map
@@ -1685,6 +1787,22 @@ function prepareBuildUnderLock({
1685
1787
  throw new Error("CampaignSpec requires exactly one identity: a saved spec_identity.map_id, or an agent-authored spec_identity.local_spec_id (1–64 letters, digits, underscores or hyphens). Keep the local ID stable across revisions; do not invent a Map ID.");
1686
1788
  }
1687
1789
  if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Set spec_identity.public_route_slug or campaign.slug.");
1790
+ // The packet and runtime directory were chosen for the route read before
1791
+ // the lock; a spec that changed meanwhile would file this funnel's records
1792
+ // under another route's.
1793
+ if (normalizePublicRouteSlug(publicRouteSlug) !== funnel.route) {
1794
+ throw new Error(`The CampaignSpec's public route slug changed while prepare-build waited for the target lock (${JSON.stringify(funnel.route)} became ${JSON.stringify(publicRouteSlug)}). Re-run prepare-build.`);
1795
+ }
1796
+ // Another funnel set up while this run waited can change where this one
1797
+ // belongs (two first funnels started at once in an empty project). The
1798
+ // paths this run writes were fixed before the lock, so it stops instead.
1799
+ const replanned = planFunnel();
1800
+ if (replanned.packetPath !== funnel.packetPath || replanned.runtimeRelPath !== funnel.runtimeRelPath) {
1801
+ throw new Error(
1802
+ `Another funnel was set up in ${targetRepo} while prepare-build waited for the target lock, so route ${JSON.stringify(funnel.route)} now belongs at `
1803
+ + `${replanned.packetPath} with its records under ${replanned.runtimeRelPath}/, not ${funnel.packetPath} and ${funnel.runtimeRelPath}/. Re-run prepare-build.`,
1804
+ );
1805
+ }
1688
1806
 
1689
1807
  // Dispatch validated the argv-only flags before spec resolution, including
1690
1808
  // the value forms of --template-family, --allow-uncertified-template,
@@ -1845,6 +1963,7 @@ function prepareBuildUnderLock({
1845
1963
  // campaign's report), and the previous brief carries field provenance.
1846
1964
  const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
1847
1965
  const buildBrief = createCampaignBuildBriefArtifact({
1966
+ designFiles: resolveDesignFileReferences({ activePages, mappings: matched.mappings, manifest: manifestResult.manifest }).pages,
1848
1967
  inputPath: briefDiscovery?.path || null,
1849
1968
  inputSource: briefDiscovery?.source || null,
1850
1969
  spec,
@@ -1915,7 +2034,7 @@ function prepareBuildUnderLock({
1915
2034
  mapId,
1916
2035
  publicRouteSlug,
1917
2036
  });
1918
- const designSourceBlockers = designSourcePackageBlockers(designSourcePackage);
2037
+ const designSourceBlockers = designSourcePackageBlockers(designSourcePackage, targetRepo);
1919
2038
  const blockers = [...sourceBlockers, ...briefBlockers, ...briefQuestionBlockers, ...designSourceBlockers];
1920
2039
  const adapterDecisions = createAdapterDecisions({ commerceZoneFindings, wrapperPolicy: wrapperPolicy.value });
1921
2040
  const proofPolicy = createProofPolicy({ orderPathDepth: orderPathDepthFlag });
@@ -1960,6 +2079,9 @@ function prepareBuildUnderLock({
1960
2079
  assembly: {
1961
2080
  implementation: "next-campaigns-build",
1962
2081
  target_repo: relFromFile(packetPath, targetRepo),
2082
+ // Recorded only for a funnel that is not the project's first; absent,
2083
+ // the records are under the project-level .campaign-runtime/.
2084
+ ...(funnel.scoped ? { runtime_dir: funnel.runtimeRelPath } : {}),
1963
2085
  output_dir: outputDir,
1964
2086
  template_family: isUnresolvedTemplateFamily(templateFamily) ? "undecided" : templateFamily,
1965
2087
  template_decision_notes: templateLocked
@@ -2033,6 +2155,7 @@ function prepareBuildUnderLock({
2033
2155
  path: portable(specPath),
2034
2156
  hash: specFileHash,
2035
2157
  material_hash: specMaterialHash(spec),
2158
+ route_masked_material_hash: routeMaskedSpecMaterialHash(spec),
2036
2159
  active_pages: activePages.map((page) => ({
2037
2160
  id: page.id,
2038
2161
  type: page.type || null,
@@ -2085,7 +2208,7 @@ function prepareBuildUnderLock({
2085
2208
  target_repo: ".",
2086
2209
  output_dir: portable(resolve(targetRepo, outputDir)),
2087
2210
  handoff_skill: existsSync(resolve(targetRepo, outputDir)) ? "next-campaigns-build" : "next-campaigns-os-setup",
2088
- handoff_artifact: ".campaign-runtime/setup-handoff.json",
2211
+ handoff_artifact: `${funnel.runtimeRelPath}/setup-handoff.json`,
2089
2212
  reason: existsSync(resolve(targetRepo, outputDir))
2090
2213
  ? "Target campaign output directory already exists."
2091
2214
  : "Target campaign output directory is missing; scaffold before build.",
@@ -2205,6 +2328,7 @@ function prepareBuildUnderLock({
2205
2328
  context,
2206
2329
  report,
2207
2330
  doctor,
2331
+ ...(funnel.addedBeside ? { funnelAddedBeside: funnel.addedBeside } : {}),
2208
2332
  };
2209
2333
  }
2210
2334
 
@@ -2285,7 +2409,7 @@ function assemblyThemeFromContext(theme) {
2285
2409
  load_order: "unknown",
2286
2410
  commerce_pages: [],
2287
2411
  evidence: wroteCss
2288
- ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact into campaign assets and load it after next-core.css on commerce pages."]
2412
+ ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact (and brand-fonts.css, when written) into campaign assets and load it after next-core.css on commerce pages."]
2289
2413
  : theme.generated?.can_generate
2290
2414
  ? ["theme inspect found a generatable brand theme; run theme generate or explicit auto policy before applying."]
2291
2415
  : ["theme inspect completed; no generated brand theme was applied during prepare-build."],
@@ -2488,11 +2612,8 @@ function rejectUnknownStandardizeFlags(args) {
2488
2612
  const known = new Set(STANDARDIZE_FLAGS);
2489
2613
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
2490
2614
  if (!unknown.length) return;
2491
- const valueHint = unknown.some((key) => key.includes("="))
2492
- ? " A flag takes its value as the next argument (--flag value), not --flag=value."
2493
- : "";
2494
2615
  throw refused(
2495
- `Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
2616
+ `Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
2496
2617
  );
2497
2618
  }
2498
2619
 
@@ -2584,7 +2705,9 @@ function themeCommand(args) {
2584
2705
  // (blocked, already-current CSS) leaves doctor's inputs untouched (Kilo
2585
2706
  // review, PR #176).
2586
2707
  if (written.wrote?.report || written.wrote?.css) {
2587
- markDoctorSidecarStale(resolveFromFile(packetPath, packet?.assembly?.target_repo) || dirname(packetPath), {
2708
+ const staleTarget = resolveFromFile(packetPath, packet?.assembly?.target_repo) || dirname(packetPath);
2709
+ markDoctorSidecarStale(staleTarget, {
2710
+ path: campaignSidecarPaths(staleTarget, packetRuntimeRelPath(packet)).doctorOutPath,
2588
2711
  command: "theme generate",
2589
2712
  reason: `Theme artifacts were generated after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
2590
2713
  });
@@ -2805,6 +2928,8 @@ export async function polishCaptureCommand(args, options = {}) {
2805
2928
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2806
2929
  probeClock: options.probeClock,
2807
2930
  targetRepo,
2931
+ visual: { packetPath, targetRepo, ...options.visual },
2932
+ onFrozen: options.onFrozen,
2808
2933
  });
2809
2934
  // Nothing was measured: the held plan error stands.
2810
2935
  if (heldPlanError && !capture.readability) throw heldPlanError;
@@ -2834,6 +2959,8 @@ export async function polishCaptureCommand(args, options = {}) {
2834
2959
  pageLoad: capture.page_load,
2835
2960
  mediaWeight: capture.media_weight,
2836
2961
  readability: capture.readability,
2962
+ captures: capture.captures,
2963
+ elementParity: capture.element_parity,
2837
2964
  now: options.now ?? new Date(),
2838
2965
  });
2839
2966
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
@@ -2879,6 +3006,15 @@ export async function polishCaptureCommand(args, options = {}) {
2879
3006
  route_scope: capture.plan?.route_scope ?? null,
2880
3007
  routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
2881
3008
  viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
3009
+ visual: capture.captures ? { cells: capture.captures.cells.length,
3010
+ rendered: capture.captures.cells.filter((cell) => cell.status === "rendered").length,
3011
+ comparisons_compared: capture.captures.comparisons.filter((comparison) => comparison.status === "compared").length,
3012
+ comparisons_unsupported: capture.captures.comparisons.filter((comparison) => comparison.status === "unsupported").length,
3013
+ dependencies: capture.captures.dependencies, adapters: capture.captures.adapters,
3014
+ // A run budget spent: a cell never rendered, or a decode or diff never ran (§2.4).
3015
+ budget_exhausted: capture.captures.cells.some((cell) => cell.status === "run_budget_exhausted")
3016
+ || capture.captures.references.some((reference) => reference.verification?.reason === "verify_budget_exhausted")
3017
+ || capture.captures.comparisons.some((comparison) => comparison.reason === "diff_budget_exhausted") } : null,
2882
3018
  },
2883
3019
  };
2884
3020
  }
@@ -3256,8 +3392,7 @@ export function pageKitSyncCommand(args) {
3256
3392
  // Assembly Report whose waivers are honoured below.
3257
3393
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
3258
3394
  if (unknown.length) {
3259
- const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
3260
- throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit sync: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${PAGE_KIT_SYNC_FLAGS.map((key) => `--${key}`).join(", ")}.`);
3395
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit sync: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${PAGE_KIT_SYNC_FLAGS.map((key) => `--${key}`).join(", ")}.`);
3261
3396
  }
3262
3397
  const packetPath = resolve(requireArg(args, "packet"));
3263
3398
  const dryRun = isDryRun(args);
@@ -3334,11 +3469,7 @@ export function pageKitSyncCommand(args) {
3334
3469
  // spec.local_path would write another campaign's store profile into this
3335
3470
  // route with a plausible-looking diff.
3336
3471
  if (spec && publicRouteSlug) {
3337
- const specSlug = normalizePublicRouteSlug(
3338
- optionalString(spec.spec_identity?.public_route_slug)
3339
- || optionalString(spec.campaign?.slug)
3340
- || optionalString(spec.campaign?.id),
3341
- );
3472
+ const specSlug = specPublicRouteSlug(spec);
3342
3473
  const specMapId = optionalString(spec.spec_identity?.map_id) || optionalString(spec.map_id);
3343
3474
  const packetMapId = optionalString(packet.spec?.map_id);
3344
3475
  if (specSlug && specSlug !== publicRouteSlug) {
@@ -3464,6 +3595,7 @@ export function pageKitSyncCommand(args) {
3464
3595
  // result, never a thrown error that hides the write.
3465
3596
  try {
3466
3597
  markDoctorSidecarStale(targetRepo, {
3598
+ path: campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).doctorOutPath,
3467
3599
  command: "page-kit sync",
3468
3600
  reason: `page-kit sync rewrote ${PAGE_KIT_CAMPAIGNS_REL_PATH}[${publicRouteSlug}] after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3469
3601
  });
@@ -3494,7 +3626,7 @@ export function pageKitSyncCommand(args) {
3494
3626
  // the default run stays offline. --dry-run prints the same diff and writes
3495
3627
  // nothing. Exit 2 when the packet, the spec or the target entry is missing,
3496
3628
  // or the store cannot be read.
3497
- const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report", "from-store", "store-token-source"]);
3629
+ const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report", "from-store", "store-token-source", "no-live-refs"]);
3498
3630
 
3499
3631
  // The store flags, parsed once for both the async reader and the sync
3500
3632
  // command: `{ subdomain, token_env }` when --from-store is given, null when
@@ -3522,9 +3654,9 @@ function parseSpecDeriveStoreFlags(args) {
3522
3654
  // run the same command with the store read in hand. The only network the
3523
3655
  // command ever does happens here, and the token never leaves this function:
3524
3656
  // the result names the credential source, never its value.
3525
- export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn } = {}) {
3657
+ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn, liveCampaign = undefined } = {}) {
3526
3658
  const store = parseSpecDeriveStoreFlags(args);
3527
- if (!store) return specDeriveCommand(args);
3659
+ if (!store) return specDeriveCommand(args, { liveCampaign });
3528
3660
  const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
3529
3661
  if (preflight.errors.length) return preflight;
3530
3662
  let read;
@@ -3537,7 +3669,36 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
3537
3669
  read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
3538
3670
  }
3539
3671
  // Recheck the original packet/spec identity after the network operation.
3540
- return specDeriveCommand(args, { store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
3672
+ return specDeriveCommand(args, { liveCampaign, store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
3673
+ }
3674
+
3675
+ // The live campaign read `spec derive` makes (#721): the one read-only GET of
3676
+ // {proxy-base}/api/campaign doctor makes, under the same rule (packet mode,
3677
+ // the packet's built _site/<route>/ exists, a public Campaigns API key
3678
+ // resolves), with the campaign body kept so the mirrored offer and package
3679
+ // fields can be refreshed from it. --no-live-refs sends nothing. `undefined`
3680
+ // means no read was due.
3681
+ export async function readSpecDeriveLiveCampaign(args, { fetchImpl = globalThis.fetch, env = process.env, warn = undefined } = {}) {
3682
+ if (liveRefsDisabled(args)) return disabledLiveRead();
3683
+ if (!isNonEmptyString(args.packet)) return undefined;
3684
+ let workspace;
3685
+ try {
3686
+ workspace = resolveCampaignWorkspace(resolve(args.packet), { followContextPointer: false });
3687
+ } catch {
3688
+ // An unreadable packet is the derive's own error; there is nothing to read for.
3689
+ return undefined;
3690
+ }
3691
+ const slug = normalizePublicRouteSlug(workspace.packet?.campaign?.public_route_slug);
3692
+ if (!workspace.targetRepo || !slug || !existsSync(join(workspace.targetRepo, "_site", slug))) return undefined;
3693
+ return readLiveCampaignForPacket({
3694
+ packet: workspace.packet,
3695
+ packetPath: workspace.packetPath,
3696
+ env,
3697
+ fetchImpl,
3698
+ proxyBase: optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
3699
+ includeCampaign: true,
3700
+ ...(warn ? { warn } : {}),
3701
+ });
3541
3702
  }
3542
3703
 
3543
3704
  // The page files page-kit renders under the campaign's source directory, with
@@ -3565,30 +3726,32 @@ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored =
3565
3726
  // page-kit honours a permalink before its filename rule, so the
3566
3727
  // frontmatter is read first (quoting, comments, BOM and CRLF as
3567
3728
  // page-kit's reader takes them); only a file with no usable permalink
3568
- // falls back to the filename route.
3569
- const permalink = frontmatterPermalink(readFileSync(fullPath, "utf8"));
3729
+ // falls back to the filename route. The page_type and the links to
3730
+ // other pages are read too: after handoff they say which files are
3731
+ // funnel pages and how they link (#721).
3732
+ const { permalink, page_type: pageType, next_url: nextUrl, decline_url: declineUrl, next_step: nextStep, step_number: stepNumber } = frontmatterScalars(readFileSync(fullPath, "utf8"));
3733
+ const facts = { page_type: pageType, next_url: nextUrl, decline_url: declineUrl, next_step: nextStep, step_number: stepNumber };
3570
3734
  if (permalink === null) {
3571
3735
  const filenameRoute = pageRouteForFile(path);
3572
3736
  if (filenameRoute === null) continue;
3573
- files.push({ path, basename: basename(entry.name, ".html"), route: filenameRoute, permalink: null, problem: null });
3737
+ files.push({ path, basename: basename(entry.name, ".html"), route: filenameRoute, permalink: null, problem: null, ...facts });
3574
3738
  continue;
3575
3739
  }
3576
3740
  const resolved = permalinkRoute(permalink, publicRouteSlug);
3577
- files.push({ path, basename: basename(entry.name, ".html"), route: resolved.route ?? null, permalink, problem: resolved.problem ?? null });
3741
+ files.push({ path, basename: basename(entry.name, ".html"), route: resolved.route ?? null, permalink, problem: resolved.problem ?? null, ...facts });
3578
3742
  }
3579
3743
  };
3580
3744
  walk(outputDir);
3581
3745
  return files.sort((a, b) => a.path.localeCompare(b.path));
3582
3746
  }
3583
3747
 
3584
- export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3748
+ export function specDeriveCommand(args, { store: storeRead = null, liveCampaign = undefined } = {}) {
3585
3749
  // A command that rewrites the spec rejects flags it does not know: a
3586
3750
  // mistyped --dryrun must not fall through to a real write. --report names
3587
3751
  // the Assembly Report whose waivers are honoured below.
3588
3752
  const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
3589
3753
  if (unknown.length) {
3590
- const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
3591
- throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec derive: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${SPEC_DERIVE_FLAGS.map((key) => `--${key}`).join(", ")}.`);
3754
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec derive: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${SPEC_DERIVE_FLAGS.map((key) => `--${key}`).join(", ")}.`);
3592
3755
  }
3593
3756
  // The store read is supplied by specDeriveFromStoreCommand; this function
3594
3757
  // never touches the network itself, so a --from-store call that reaches it
@@ -3616,6 +3779,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3616
3779
  not_derived: [],
3617
3780
  not_in_target: [],
3618
3781
  stale_hints: [],
3782
+ page_scope: null,
3783
+ live_campaign: null,
3619
3784
  store: storeFlags
3620
3785
  ? { 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 }
3621
3786
  : null,
@@ -3679,11 +3844,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3679
3844
  // (campaign.route_slug_identity); without it a stale or copy-pasted
3680
3845
  // spec.local_path would receive another campaign's routes and pin.
3681
3846
  if (spec && publicRouteSlug) {
3682
- const specSlug = normalizePublicRouteSlug(
3683
- optionalString(spec.spec_identity?.public_route_slug)
3684
- || optionalString(spec.campaign?.slug)
3685
- || optionalString(spec.campaign?.id),
3686
- );
3847
+ const specSlug = specPublicRouteSlug(spec);
3687
3848
  const specMapId = optionalString(spec.spec_identity?.map_id) || optionalString(spec.map_id);
3688
3849
  const packetMapId = optionalString(packet.spec?.map_id);
3689
3850
  if (specSlug && specSlug !== publicRouteSlug) {
@@ -3806,7 +3967,24 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3806
3967
  addIssue(result.errors, `spec.derive.${codes[storeRead.status] || "store_unreachable"}`, `${storeRead.detail} Nothing was written.`, { subdomain: storeRead.subdomain, token_source: storeRead.token_env ? `env:${storeRead.token_env}` : "gateway:login" });
3807
3968
  return result;
3808
3969
  }
3809
- const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug });
3970
+ // After handoff the page tree also says which pages exist (#721).
3971
+ const handedOff = campaignHandedOff(report);
3972
+ const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug, handedOff });
3973
+ result.page_scope = plan.page_scope;
3974
+ // The mirrored offer and package fields, from the live campaign read
3975
+ // (#721). A read that did not happen leaves them as they are and says why.
3976
+ if (liveCampaign?.status === "read" && isObject(liveCampaign.campaign)) {
3977
+ const mirror = planCampaignMirrorDerive({ spec, campaign: liveCampaign.campaign, campaignRef: liveCampaign.campaign_ref_id || null });
3978
+ plan.changes.push(...mirror.changes);
3979
+ plan.unchanged.push(...mirror.unchanged);
3980
+ plan.not_derived.push(...mirror.not_derived);
3981
+ result.live_campaign = { status: "read", key_source: liveCampaign.key_source || null, campaign_ref_id: liveCampaign.campaign_ref_id || null, ...mirror.summary };
3982
+ } else if (liveCampaign) {
3983
+ result.live_campaign = { status: "not_run", reason_code: liveCampaign.reason_code || null, reason: liveCampaign.reason || null, key_source: liveCampaign.key_source || null };
3984
+ if (!["disabled", "no_key", "not_read"].includes(liveCampaign.reason_code)) {
3985
+ addIssue(result.warnings, "spec.derive.live_campaign_not_read", `The live campaign was not read (${singleLineDetail(liveCampaign.reason || liveCampaign.reason_code || "unknown reason")}); the spec's offer and package fields were left as they are.`, { reason_code: liveCampaign.reason_code || null });
3986
+ }
3987
+ }
3810
3988
  let storeChanged = false;
3811
3989
  if (storeRead) {
3812
3990
  const storePlan = planStoreProfileDerive({ spec, store: storeRead.store, pages: storeRead.pages, pagesStatus: storeRead.pages_status, pagesDetail: storeRead.pages_detail, subdomain: storeRead.subdomain });
@@ -3830,11 +4008,18 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3830
4008
  addIssue(result.warnings, `spec.derive.${row.reason}`, `${row.field} was not derived: ${row.detail}`, { field: row.field, reason: row.reason, ...(row.page_id ? { page_id: row.page_id } : {}) });
3831
4009
  }
3832
4010
  for (const hint of plan.stale_hints) {
3833
- addIssue(result.warnings, "spec.derive.routing_hint_stale", `page "${hint.page_id}" carries sdk_hints.meta_tags.${hint.tag} ${JSON.stringify(hint.value)}, but page "${hint.target_page_id}" now derives to ${JSON.stringify(hint.derived_route)}. Routing hints are a Map projection derive does not rewrite; re-save the Map (or edit the hint) so the built meta tag and doctor's expectation agree.`, hint);
4011
+ addIssue(result.warnings, "spec.derive.routing_hint_stale", `page "${hint.page_id}" carries sdk_hints.meta_tags.${hint.tag} ${JSON.stringify(hint.value)}, but page "${hint.target_page_id}" now derives to ${JSON.stringify(hint.derived_route)}. Derive rewrites a hint only on an edge it re-points after handoff; edit the hint to that route in the spec (or, while the Map is still the spec's source, re-save the Map) so the built meta tag and doctor's expectation agree. Doctor blocks and record spec refuses until then.`, hint);
3834
4012
  }
3835
4013
  for (const block of plan.created_blocks || []) {
3836
4014
  addIssue(result.warnings, "spec.derive.analytics_block_created", `${block} ${dryRun ? "would be" : "is"} created from the repo's id: the spec ${dryRun ? "would then declare" : "now declares"} an analytics contract, so QA expects that tag to fire instead of treating analytics as advisory.`);
3837
4015
  }
4016
+ // A vendor block changes what doctor reads; it turns on QA's analytics leg
4017
+ // only when it is the first analytics the spec carries (a provider block
4018
+ // created beside it already says so).
4019
+ const vendorOpensAnalytics = plan.analytics_created === true && !(plan.created_blocks || []).length;
4020
+ for (const block of plan.created_vendor_blocks || []) {
4021
+ addIssue(result.warnings, "spec.derive.analytics_vendor_created", `${block} ${dryRun ? "would be" : "is"} created from the repo's id: doctor ${dryRun ? "would then read" : "now reads"} that vendor's campaigns.json key against the spec${vendorOpensAnalytics ? `, and the spec ${dryRun ? "would then carry" : "now carries"} an analytics block, so QA runs its analytics leg and expects the typed-card order to fire a Purchase` : ""}.`);
4022
+ }
3838
4023
 
3839
4024
  // What a write entails is said in both modes, so a dry run previews the
3840
4025
  // warnings a real run would carry: a lossy round trip, a build now stale,
@@ -3848,8 +4033,9 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3848
4033
  if (serialize(spec) !== text) {
3849
4034
  addIssue(result.warnings, "spec.derive.file_reformatted", `${result.spec_path} ${would} re-serialized with ${indent === "\t" ? "tab" : `${indent.length}-space`} indentation; formatting outside the derived fields (key order, whitespace, number spelling) may differ from the original. Review the file diff before committing.`);
3850
4035
  }
3851
- if (plan.changes.some((row) => row.page_id)) {
3852
- 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.`);
4036
+ if (plan.changes.some((row) => row.page_id && (row.op || /\.page_url$/.test(row.field)))) {
4037
+ const pageSetMoved = plan.changes.some((row) => row.op && row.page_id);
4038
+ addIssue(result.warnings, "spec.derive.projection_stale", `${pageSetMoved ? "A page added or removed" : "A page route moved"} ${dryRun ? "would leave" : "leaves"} the packet's page-kit projection (source_html.pages[].page_kit) and the Build Context describing the old pages. Bind it with ${cmd("record")} spec, which re-projects them for a routes-only change and, on a handed-off campaign, for pages added or removed; any other page-scope change needs ${cmd("prepare-build")}.`);
3853
4039
  }
3854
4040
  if (stageIsTerminal(report?.stages?.assembly?.status)) {
3855
4041
  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.`);
@@ -3901,6 +4087,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3901
4087
  // The retained doctor snapshot (if any) now predates the spec it judged.
3902
4088
  try {
3903
4089
  markDoctorSidecarStale(targetRepo, {
4090
+ path: campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).doctorOutPath,
3904
4091
  command: "spec derive",
3905
4092
  reason: `spec derive rewrote the CampaignSpec after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3906
4093
  });
@@ -3951,6 +4138,14 @@ export function specDeriveTextLines(result) {
3951
4138
  }
3952
4139
  if (result.unchanged?.length) lines.push(`Unchanged: ${result.unchanged.map((row) => row.field).join(", ")}`);
3953
4140
  if (result.not_in_target?.length) lines.push(`Not in target (left as they are): ${result.not_in_target.join(", ")}`);
4141
+ if (result.page_scope?.added?.length) lines.push(`Pages added from the page tree: ${result.page_scope.added.map((page) => `${singleLineField(page.page_id)} (${singleLineField(page.file)})`).join(", ")}`);
4142
+ if (result.page_scope?.removed?.length) lines.push(`Pages removed (their built file is gone): ${result.page_scope.removed.map((page) => `${singleLineField(page.page_id)} (${singleLineField(page.file)})`).join(", ")}`);
4143
+ if (result.page_scope?.unclaimed?.length) lines.push(`Page files no spec page binds (pages are added from the tree only after handoff): ${result.page_scope.unclaimed.map(singleLineField).join(", ")}`);
4144
+ if (result.live_campaign) {
4145
+ lines.push(result.live_campaign.status === "read"
4146
+ ? `Live campaign: read${result.live_campaign.campaign_ref_id ? ` (campaign ${singleLineField(result.live_campaign.campaign_ref_id)})` : ""}; offer and package fields refreshed from it`
4147
+ : `Live campaign: not read (${singleLineField(result.live_campaign.reason_code || "unknown")}); offer and package fields left as they are`);
4148
+ }
3954
4149
  if (result.warnings?.length) {
3955
4150
  lines.push("Warnings:");
3956
4151
  for (const issue of result.warnings) lines.push(`- ${formatIssueSummary(issue)}`);
@@ -4349,7 +4544,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4349
4544
  let runRecordCloseout = null;
4350
4545
  try {
4351
4546
  runRecordCloseout = assessRunRecordCloseout({
4352
- records: readRunRecordsForTarget(dirname(packetPath)),
4547
+ records: readRunRecordsForTarget(dirname(packetPath), packetRuntimeRelPath(packet)),
4353
4548
  packet,
4354
4549
  report,
4355
4550
  currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null, targetRepo),
@@ -4375,7 +4570,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4375
4570
  // command names the report this run read when that is not the default
4376
4571
  // one (from --report or the Build Context pointer), so it records there.
4377
4572
  const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4378
- result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
4573
+ result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo, packetRuntimeRelPath(packet)) });
4379
4574
  // One note per operator decision that kept the build output unchanged
4380
4575
  // after an input change, quoting the operator's reason.
4381
4576
  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}"`);
@@ -5053,14 +5248,15 @@ function templateStockPromptLine(packet, derived = {}) {
5053
5248
 
5054
5249
  function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
5055
5250
  const briefPath = packet.build_brief?.normalized_path || "(missing; generate or confirm Campaign Build Brief before business-sensitive assembly)";
5251
+ const runtime = runtimeRelPathOf(packet);
5056
5252
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
5057
5253
 
5058
5254
  Read first:
5059
5255
  - Build Packet: ${packetPath}
5060
- - Build Context: ${contextPath || "(use packet-adjacent .campaign-runtime/build-context.json if present)"}
5061
- - Assembly Report: ${reportPath || "(use packet-adjacent .campaign-runtime/assembly-report.json if present)"}
5256
+ - Build Context: ${contextPath || `(use packet-adjacent ${runtime}/build-context.json if present)`}
5257
+ - Assembly Report: ${reportPath || `(use packet-adjacent ${runtime}/assembly-report.json if present)`}
5062
5258
  - Campaign Build Brief: ${briefPath}
5063
- - Design Source Package: .campaign-runtime/input/design-source-package.json when present; use report.design_source_package.material_fingerprint as the source context fingerprint.
5259
+ - Design Source Package: ${runtime}/input/design-source-package.json when present; use report.design_source_package.material_fingerprint as the source context fingerprint.
5064
5260
  - Template family: ${packet.assembly.template_family}${templateStockPromptLine(packet, derived)}
5065
5261
 
5066
5262
  Rules:
@@ -5071,7 +5267,7 @@ Rules:
5071
5267
  - 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.
5072
5268
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
5073
5269
  - For checkout/upsell/downsell/receipt, use the starter template as the SDK contract reference only: preserve required data-next controls and runtime wiring, but let the campaign/source own visual chrome, copy hierarchy, imagery, and brand layer.
5074
- - Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5270
+ - Read context.theme and ${runtime}/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; when theme generate also wrote brand-fonts.css (the design's own faces), copy it beside brand-theme.css and list it on the same pages; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5075
5271
  - Generated brand-theme.css v0 is root-variable-only. Do not edit SDK-owned selectors, package controls, payment fields, totals, submit controls, receipt templates, route meta tags, or SDK JavaScript as part of theme application.
5076
5272
  - If you copy starter-template files, copy the selected family atomically with dependent pages, _includes, _layouts, assets/css, and assets/js; do not copy only checkout.html and receipt.html.
5077
5273
  - Resolve SDK routing meta tags to campaign-root paths such as ${campaignRouteRoot(packet) || `/${packet.campaign.public_route_slug}/`}upsell/, not source filenames or unrooted spec literals.
@@ -5143,7 +5339,7 @@ Read first:
5143
5339
 
5144
5340
  Prepare the target page-kit structure and agent context, then record setup:
5145
5341
  - ${cmd("record")} setup --packet ${packetPath}
5146
- It sets .campaign-runtime/build-context.json scaffold.required=false and .campaign-runtime/assembly-report.json stages.setup to completed, validated against their schemas; do not hand-edit either file.
5342
+ It sets the Build Context's scaffold.required=false and the Assembly Report's stages.setup to completed, validated against their schemas; do not hand-edit either file.
5147
5343
 
5148
5344
  When copying a starter template family, copy the family as an atomic page-kit slice: pages plus required _includes, _layouts, assets/css, and assets/js. Do not copy only checkout.html and receipt.html.
5149
5345
 
@@ -5466,10 +5662,13 @@ function installAgentContext(targetRepo, dryRun = false) {
5466
5662
  ["copilot-instructions.md", join(ROOT, "agents/copilot/copilot-instructions.md")],
5467
5663
  ];
5468
5664
  const written = [];
5665
+ const fileContents = [];
5469
5666
  if (!dryRun) mkdirSync(outDir, { recursive: true });
5470
5667
  for (const [name, source] of files) {
5471
5668
  const dest = join(outDir, name);
5472
- if (!dryRun) writeFileSync(dest, readFileSync(source, "utf8"));
5669
+ const content = readFileSync(source, "utf8");
5670
+ if (!dryRun) writeFileSync(dest, content);
5671
+ else fileContents.push({ destination: dest, content });
5473
5672
  written.push(dest);
5474
5673
  }
5475
5674
  const gitignore = ensureRuntimeStateIgnored(targetRepo, { dryRun });
@@ -5479,6 +5678,7 @@ function installAgentContext(targetRepo, dryRun = false) {
5479
5678
  target_repo: targetRepo,
5480
5679
  directory: outDir,
5481
5680
  files: written,
5681
+ ...(dryRun ? { file_contents: fileContents } : {}),
5482
5682
  gitignore,
5483
5683
  note: "Context files are staged under .campaign-runtime/agent-context and do not overwrite root agent files.",
5484
5684
  };
@@ -5841,11 +6041,22 @@ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
5841
6041
  // An explicit packet names its project: its target repo, whatever the cwd.
5842
6042
  const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
5843
6043
  const packageJsonPath = nearestPackageJson(projectStart);
6044
+ let packetUnread = false;
5844
6045
  if (!packetPath) {
5845
6046
  // The contracted home of the packet is the project root beside package.json
5846
6047
  // (prepare-build's default --out), the same place readback discovers it.
5847
- packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
5848
- if (existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
6048
+ // A project holding several funnels has one packet each; reading any one
6049
+ // of them would be a guess, so none is read and the warning names them.
6050
+ const projectRoot = packageJsonPath ? dirname(packageJsonPath) : resolve(cwd);
6051
+ packetPath = join(projectRoot, DEFAULT_PACKET_FILENAME);
6052
+ try {
6053
+ packetPath = soleProjectPacket(projectRoot, { command: "tooling status" }) || packetPath;
6054
+ } catch (error) {
6055
+ if (error?.code !== "MULTIPLE_FUNNEL_PACKETS") throw error;
6056
+ packetUnread = true;
6057
+ warnings.push(`Packet pin not read: ${error.message}`);
6058
+ }
6059
+ if (!packetUnread && existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
5849
6060
  }
5850
6061
 
5851
6062
  const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
@@ -5862,7 +6073,7 @@ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
5862
6073
  packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
5863
6074
  warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
5864
6075
  }
5865
- } else if (existsSync(packetPath) && !packet) {
6076
+ } else if (!packetUnread && existsSync(packetPath) && !packet) {
5866
6077
  warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
5867
6078
  }
5868
6079
  return {
@@ -6080,7 +6291,12 @@ function toolingCommand(args) {
6080
6291
  ? stalePlatforms.map((platform) => ["--platform", platform])
6081
6292
  : [["--platform", args.platform || "all"]];
6082
6293
  const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
6083
- actions.push(`Refresh installed skills: ${commands.join(" and ")}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6294
+ // A per-platform refresh names the harnesses it is for, so a session in
6295
+ // another harness does not read it as its own required step (#683).
6296
+ const forHarnesses = !args.target && skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
6297
+ ? ` (for ${stalePlatforms.map((id) => SKILL_PLATFORMS.find((platform) => platform.id === id)?.label || id).join(" and ")}; not needed in a session that uses another harness)`
6298
+ : "";
6299
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}${forHarnesses}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6084
6300
  }
6085
6301
 
6086
6302
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -7320,7 +7536,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7320
7536
  const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
7321
7537
  const packet = readJson(packetPath);
7322
7538
  const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
7323
- const { baseDir, targetRepo, contextPath, reportPath } = resolveCampaignWorkspace(packetPath, {
7539
+ const { baseDir, runtimeRelPath, targetRepo, contextPath, reportPath } = resolveCampaignWorkspace(packetPath, {
7324
7540
  packet,
7325
7541
  contextPath: args.context ? resolve(args.context) : undefined,
7326
7542
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -7364,7 +7580,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7364
7580
  // session decides without it, and a target with a long history pays no I/O
7365
7581
  // for a decision already made.
7366
7582
  const needsDiskScan = listOnly || (!optionalString(args["run-id"]) && args["new-run"] !== true && !isNonEmptyString(ambient?.session?.run_id));
7367
- const targetRecords = needsDiskScan ? readRunRecordsForTarget(baseDir) : [];
7583
+ const targetRecords = needsDiskScan ? readRunRecordsForTarget(baseDir, runtimeRelPath) : [];
7368
7584
  const latestRecordEntry = needsDiskScan ? latestMatchingRunRecord(targetRecords, packet) : null;
7369
7585
  let runId;
7370
7586
  let runIdSource;
@@ -7505,7 +7721,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7505
7721
  // may not. A pure --no-write run reads nothing: it writes and sends nothing.
7506
7722
  // A --dry-run does read it, because what a real run would do here is the
7507
7723
  // very thing the dry run is being asked to report.
7508
- const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
7724
+ const prior = write || dryRun ? readPriorRunRecord(runId, baseDir, runtimeRelPath) : null;
7509
7725
  const priorRemit = priorRemitOutcome(prior?.record);
7510
7726
  const storedRemotely = priorRemit?.state === "ok";
7511
7727
  // A pure local-inspection run (--no-write), an explicit --no-remit, or a
@@ -7704,7 +7920,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7704
7920
  // carries this run's remit_* outcome rather than the placeholders it was
7705
7921
  // assembled with. Validated above, ahead of the send; nothing is written
7706
7922
  // under --dry-run or --no-write.
7707
- const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
7923
+ const recordPath = write ? writeRunRecord(record, { baseDir, runtimeRelPath }) : null;
7708
7924
 
7709
7925
  const summary = {
7710
7926
  ok: true,
@@ -7736,7 +7952,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7736
7952
  // stopped the send anyway, and null with `would_remit_refused` set when the
7737
7953
  // transport's destination gate refuses the base before any socket opens).
7738
7954
  // Envelope only; never on the record.
7739
- ...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
7955
+ ...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir, runtimeRelPath), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
7740
7956
  };
7741
7957
  if (silent) return summary;
7742
7958
  if (args.json) {
@@ -7789,10 +8005,10 @@ function assertRunRecordValid(record) {
7789
8005
  // `writeRunRecord` could have written is trusted as a prior — the same
7790
8006
  // validator gates both — so a file that merely says `remit_state: "ok"` is
7791
8007
  // replaced like a corrupt one, never preserved or handed back as the record.
7792
- function readPriorRunRecord(runId, baseDir) {
8008
+ function readPriorRunRecord(runId, baseDir, runtimeRelPath) {
7793
8009
  let path;
7794
8010
  try {
7795
- path = resolveRunRecordPath(runId, baseDir);
8011
+ path = resolveRunRecordPath(runId, baseDir, runtimeRelPath);
7796
8012
  } catch {
7797
8013
  return null;
7798
8014
  }
@@ -7874,6 +8090,26 @@ function artifactRefPath(kind, filePath, baseDir) {
7874
8090
  return rel.startsWith(".") ? rel : `./${rel}`;
7875
8091
  }
7876
8092
 
8093
+ // The packet's runtime directory for a printed prompt; a packet that records
8094
+ // an unusable one names the project-level directory (doctor refuses it).
8095
+ function runtimeRelPathOf(packet) {
8096
+ try {
8097
+ return packetRuntimeRelPath(packet);
8098
+ } catch {
8099
+ return RUNTIME_DIR_REL_PATH;
8100
+ }
8101
+ }
8102
+
8103
+ // The runtime directory of the packet at `packetPath`, or the project-level
8104
+ // one when it cannot be read (the printed command then carries --report).
8105
+ function runtimeRelPathAt(packetPath) {
8106
+ try {
8107
+ return packetPath ? packetRuntimeRelPath(readJson(packetPath)) : RUNTIME_DIR_REL_PATH;
8108
+ } catch {
8109
+ return RUNTIME_DIR_REL_PATH;
8110
+ }
8111
+ }
8112
+
7877
8113
  // The verdict a Run Record should carry when none was named: every candidate
7878
8114
  // the report records or the qa-output directories hold, minus any the QA
7879
8115
  // verdict receiver stamped `trusted: false` (an anonymous submission is
@@ -7886,7 +8122,7 @@ function inferQaVerdictPath({ packet, report, reportPath = null, targetRepo = nu
7886
8122
  const eligible = discoverQaVerdicts({
7887
8123
  packet,
7888
8124
  report,
7889
- reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null),
8125
+ reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null),
7890
8126
  roots: [targetRepo, baseDir],
7891
8127
  }).filter((candidate) => candidate.verdict && candidate.trusted
7892
8128
  && ((packet?.spec?.local_spec_id == null && candidate.verdict.local_spec_id == null) || candidate.identityMatch));
@@ -7902,16 +8138,14 @@ const ARGV_SHAPE_PRIVATE_FLAGS = new Set(["auth-cookie", "no-remit", "no-write",
7902
8138
 
7903
8139
  // argv SHAPE = selected flag NAMES present, never their values (minimization).
7904
8140
  // Sorted + de-duplicated for deterministic records; opt-out and endpoint flags
7905
- // stay private. A `--flag=value` token parses as a single key, so split on "="
7906
- // and keep only the name — otherwise a value (e.g. --auth-cookie=SECRET) would
7907
- // leak into a persisted, potentially-remitted shape, breaking the guarantee.
8141
+ // stay private. parseArgs keeps a `--flag=value` token off the keys, so no
8142
+ // value (e.g. --auth-cookie=SECRET) can reach a persisted, remitted shape.
7908
8143
  function argvShape(args) {
7909
8144
  const names = new Set();
7910
8145
  for (const key of Object.keys(args)) {
7911
8146
  if (key === "_") continue;
7912
- const name = key.split("=")[0];
7913
- if (ARGV_SHAPE_PRIVATE_FLAGS.has(name)) continue;
7914
- names.add(`--${name}`);
8147
+ if (ARGV_SHAPE_PRIVATE_FLAGS.has(key)) continue;
8148
+ names.add(`--${key}`);
7915
8149
  }
7916
8150
  return [...names].sort();
7917
8151
  }
@@ -8427,7 +8661,7 @@ export function doctorRequiredActionLines(result) {
8427
8661
  // cannot be ruled out.
8428
8662
  const inspectedReportPath = typeof derived?.assembly_report_path === "string" ? derived.assembly_report_path : null;
8429
8663
  const inferredReportPath = typeof derived?.target_repo === "string"
8430
- ? campaignSidecarPaths(derived.target_repo).reportPath
8664
+ ? campaignSidecarPaths(derived.target_repo, runtimeRelPathAt(packetPath)).reportPath
8431
8665
  : null;
8432
8666
  const reportPath = inspectedReportPath && inspectedReportPath !== inferredReportPath ? inspectedReportPath : null;
8433
8667
  const lines = [];
@@ -8464,6 +8698,19 @@ function printPrepareResult(result, args) {
8464
8698
  }
8465
8699
  }
8466
8700
  console.log(`Packet: ${result.packetPath}`);
8701
+ if (result.funnelAddedBeside) {
8702
+ // A route differing from the default packet's is either another funnel
8703
+ // or a rename of that one; only the operator knows which, so say what was
8704
+ // done and how to undo it.
8705
+ const beside = result.funnelAddedBeside;
8706
+ const route = result.packet?.campaign?.public_route_slug;
8707
+ const runtimeDir = result.packet?.assembly?.runtime_dir;
8708
+ console.log(
8709
+ `Funnel: route ${route} was set up as another funnel beside ${beside.packetPath} (route ${beside.route || "unnamed"})`
8710
+ + `${runtimeDir ? `, with its records under ${runtimeDir}/` : ""}. To rename that funnel's route instead, delete ${result.packetPath}`
8711
+ + `${runtimeDir ? ` and ${runtimeDir}/` : ""}, then re-run prepare-build with --out ${beside.packetPath} --force.`,
8712
+ );
8713
+ }
8467
8714
  if (result.briefPath) {
8468
8715
  const brief = result.context?.build_brief;
8469
8716
  console.log(`Brief: ${result.briefPath}${brief ? ` (${brief.mode}, ${brief.status}, questions=${brief.question_count})` : ""}`);
@@ -8611,16 +8858,30 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
8611
8858
  if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) {
8612
8859
  throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
8613
8860
  }
8614
- if (optionalString(args["proxy-base"]) && args["write-map"] !== true) {
8615
- throw refused("spec derive: --proxy-base only applies with --write-map; nothing was written.");
8861
+ if (Object.hasOwn(args, "no-live-refs") && args["no-live-refs"] !== true) {
8862
+ throw refused(`--no-live-refs takes no value (got ${JSON.stringify(args["no-live-refs"])}); write \`--no-live-refs\` on its own, after the other flags.`);
8616
8863
  }
8617
8864
  const writeMap = args["write-map"] === true;
8618
8865
  const localArgs = Object.fromEntries(Object.entries(args).filter(([key]) => !SPEC_DERIVE_MAP_FLAGS.includes(key)));
8866
+ // The live campaign read (#721), under the proxy --proxy-base names, is
8867
+ // made only once the local checks pass: a dry run of the repo half first,
8868
+ // so a missing packet, spec or entry (or an unknown flag) sends nothing.
8869
+ // --no-live-refs sends nothing at all.
8870
+ const unknownFlags = Object.keys(localArgs).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
8871
+ // The store flags are judged first, as the local command judges them, so
8872
+ // the preflight below (which leaves them out) never changes which refusal
8873
+ // a malformed invocation gets.
8874
+ if (!unknownFlags.length) parseSpecDeriveStoreFlags(localArgs);
8875
+ const preflightArgs = Object.fromEntries(Object.entries(localArgs).filter(([key]) => key !== "from-store" && key !== "store-token-source"));
8876
+ const preflight = unknownFlags.length || liveRefsDisabled(args) ? null : specDeriveCommand({ ...preflightArgs, "dry-run": true });
8877
+ const liveCampaign = liveRefsDisabled(args)
8878
+ ? disabledLiveRead()
8879
+ : preflight && !preflight.errors.length ? await readSpecDeriveLiveCampaign(args, { ...(fetchImpl ? { fetchImpl } : {}), env, ...(warn ? { warn } : {}) }) : undefined;
8619
8880
  // A copy, so nothing here depends on the local command handing out a
8620
8881
  // mutable object; the Map outcome is added beside its fields, not into them.
8621
8882
  // The store source (--from-store) is the other network read; it runs
8622
8883
  // before the Map write-back so the pin it reports is the one written.
8623
- const local = Object.hasOwn(localArgs, "from-store") ? await specDeriveFromStoreCommand(localArgs) : specDeriveCommand(localArgs);
8884
+ const local = Object.hasOwn(localArgs, "from-store") ? await specDeriveFromStoreCommand(localArgs, { liveCampaign }) : specDeriveCommand(localArgs, { liveCampaign });
8624
8885
  const result = { ...local, write_map: writeMap, map: null };
8625
8886
  if (!writeMap) return result;
8626
8887
  const skipped = (reason, detail) => {