@nextcommerce/campaigns-os 1.61.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +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/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
package/src/cli.mjs CHANGED
@@ -28,11 +28,11 @@ import { diagnosticExport, diagnosticTextLines } from "./diagnostic.mjs";
28
28
  import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
29
29
  import { HIDDEN_EAGER_MEDIA_ACTIONS, qaRunCommand, requiredActionText, substitutePacket } from "./gate-actions.mjs";
30
30
  import { orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
31
- import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
31
+ import { routeMaskedSpecMaterialHash, specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
32
32
  // The same predicate stage-ledger.mjs judges a mutator's result with, imported
33
33
  // rather than re-stated so the waive preview and the commit agree by identity.
34
34
  import { isPlainObject } from "./repo-scan.mjs";
35
- 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";
36
36
  import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
37
37
  import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
38
38
  import {
@@ -82,7 +82,17 @@ import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./d
82
82
  import { QcAcceptRefusal, buildQcHandoff, parseQcResultRef, planQcAccepts, projectQcAccept, qcAcceptAttribution, qcHandoffTextLines } from "./qc-accept.mjs";
83
83
  import { loadQcRederivers } from "./qc-check-registry.mjs";
84
84
  import { fingerprint12, readCurrentQcResults } from "./qc-results.mjs";
85
- 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";
86
96
  import { canonicalPath, sameFile } from "./fs-identity.mjs";
87
97
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
88
98
  import { writeMapSdkPin } from "./map-pin-writeback.mjs";
@@ -101,7 +111,7 @@ import {
101
111
  runWithRefusalScope,
102
112
  timeOperatorWait,
103
113
  } from "./lifecycle.mjs";
104
- import { commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
114
+ import { JOINED_FLAGS, commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
105
115
  import {
106
116
  clearRunSession,
107
117
  findRunSession,
@@ -141,6 +151,7 @@ import { derivePackagePin, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveI
141
151
  import {
142
152
  campaignRouteRoot,
143
153
  normalizePublicRouteSlug,
154
+ specPublicRouteSlug,
144
155
  } from "./route-identity.mjs";
145
156
  import {
146
157
  PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND,
@@ -240,11 +251,13 @@ import {
240
251
  formatDeriveValue,
241
252
  isPageTreeIgnoredDir,
242
253
  isPageTreeIgnoredPath,
243
- frontmatterPermalink,
254
+ frontmatterScalars,
244
255
  pageRouteForFile,
245
256
  permalinkRoute,
246
257
  planSpecDerive,
247
258
  } from "./spec-derive.mjs";
259
+ import { planCampaignMirrorDerive } from "./spec-derive-campaign.mjs";
260
+ import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "./live-campaign-refs.mjs";
248
261
  import {
249
262
  adminApiBaseForStore,
250
263
  normalizeStoreSubdomain,
@@ -311,7 +324,7 @@ Usage:
311
324
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
312
325
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
313
326
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
314
- [--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)
315
328
  campaigns-os prepare-build (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
316
329
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
317
330
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
@@ -334,7 +347,7 @@ Usage:
334
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
335
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
336
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.
337
- 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.
338
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.
339
352
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
340
353
  campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
@@ -361,7 +374,7 @@ Usage:
361
374
  campaigns-os next deploy --packet <json> --report <json> [--json]
362
375
  campaigns-os next qa --packet <json> --report <json> [--json]
363
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
364
- campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--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>] [--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]
365
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
366
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
367
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
@@ -375,7 +388,7 @@ Usage:
375
388
 
376
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.
377
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.
378
- 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.
379
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
380
393
  campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
381
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).
@@ -487,7 +500,10 @@ export async function main(argv, { authentication, qcStandIns } = {}) {
487
500
  return;
488
501
  }
489
502
  return withDoctorCiReport(parseArgs(argv), (args) => runInvocation(args, {
490
- dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
503
+ dispatch: (command, args, context) => {
504
+ refuseJoinedFlag(args);
505
+ return dispatch(command, args, { ...context, argv, authentication, qcStandIns });
506
+ },
491
507
  closeOutStaleRunSessions,
492
508
  ambientRunSession,
493
509
  lifecycleIdentity,
@@ -730,10 +746,17 @@ export function recordQaStageOutcome(args, result) {
730
746
  // start and the spec material it judged, never the inputs at write time.
731
747
  const qaStamps = inputStamps({ briefMaterial: verdict.source_brief_material, specMaterial: verdict.spec_hash });
732
748
  const qaInputs = stageWriteInputs("qa", currentPacketInputs({ packet, packetPath }));
749
+ const pageScope = isObject(result.page_scope) ? result.page_scope : null;
733
750
  const committed = commitAssemblyReport(workspace, (report) => {
734
751
  if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
735
752
  throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
736
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);
737
760
  return recordProducerStageOutcome(report, {
738
761
  stage: "qa",
739
762
  disposition: verdict.disposition,
@@ -750,11 +773,19 @@ export function recordQaStageOutcome(args, result) {
750
773
  // Which build this verdict judged, and the gates whose browser outcome
751
774
  // the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
752
775
  // gate that never ran is left out, so silence never reads as a pass.
753
- 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,
754
781
  // Counts-only: never order ids, refs, emails or URLs (see
755
782
  // summarizePurchaseProof). This is what lets `next` tell a real purchase
756
- // path from a `--test-order off` diagnostic.
757
- 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 }),
758
789
  stamps: qaStamps,
759
790
  inputs: qaInputs,
760
791
  });
@@ -1024,7 +1055,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1024
1055
  // A --map-id fetch runs here, but the fetched spec is written to the
1025
1056
  // shared cache file inside prepareBuild, under the per-target lock, so it
1026
1057
  // never replaces the spec another run is reading and hashing (#501).
1027
- 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 }));
1028
1059
  // The spec as the operator named it, before resolution, so the build
1029
1060
  // context can record which input to replay (a local file, or a map id
1030
1061
  // fetched from a given store).
@@ -1035,7 +1066,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1035
1066
  // Doctor runs inside prepare-build, so its codes are recorded while that
1036
1067
  // phase is active and attach to it.
1037
1068
  const result = await recorder.time("prepare-build", async () => {
1038
- 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 });
1039
1070
  if (prepared.doctor && !prepared.doctor.ok) recorder.recordFindingCodes(prepared.doctor.errors?.map((issue) => issue.code));
1040
1071
  return prepared;
1041
1072
  });
@@ -1257,6 +1288,8 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1257
1288
  // The ambient session is handed over, not re-discovered: the closeout
1258
1289
  // command a QA run prints has to agree with the run_id this session will
1259
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") };
1260
1293
  const result = await runQaCli(args, { ambient });
1261
1294
  if (result?.verdict?.disposition === "blocked") {
1262
1295
  recorder.recordFindingCodes(result.verdict.assertions?.filter((item) => item.status === "fail" || item.severity === "blocker")
@@ -1307,6 +1340,20 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1307
1340
  );
1308
1341
  }
1309
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
+
1310
1357
  export function parseArgs(argv) {
1311
1358
  const args = { _: [] };
1312
1359
  for (let index = 0; index < argv.length; index += 1) {
@@ -1315,6 +1362,12 @@ export function parseArgs(argv) {
1315
1362
  args._.push(token);
1316
1363
  continue;
1317
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
+ }
1318
1371
  const key = token.slice(2);
1319
1372
  const next = argv[index + 1];
1320
1373
  if (!next || next.startsWith("--")) {
@@ -1403,7 +1456,9 @@ async function resolveSpecPath(args, opts = {}) {
1403
1456
  return { specPath: cachePath, source: "remote", mapId, proxyBase,
1404
1457
  savedMapRevision: { map_id: mapId, hash: spec.spec_identity?.spec_hash || spec.spec_hash || null,
1405
1458
  algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
1406
- ...(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 } : {}),
1407
1462
  };
1408
1463
  }
1409
1464
  throw refused(
@@ -1532,11 +1587,13 @@ function createProofPolicy({ orderPathDepth = null } = {}) {
1532
1587
  // channel that supplies it. Source screenshot proof is seeded only by the
1533
1588
  // source-html manifest's pages[].screenshots[], which is documented nowhere in
1534
1589
  // the blocking reason itself.
1535
- 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) => [
1536
1593
  `Supply the missing source proof through pages[].screenshots[] in ${SOURCE_HTML_MANIFEST_REL_PATH}`,
1537
1594
  "under the source root, or in a manifest anywhere else named by --design-manifest <path>",
1538
1595
  "(one available desktop record and one available mobile record per renderable page);",
1539
- `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}`,
1540
1597
  "and rerun prepare-build/start.",
1541
1598
  'See "Clearing DESIGN_SOURCE_PACKAGE_NOT_READY" in docs/design-source-package.md.',
1542
1599
  ].join(" ");
@@ -1547,12 +1604,13 @@ const DESIGN_SOURCE_PACKAGE_REMEDY = [
1547
1604
  // exception reasons keep their own wording.
1548
1605
  const DESIGN_SOURCE_PACKAGE_REMEDY_REASON = /source_screenshot proof\.$|^Source TODO "capture-(?![^"]*-template-)[^"]*" is /;
1549
1606
 
1550
- function designSourcePackageBlockers(prepared) {
1607
+ function designSourcePackageBlockers(prepared, targetRepo) {
1551
1608
  if (!["blocked", "pending"].includes(prepared.value?.readiness?.status)) return [];
1609
+ const remedy = designSourcePackageRemedy(prepared.path && targetRepo ? relFromDir(targetRepo, prepared.path) : undefined);
1552
1610
  return (prepared.value.readiness.blocking_reasons || []).map((message, index) => ({
1553
1611
  code: "DESIGN_SOURCE_PACKAGE_NOT_READY",
1554
1612
  stage: "prepare_build",
1555
- 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,
1556
1614
  detail: {
1557
1615
  blocker_index: index,
1558
1616
  readiness_status: prepared.value.readiness.status,
@@ -1657,18 +1715,44 @@ async function prepareBuild(args, options = {}) {
1657
1715
  if (publishSpec && !existsSync(targetRepo)) mkdirSync(targetRepo, { recursive: true });
1658
1716
  if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) throw new Error(`Target repo is not a directory: ${targetRepo}`);
1659
1717
 
1660
- const sidecars = campaignSidecarPaths(targetRepo);
1661
- 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;
1662
1729
  const contextPath = resolve(args["context-out"] || sidecars.contextPath);
1663
1730
  const reportPath = resolve(args["report-out"] || sidecars.reportPath);
1664
1731
  const doctorOutPath = resolve(args["doctor-out"] || sidecars.doctorOutPath);
1665
- 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)));
1666
1733
  return withDesignSourcePublication({
1667
1734
  targetRepo,
1735
+ runtimeDir: sidecars.runtimeDir,
1668
1736
  outputs: { packetPath, contextPath, reportPath, doctorOutPath, briefPath },
1669
1737
  force: args.force === true,
1670
1738
  publishSpec,
1671
- }, (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;
1672
1756
  }
1673
1757
 
1674
1758
  function prepareBuildUnderLock({
@@ -1678,6 +1762,8 @@ function prepareBuildUnderLock({
1678
1762
  sourceRoot,
1679
1763
  targetRepo,
1680
1764
  publication,
1765
+ funnel,
1766
+ planFunnel,
1681
1767
  }) {
1682
1768
  const { packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath } = publication.paths;
1683
1769
  // #531: a host-prefixed route ("shop.example.com/route/upsell/") in a Map
@@ -1701,6 +1787,22 @@ function prepareBuildUnderLock({
1701
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.");
1702
1788
  }
1703
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
+ }
1704
1806
 
1705
1807
  // Dispatch validated the argv-only flags before spec resolution, including
1706
1808
  // the value forms of --template-family, --allow-uncertified-template,
@@ -1932,7 +2034,7 @@ function prepareBuildUnderLock({
1932
2034
  mapId,
1933
2035
  publicRouteSlug,
1934
2036
  });
1935
- const designSourceBlockers = designSourcePackageBlockers(designSourcePackage);
2037
+ const designSourceBlockers = designSourcePackageBlockers(designSourcePackage, targetRepo);
1936
2038
  const blockers = [...sourceBlockers, ...briefBlockers, ...briefQuestionBlockers, ...designSourceBlockers];
1937
2039
  const adapterDecisions = createAdapterDecisions({ commerceZoneFindings, wrapperPolicy: wrapperPolicy.value });
1938
2040
  const proofPolicy = createProofPolicy({ orderPathDepth: orderPathDepthFlag });
@@ -1977,6 +2079,9 @@ function prepareBuildUnderLock({
1977
2079
  assembly: {
1978
2080
  implementation: "next-campaigns-build",
1979
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 } : {}),
1980
2085
  output_dir: outputDir,
1981
2086
  template_family: isUnresolvedTemplateFamily(templateFamily) ? "undecided" : templateFamily,
1982
2087
  template_decision_notes: templateLocked
@@ -2050,6 +2155,7 @@ function prepareBuildUnderLock({
2050
2155
  path: portable(specPath),
2051
2156
  hash: specFileHash,
2052
2157
  material_hash: specMaterialHash(spec),
2158
+ route_masked_material_hash: routeMaskedSpecMaterialHash(spec),
2053
2159
  active_pages: activePages.map((page) => ({
2054
2160
  id: page.id,
2055
2161
  type: page.type || null,
@@ -2102,7 +2208,7 @@ function prepareBuildUnderLock({
2102
2208
  target_repo: ".",
2103
2209
  output_dir: portable(resolve(targetRepo, outputDir)),
2104
2210
  handoff_skill: existsSync(resolve(targetRepo, outputDir)) ? "next-campaigns-build" : "next-campaigns-os-setup",
2105
- handoff_artifact: ".campaign-runtime/setup-handoff.json",
2211
+ handoff_artifact: `${funnel.runtimeRelPath}/setup-handoff.json`,
2106
2212
  reason: existsSync(resolve(targetRepo, outputDir))
2107
2213
  ? "Target campaign output directory already exists."
2108
2214
  : "Target campaign output directory is missing; scaffold before build.",
@@ -2222,6 +2328,7 @@ function prepareBuildUnderLock({
2222
2328
  context,
2223
2329
  report,
2224
2330
  doctor,
2331
+ ...(funnel.addedBeside ? { funnelAddedBeside: funnel.addedBeside } : {}),
2225
2332
  };
2226
2333
  }
2227
2334
 
@@ -2505,11 +2612,8 @@ function rejectUnknownStandardizeFlags(args) {
2505
2612
  const known = new Set(STANDARDIZE_FLAGS);
2506
2613
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
2507
2614
  if (!unknown.length) return;
2508
- const valueHint = unknown.some((key) => key.includes("="))
2509
- ? " A flag takes its value as the next argument (--flag value), not --flag=value."
2510
- : "";
2511
2615
  throw refused(
2512
- `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(", ")}.`,
2513
2617
  );
2514
2618
  }
2515
2619
 
@@ -2601,7 +2705,9 @@ function themeCommand(args) {
2601
2705
  // (blocked, already-current CSS) leaves doctor's inputs untouched (Kilo
2602
2706
  // review, PR #176).
2603
2707
  if (written.wrote?.report || written.wrote?.css) {
2604
- 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,
2605
2711
  command: "theme generate",
2606
2712
  reason: `Theme artifacts were generated after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
2607
2713
  });
@@ -3286,8 +3392,7 @@ export function pageKitSyncCommand(args) {
3286
3392
  // Assembly Report whose waivers are honoured below.
3287
3393
  const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
3288
3394
  if (unknown.length) {
3289
- const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
3290
- 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(", ")}.`);
3291
3396
  }
3292
3397
  const packetPath = resolve(requireArg(args, "packet"));
3293
3398
  const dryRun = isDryRun(args);
@@ -3364,11 +3469,7 @@ export function pageKitSyncCommand(args) {
3364
3469
  // spec.local_path would write another campaign's store profile into this
3365
3470
  // route with a plausible-looking diff.
3366
3471
  if (spec && publicRouteSlug) {
3367
- const specSlug = normalizePublicRouteSlug(
3368
- optionalString(spec.spec_identity?.public_route_slug)
3369
- || optionalString(spec.campaign?.slug)
3370
- || optionalString(spec.campaign?.id),
3371
- );
3472
+ const specSlug = specPublicRouteSlug(spec);
3372
3473
  const specMapId = optionalString(spec.spec_identity?.map_id) || optionalString(spec.map_id);
3373
3474
  const packetMapId = optionalString(packet.spec?.map_id);
3374
3475
  if (specSlug && specSlug !== publicRouteSlug) {
@@ -3494,6 +3595,7 @@ export function pageKitSyncCommand(args) {
3494
3595
  // result, never a thrown error that hides the write.
3495
3596
  try {
3496
3597
  markDoctorSidecarStale(targetRepo, {
3598
+ path: campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).doctorOutPath,
3497
3599
  command: "page-kit sync",
3498
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.`,
3499
3601
  });
@@ -3524,7 +3626,7 @@ export function pageKitSyncCommand(args) {
3524
3626
  // the default run stays offline. --dry-run prints the same diff and writes
3525
3627
  // nothing. Exit 2 when the packet, the spec or the target entry is missing,
3526
3628
  // or the store cannot be read.
3527
- 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"]);
3528
3630
 
3529
3631
  // The store flags, parsed once for both the async reader and the sync
3530
3632
  // command: `{ subdomain, token_env }` when --from-store is given, null when
@@ -3552,9 +3654,9 @@ function parseSpecDeriveStoreFlags(args) {
3552
3654
  // run the same command with the store read in hand. The only network the
3553
3655
  // command ever does happens here, and the token never leaves this function:
3554
3656
  // the result names the credential source, never its value.
3555
- 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 } = {}) {
3556
3658
  const store = parseSpecDeriveStoreFlags(args);
3557
- if (!store) return specDeriveCommand(args);
3659
+ if (!store) return specDeriveCommand(args, { liveCampaign });
3558
3660
  const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
3559
3661
  if (preflight.errors.length) return preflight;
3560
3662
  let read;
@@ -3567,7 +3669,36 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
3567
3669
  read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
3568
3670
  }
3569
3671
  // Recheck the original packet/spec identity after the network operation.
3570
- 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
+ });
3571
3702
  }
3572
3703
 
3573
3704
  // The page files page-kit renders under the campaign's source directory, with
@@ -3595,30 +3726,32 @@ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored =
3595
3726
  // page-kit honours a permalink before its filename rule, so the
3596
3727
  // frontmatter is read first (quoting, comments, BOM and CRLF as
3597
3728
  // page-kit's reader takes them); only a file with no usable permalink
3598
- // falls back to the filename route.
3599
- 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 };
3600
3734
  if (permalink === null) {
3601
3735
  const filenameRoute = pageRouteForFile(path);
3602
3736
  if (filenameRoute === null) continue;
3603
- 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 });
3604
3738
  continue;
3605
3739
  }
3606
3740
  const resolved = permalinkRoute(permalink, publicRouteSlug);
3607
- 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 });
3608
3742
  }
3609
3743
  };
3610
3744
  walk(outputDir);
3611
3745
  return files.sort((a, b) => a.path.localeCompare(b.path));
3612
3746
  }
3613
3747
 
3614
- export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3748
+ export function specDeriveCommand(args, { store: storeRead = null, liveCampaign = undefined } = {}) {
3615
3749
  // A command that rewrites the spec rejects flags it does not know: a
3616
3750
  // mistyped --dryrun must not fall through to a real write. --report names
3617
3751
  // the Assembly Report whose waivers are honoured below.
3618
3752
  const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
3619
3753
  if (unknown.length) {
3620
- const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
3621
- 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(", ")}.`);
3622
3755
  }
3623
3756
  // The store read is supplied by specDeriveFromStoreCommand; this function
3624
3757
  // never touches the network itself, so a --from-store call that reaches it
@@ -3646,6 +3779,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3646
3779
  not_derived: [],
3647
3780
  not_in_target: [],
3648
3781
  stale_hints: [],
3782
+ page_scope: null,
3783
+ live_campaign: null,
3649
3784
  store: storeFlags
3650
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 }
3651
3786
  : null,
@@ -3709,11 +3844,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3709
3844
  // (campaign.route_slug_identity); without it a stale or copy-pasted
3710
3845
  // spec.local_path would receive another campaign's routes and pin.
3711
3846
  if (spec && publicRouteSlug) {
3712
- const specSlug = normalizePublicRouteSlug(
3713
- optionalString(spec.spec_identity?.public_route_slug)
3714
- || optionalString(spec.campaign?.slug)
3715
- || optionalString(spec.campaign?.id),
3716
- );
3847
+ const specSlug = specPublicRouteSlug(spec);
3717
3848
  const specMapId = optionalString(spec.spec_identity?.map_id) || optionalString(spec.map_id);
3718
3849
  const packetMapId = optionalString(packet.spec?.map_id);
3719
3850
  if (specSlug && specSlug !== publicRouteSlug) {
@@ -3836,7 +3967,24 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3836
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" });
3837
3968
  return result;
3838
3969
  }
3839
- 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
+ }
3840
3988
  let storeChanged = false;
3841
3989
  if (storeRead) {
3842
3990
  const storePlan = planStoreProfileDerive({ spec, store: storeRead.store, pages: storeRead.pages, pagesStatus: storeRead.pages_status, pagesDetail: storeRead.pages_detail, subdomain: storeRead.subdomain });
@@ -3860,11 +4008,18 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3860
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 } : {}) });
3861
4009
  }
3862
4010
  for (const hint of plan.stale_hints) {
3863
- 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);
3864
4012
  }
3865
4013
  for (const block of plan.created_blocks || []) {
3866
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.`);
3867
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
+ }
3868
4023
 
3869
4024
  // What a write entails is said in both modes, so a dry run previews the
3870
4025
  // warnings a real run would carry: a lossy round trip, a build now stale,
@@ -3878,8 +4033,9 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3878
4033
  if (serialize(spec) !== text) {
3879
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.`);
3880
4035
  }
3881
- if (plan.changes.some((row) => row.page_id)) {
3882
- 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")}.`);
3883
4039
  }
3884
4040
  if (stageIsTerminal(report?.stages?.assembly?.status)) {
3885
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.`);
@@ -3931,6 +4087,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3931
4087
  // The retained doctor snapshot (if any) now predates the spec it judged.
3932
4088
  try {
3933
4089
  markDoctorSidecarStale(targetRepo, {
4090
+ path: campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).doctorOutPath,
3934
4091
  command: "spec derive",
3935
4092
  reason: `spec derive rewrote the CampaignSpec after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
3936
4093
  });
@@ -3981,6 +4138,14 @@ export function specDeriveTextLines(result) {
3981
4138
  }
3982
4139
  if (result.unchanged?.length) lines.push(`Unchanged: ${result.unchanged.map((row) => row.field).join(", ")}`);
3983
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
+ }
3984
4149
  if (result.warnings?.length) {
3985
4150
  lines.push("Warnings:");
3986
4151
  for (const issue of result.warnings) lines.push(`- ${formatIssueSummary(issue)}`);
@@ -4379,7 +4544,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4379
4544
  let runRecordCloseout = null;
4380
4545
  try {
4381
4546
  runRecordCloseout = assessRunRecordCloseout({
4382
- records: readRunRecordsForTarget(dirname(packetPath)),
4547
+ records: readRunRecordsForTarget(dirname(packetPath), packetRuntimeRelPath(packet)),
4383
4548
  packet,
4384
4549
  report,
4385
4550
  currentQaVerdictDigests: currentQaVerdictDigestsForReport(report, report ? reportPath : null, targetRepo),
@@ -4405,7 +4570,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4405
4570
  // command names the report this run read when that is not the default
4406
4571
  // one (from --report or the Build Context pointer), so it records there.
4407
4572
  const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
4408
- 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)) });
4409
4574
  // One note per operator decision that kept the build output unchanged
4410
4575
  // after an input change, quoting the operator's reason.
4411
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}"`);
@@ -5083,14 +5248,15 @@ function templateStockPromptLine(packet, derived = {}) {
5083
5248
 
5084
5249
  function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
5085
5250
  const briefPath = packet.build_brief?.normalized_path || "(missing; generate or confirm Campaign Build Brief before business-sensitive assembly)";
5251
+ const runtime = runtimeRelPathOf(packet);
5086
5252
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
5087
5253
 
5088
5254
  Read first:
5089
5255
  - Build Packet: ${packetPath}
5090
- - Build Context: ${contextPath || "(use packet-adjacent .campaign-runtime/build-context.json if present)"}
5091
- - 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)`}
5092
5258
  - Campaign Build Brief: ${briefPath}
5093
- - 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.
5094
5260
  - Template family: ${packet.assembly.template_family}${templateStockPromptLine(packet, derived)}
5095
5261
 
5096
5262
  Rules:
@@ -5101,7 +5267,7 @@ Rules:
5101
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.
5102
5268
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
5103
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.
5104
- - 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; 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.
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.
5105
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.
5106
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.
5107
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.
@@ -5173,7 +5339,7 @@ Read first:
5173
5339
 
5174
5340
  Prepare the target page-kit structure and agent context, then record setup:
5175
5341
  - ${cmd("record")} setup --packet ${packetPath}
5176
- 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.
5177
5343
 
5178
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.
5179
5345
 
@@ -5875,11 +6041,22 @@ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
5875
6041
  // An explicit packet names its project: its target repo, whatever the cwd.
5876
6042
  const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
5877
6043
  const packageJsonPath = nearestPackageJson(projectStart);
6044
+ let packetUnread = false;
5878
6045
  if (!packetPath) {
5879
6046
  // The contracted home of the packet is the project root beside package.json
5880
6047
  // (prepare-build's default --out), the same place readback discovers it.
5881
- packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
5882
- 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);
5883
6060
  }
5884
6061
 
5885
6062
  const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
@@ -5896,7 +6073,7 @@ export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
5896
6073
  packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
5897
6074
  warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
5898
6075
  }
5899
- } else if (existsSync(packetPath) && !packet) {
6076
+ } else if (!packetUnread && existsSync(packetPath) && !packet) {
5900
6077
  warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
5901
6078
  }
5902
6079
  return {
@@ -7359,7 +7536,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7359
7536
  const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
7360
7537
  const packet = readJson(packetPath);
7361
7538
  const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
7362
- const { baseDir, targetRepo, contextPath, reportPath } = resolveCampaignWorkspace(packetPath, {
7539
+ const { baseDir, runtimeRelPath, targetRepo, contextPath, reportPath } = resolveCampaignWorkspace(packetPath, {
7363
7540
  packet,
7364
7541
  contextPath: args.context ? resolve(args.context) : undefined,
7365
7542
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -7403,7 +7580,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7403
7580
  // session decides without it, and a target with a long history pays no I/O
7404
7581
  // for a decision already made.
7405
7582
  const needsDiskScan = listOnly || (!optionalString(args["run-id"]) && args["new-run"] !== true && !isNonEmptyString(ambient?.session?.run_id));
7406
- const targetRecords = needsDiskScan ? readRunRecordsForTarget(baseDir) : [];
7583
+ const targetRecords = needsDiskScan ? readRunRecordsForTarget(baseDir, runtimeRelPath) : [];
7407
7584
  const latestRecordEntry = needsDiskScan ? latestMatchingRunRecord(targetRecords, packet) : null;
7408
7585
  let runId;
7409
7586
  let runIdSource;
@@ -7544,7 +7721,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7544
7721
  // may not. A pure --no-write run reads nothing: it writes and sends nothing.
7545
7722
  // A --dry-run does read it, because what a real run would do here is the
7546
7723
  // very thing the dry run is being asked to report.
7547
- const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
7724
+ const prior = write || dryRun ? readPriorRunRecord(runId, baseDir, runtimeRelPath) : null;
7548
7725
  const priorRemit = priorRemitOutcome(prior?.record);
7549
7726
  const storedRemotely = priorRemit?.state === "ok";
7550
7727
  // A pure local-inspection run (--no-write), an explicit --no-remit, or a
@@ -7743,7 +7920,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7743
7920
  // carries this run's remit_* outcome rather than the placeholders it was
7744
7921
  // assembled with. Validated above, ahead of the send; nothing is written
7745
7922
  // under --dry-run or --no-write.
7746
- const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
7923
+ const recordPath = write ? writeRunRecord(record, { baseDir, runtimeRelPath }) : null;
7747
7924
 
7748
7925
  const summary = {
7749
7926
  ok: true,
@@ -7775,7 +7952,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
7775
7952
  // stopped the send anyway, and null with `would_remit_refused` set when the
7776
7953
  // transport's destination gate refuses the base before any socket opens).
7777
7954
  // Envelope only; never on the record.
7778
- ...(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 } : {}),
7779
7956
  };
7780
7957
  if (silent) return summary;
7781
7958
  if (args.json) {
@@ -7828,10 +8005,10 @@ function assertRunRecordValid(record) {
7828
8005
  // `writeRunRecord` could have written is trusted as a prior — the same
7829
8006
  // validator gates both — so a file that merely says `remit_state: "ok"` is
7830
8007
  // replaced like a corrupt one, never preserved or handed back as the record.
7831
- function readPriorRunRecord(runId, baseDir) {
8008
+ function readPriorRunRecord(runId, baseDir, runtimeRelPath) {
7832
8009
  let path;
7833
8010
  try {
7834
- path = resolveRunRecordPath(runId, baseDir);
8011
+ path = resolveRunRecordPath(runId, baseDir, runtimeRelPath);
7835
8012
  } catch {
7836
8013
  return null;
7837
8014
  }
@@ -7913,6 +8090,26 @@ function artifactRefPath(kind, filePath, baseDir) {
7913
8090
  return rel.startsWith(".") ? rel : `./${rel}`;
7914
8091
  }
7915
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
+
7916
8113
  // The verdict a Run Record should carry when none was named: every candidate
7917
8114
  // the report records or the qa-output directories hold, minus any the QA
7918
8115
  // verdict receiver stamped `trusted: false` (an anonymous submission is
@@ -7925,7 +8122,7 @@ function inferQaVerdictPath({ packet, report, reportPath = null, targetRepo = nu
7925
8122
  const eligible = discoverQaVerdicts({
7926
8123
  packet,
7927
8124
  report,
7928
- reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null),
8125
+ reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null),
7929
8126
  roots: [targetRepo, baseDir],
7930
8127
  }).filter((candidate) => candidate.verdict && candidate.trusted
7931
8128
  && ((packet?.spec?.local_spec_id == null && candidate.verdict.local_spec_id == null) || candidate.identityMatch));
@@ -7941,16 +8138,14 @@ const ARGV_SHAPE_PRIVATE_FLAGS = new Set(["auth-cookie", "no-remit", "no-write",
7941
8138
 
7942
8139
  // argv SHAPE = selected flag NAMES present, never their values (minimization).
7943
8140
  // Sorted + de-duplicated for deterministic records; opt-out and endpoint flags
7944
- // stay private. A `--flag=value` token parses as a single key, so split on "="
7945
- // and keep only the name — otherwise a value (e.g. --auth-cookie=SECRET) would
7946
- // 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.
7947
8143
  function argvShape(args) {
7948
8144
  const names = new Set();
7949
8145
  for (const key of Object.keys(args)) {
7950
8146
  if (key === "_") continue;
7951
- const name = key.split("=")[0];
7952
- if (ARGV_SHAPE_PRIVATE_FLAGS.has(name)) continue;
7953
- names.add(`--${name}`);
8147
+ if (ARGV_SHAPE_PRIVATE_FLAGS.has(key)) continue;
8148
+ names.add(`--${key}`);
7954
8149
  }
7955
8150
  return [...names].sort();
7956
8151
  }
@@ -8466,7 +8661,7 @@ export function doctorRequiredActionLines(result) {
8466
8661
  // cannot be ruled out.
8467
8662
  const inspectedReportPath = typeof derived?.assembly_report_path === "string" ? derived.assembly_report_path : null;
8468
8663
  const inferredReportPath = typeof derived?.target_repo === "string"
8469
- ? campaignSidecarPaths(derived.target_repo).reportPath
8664
+ ? campaignSidecarPaths(derived.target_repo, runtimeRelPathAt(packetPath)).reportPath
8470
8665
  : null;
8471
8666
  const reportPath = inspectedReportPath && inspectedReportPath !== inferredReportPath ? inspectedReportPath : null;
8472
8667
  const lines = [];
@@ -8503,6 +8698,19 @@ function printPrepareResult(result, args) {
8503
8698
  }
8504
8699
  }
8505
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
+ }
8506
8714
  if (result.briefPath) {
8507
8715
  const brief = result.context?.build_brief;
8508
8716
  console.log(`Brief: ${result.briefPath}${brief ? ` (${brief.mode}, ${brief.status}, questions=${brief.question_count})` : ""}`);
@@ -8650,16 +8858,30 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
8650
8858
  if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) {
8651
8859
  throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
8652
8860
  }
8653
- if (optionalString(args["proxy-base"]) && args["write-map"] !== true) {
8654
- 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.`);
8655
8863
  }
8656
8864
  const writeMap = args["write-map"] === true;
8657
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;
8658
8880
  // A copy, so nothing here depends on the local command handing out a
8659
8881
  // mutable object; the Map outcome is added beside its fields, not into them.
8660
8882
  // The store source (--from-store) is the other network read; it runs
8661
8883
  // before the Map write-back so the pin it reports is the one written.
8662
- 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 });
8663
8885
  const result = { ...local, write_map: writeMap, map: null };
8664
8886
  if (!writeMap) return result;
8665
8887
  const skipped = (reason, detail) => {