@nextcommerce/campaigns-os 1.41.2 → 1.43.2

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 (83) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +629 -0
  3. package/README.md +8 -6
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  7. package/contracts/effects.v1.json +118 -25
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1424 -0
  10. package/contracts/supported-surface.json +12 -11
  11. package/docs/build-packet.md +120 -8
  12. package/docs/campaigns-os-build-flow.md +3 -2
  13. package/docs/design-source-package.md +89 -15
  14. package/docs/effects.md +83 -2
  15. package/docs/local-setup.md +51 -0
  16. package/docs/migration-sidecar-bundle.md +6 -1
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/progress-snapshots.md +16 -6
  19. package/docs/qa-and-test-orders.md +157 -17
  20. package/docs/release-ledger-authoring-guide.md +6 -4
  21. package/docs/runtime-readiness.md +1 -1
  22. package/docs/skills-revision.md +10 -10
  23. package/package.json +3 -2
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  25. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  26. package/schemas/campaign-spec.v4.schema.json +4 -0
  27. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  28. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  29. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  30. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +8 -6
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +17 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +3 -3
  40. package/skills/next-campaigns-qa/SKILL.md +10 -9
  41. package/skills.json +11 -11
  42. package/src/build-brief.mjs +6 -4
  43. package/src/built-script-syntax.mjs +480 -0
  44. package/src/campaigns-api-key.mjs +99 -0
  45. package/src/cli-helpers.mjs +118 -0
  46. package/src/cli.mjs +796 -6963
  47. package/src/design-source-package.mjs +1 -1
  48. package/src/design-source-publication.mjs +898 -0
  49. package/src/diagnostic.mjs +2 -1
  50. package/src/directory-lock.mjs +270 -0
  51. package/src/doctor/checks.mjs +4415 -0
  52. package/src/doctor/inspect.mjs +636 -0
  53. package/src/doctor/next-step.mjs +731 -0
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/install-invocation.mjs +29 -0
  56. package/src/invocation.mjs +179 -0
  57. package/src/lifecycle.mjs +5 -4
  58. package/src/polish-node.mjs +5 -2
  59. package/src/private-template-source.mjs +1 -1
  60. package/src/progress-node.mjs +9 -37
  61. package/src/progress.mjs +5 -3
  62. package/src/proof-policy.mjs +1 -1
  63. package/src/qa-analytics-correctness.mjs +3 -0
  64. package/src/qa-binding-evidence.mjs +76 -11
  65. package/src/qa-browser.mjs +778 -77
  66. package/src/qa-build-scope.mjs +47 -0
  67. package/src/qa-node.mjs +276 -39
  68. package/src/qa-publish.mjs +4 -0
  69. package/src/qa-sidecar.mjs +2 -0
  70. package/src/qa-verdict-discovery.mjs +11 -0
  71. package/src/qa-verdict-publish.mjs +1 -0
  72. package/src/qa-verdict.mjs +8 -1
  73. package/src/readback.mjs +2 -1
  74. package/src/run-record-closeout.mjs +3 -4
  75. package/src/run-record.mjs +4 -0
  76. package/src/sidecar-bundle.mjs +21 -0
  77. package/src/source-html-intake.mjs +1 -1
  78. package/src/source-html-manifest.mjs +9 -2
  79. package/src/spec-source-identity.mjs +44 -0
  80. package/src/stage-ledger.mjs +32 -1
  81. package/src/target-lock.mjs +54 -0
  82. package/src/template-brand-contract.mjs +17 -1
  83. package/src/tooling-setup.mjs +160 -0
@@ -55,12 +55,17 @@ contract. A packet found only at
55
55
  remedy; conformance does not silently widen discovery.
56
56
 
57
57
  The checker validates canonical paths, declared schema versions, strict UTC
58
- timestamps, cross-artifact Map ID, public slug, campaign directory, live URL
58
+ timestamps, cross-artifact Map ID or local-spec ID, public slug, campaign directory, live URL
59
59
  path, template family, and spec identity, doctor freshness, and the URL/order-
60
60
  free QA projection. Safe repository-relative spellings such as
61
61
  `campaign-runtime.build.json` and `./campaign-runtime.build.json` are
62
62
  equivalent; absolute paths, URIs, backslashes, and parent traversal are not.
63
63
 
64
+ Local-spec bundles compare `local_spec_id` across the packet, report, doctor
65
+ output and QA sidecar. Their Map IDs remain null; the QA verdict's
66
+ `campaign_slug` is the storage key `local-spec-<local_spec_id>`. Mixing local
67
+ and saved-Map identities fails conformance; a shared public route is not enough.
68
+
64
69
  Spec identity has two deliberately separate meanings. Build Context
65
70
  `spec.hash` and Assembly Report `identity.spec_hash` retain exact raw-byte
66
71
  integrity. Build Context `spec.material_hash`, Assembly Report
@@ -25,7 +25,7 @@ Ledger schema id: `campaigns-os-release-ledger/v1`
25
25
  Change policy version: `1.0.0`
26
26
  Reason-code vocabulary version: `1.0.0`
27
27
  Limits version: `1.0.0`
28
- Supported surface at generation time: `1.41.2`
28
+ Supported surface at generation time: `1.43.2`
29
29
 
30
30
  ## Forward compatibility
31
31
 
@@ -59,12 +59,16 @@ across them. A progress stream is independent of a run-session ID.
59
59
 
60
60
  Sanitized immutable snapshots are written under the target repository's
61
61
  `.campaign-runtime/progress/` before any request. Allocation uses an exclusive
62
- local lock with a process owner. Dead owners are recovered through an exclusive
63
- recovery claim and an atomic rename; a live process is never evicted. An ownerless
64
- crash gap is recoverable after ten seconds. If recovery itself is interrupted,
65
- capture fails closed: stop all Campaigns OS writers for that target, then remove
66
- the abandoned `.allocation-lock` directory in the affected progress scope before
67
- retrying `next`. Do not remove a lock while a writer is active.
62
+ local lock with a process owner; the lock directory and its owner record are
63
+ published together in one rename, so a lock never exists without its owner.
64
+ Dead owners are recovered through an exclusive recovery claim and an atomic
65
+ rename; a live process is never evicted. A lock directory with no owner record
66
+ (left by an older release) is never taken over: capture refuses it after about
67
+ a second. If one is found, or if recovery itself is interrupted, capture fails
68
+ closed and the warning names the affected `.allocation-lock` directory: stop
69
+ all Campaigns OS writers for that target, then remove that directory before
70
+ retrying `next`. Do not remove a lock while a writer is active. Do not run an
71
+ older Campaigns OS release against the same target at the same time.
68
72
 
69
73
  An unchanged projection reuses its ID, timestamp and sequence. Identity
70
74
  changes start a new stream. Each local scope retains at most 32 snapshots and
@@ -140,3 +144,9 @@ The planned immutable receiver key is
140
144
  revision. A key match identifies scope; it is not authentication or trust. The
141
145
  receiver must verify the digest and authorized Map scope and stamp its own trust.
142
146
  Unknown, incomplete or conflicted histories must never yield a ready workspace.
147
+
148
+ Local-spec packets add optional `identity.local_spec_id`. Report binding compares
149
+ that ID and the local material hash, so local stages can be observed without a
150
+ saved Map. `map_id` and `map_revision_hash` remain null and
151
+ `saved_revision_alignment` remains `unconfirmed`; these observations stay on disk
152
+ with `map_id_missing` and have no portal storage key.
@@ -1,5 +1,13 @@
1
1
  # QA And Test Orders
2
2
 
3
+ For packet-based partial builds, QA honors the Assembly Report's recorded
4
+ `stages.prepare_build.declared_out_of_scope` declarations together with the
5
+ packet's skip mappings. Unbuilt declared pages emit `skipped` evidence with
6
+ reason `out_of_build_scope`; HTTP, browser and commercial checks do not request
7
+ those routes, and entry URLs come from the remaining pages. A materialized
8
+ stock page rejoins QA. Missing in-scope pages still fail normally. A raw skip
9
+ mapping without a recorded declaration does not suppress checks.
10
+
3
11
  The public v0 QA runner is Node/npm-based and does not require access to a private runtime repo.
4
12
 
5
13
  > **Commerce QA requires network; it cannot run in a no-outbound sandbox.** The SDK, product images, fonts, the Netlify preview, and the Playwright typed-card test order all need outbound network. A build environment without it can only validate markup/build/CSS — the commerce runtime and the typed-card test order (the Campaigns OS control) must be deferred to a deployed preview. Always run the QA runner against a `--base-url` preview/production origin (e.g. `npm run campaigns-os -- qa run --packet campaign-runtime.build.json --base-url https://deploy-preview-7--your-site.netlify.app/ --browser --test-order common`); never report commerce-runtime QA as passed from an offline build.
@@ -98,6 +106,11 @@ npm run campaigns-os -- qa resolve --packet campaign-runtime.build.json
98
106
 
99
107
  Resolve reads the packet, loads the local CampaignSpec when available, derives deployed page URLs from the packet deploy URL or `--base-url`, probes the entry URLs it derived, and prints the funnel topology. It does not create a verdict.
100
108
 
109
+ Local-spec QA requires `--packet` and matching `local_spec_id` values in the
110
+ packet, CampaignSpec and Assembly Report, with a current report material hash.
111
+ A Map ID override is refused. See the [local-spec entry](build-packet.md#local-spec-entry)
112
+ for preparation and identity rules.
113
+
101
114
  ### Route reachability
102
115
 
103
116
  A route set derived from the packet is not evidence that the deployment serves
@@ -289,7 +302,7 @@ npm run campaigns-os -- qa run \
289
302
  --base-url https://preview.example.com/campaign/
290
303
  ```
291
304
 
292
- The runner fetches deployed pages, checks route availability, verifies CampaignSpec `sdk_hints.meta_tags` (a key the Campaign Cart SDK does not read, `next-currency` or `next-predictive-address` from `src/sdk-meta-tags.mjs`, is a `warn` row at severity `warn` carrying the shared note, never `manual_review` and never a blocker, whether or not the tag rendered; doctor reports the same key as `sdk_hints.meta_tags.ignored_by_sdk`), writes a local verdict JSON under `<target-repo>/qa-output/<map-id>/<run-id>.json` (the packet's `assembly.target_repo`, else the packet's directory; `--output-dir` overrides it, and a packet-less run uses `qa-output/` under the current directory), and returns exit code `4` when the verdict is blocked. The target's managed ignore block lists `qa-output/`, because full verdicts carry live storefront URLs; the committed form is the `.campaign-runtime/qa-verdict.json` projection.
305
+ The runner fetches deployed pages, checks route availability, verifies CampaignSpec `sdk_hints.meta_tags` (a key the Campaign Cart SDK does not read, `next-currency` or `next-predictive-address` from `src/sdk-meta-tags.mjs`, is a `warn` row at severity `warn` carrying the shared note, never `manual_review` and never a blocker, whether or not the tag rendered; doctor reports the same key as `sdk_hints.meta_tags.ignored_by_sdk`), writes a local verdict JSON under `<target-repo>/qa-output/<qa-storage-key>/<run-id>.json` (the packet's `assembly.target_repo`, else the packet's directory; `--output-dir` overrides it, and a packet-less run uses `qa-output/` under the current directory), and returns exit code `4` when the verdict is blocked. The storage key is the Map ID for saved-Map QA and `local-spec-<local_spec_id>` for local-spec packet QA. The target's managed ignore block lists `qa-output/`, because full verdicts carry live storefront URLs; the committed form is the `.campaign-runtime/qa-verdict.json` projection.
293
306
 
294
307
  ### Automatic commercial parity
295
308
 
@@ -353,7 +366,7 @@ nothing is ever selected by mtime or "latest":
353
366
  ```bash
354
367
  campaigns-os qa promote \
355
368
  --packet campaign-runtime.build.json \
356
- --verdict qa-output/<map-id>/<run-id>.json \
369
+ --verdict qa-output/<qa-storage-key>/<run-id>.json \
357
370
  --json
358
371
  ```
359
372
 
@@ -381,10 +394,12 @@ Report's `identity.spec_material_hash` and the Build Context's
381
394
  raw-byte digest of the spec file (the two meanings are deliberate; see
382
395
  [docs/migration-sidecar-bundle.md](./migration-sidecar-bundle.md)).
383
396
  `campaign_ref_id` is copied from the CampaignSpec's `campaign.ref_id` and
384
- identifies the platform campaign the spec was exported from, not this build:
385
- two specs exported from one platform campaign share it by design, and it is
386
- `null` when the spec carries none. Use `campaign_slug` (the Map ID) and
387
- `public_route_slug` to tell builds apart.
397
+ identifies the configured platform campaign, not this build: two specs for one
398
+ platform campaign share it by design, and it is `null` when the spec carries
399
+ none. `campaign_slug` carries the Map ID for saved-Map verdicts. Local-spec
400
+ verdicts carry explicit `local_spec_id` and use
401
+ `campaign_slug: "local-spec-<local_spec_id>"`; both survive sidecar projection.
402
+ `public_route_slug` is the route, not a substitute for either stable identity.
388
403
 
389
404
  **Trust is stamped by the receiver, never by this CLI.** The QA portal
390
405
  receiver accepts verdict posts publicly (after shape/size/rate checks) and
@@ -512,7 +527,8 @@ Exactly one comparison, against exactly one earlier run:
512
527
 
513
528
  1. **Find the previous run.** The most recent Run Record under the Build
514
529
  Packet's `.campaign-runtime/run-records/` whose `identity.map_id` matches
515
- this campaign. Only the first match counts — walking further back to find a
530
+ this saved Map, or whose `identity.local_spec_id` matches this local spec
531
+ with no Map ID. Only the first match counts — walking further back to find a
516
532
  record that happens to carry usable evidence would compare this run against
517
533
  a non-adjacent one and report anything introduced in between as
518
534
  pre-existing.
@@ -624,7 +640,7 @@ QA evidence redacts checkout request bodies and generated QA emails. Verdict art
624
640
  keep method, URL, response summaries, order refs, line-item summaries, and card last4,
625
641
  but they should not contain full customer address/payment payloads.
626
642
 
627
- QA runs **publish to the QA portal by default** — they appear in the Campaign Map
643
+ Saved-Map QA runs **publish to the QA portal by default** — they appear in the Campaign Map
628
644
  QA tab and the run picker, and the command prints the portal link. No flag needed.
629
645
  Pass `--no-post-verdict` (or `--local-only`) for offline / dev / CI runs that should
630
646
  stay local-only; publishing never fails the QA run if the portal is unreachable.
@@ -636,7 +652,14 @@ local-only, and the output names the destination plus the opt-in
636
652
  (`--post-verdict`, or `campaigns-os telemetry on`). Portal-managed campaigns —
637
653
  spec resolved from the portal for the run — keep publish-by-default regardless
638
654
  of consent: those verdicts are the QA tab's product surface, not telemetry.
639
- Explicit flags always win in both directions.
655
+ Explicit flags always win in both directions for saved-Map QA.
656
+
657
+ A packet with `local_spec_id` always keeps its verdict and progress local.
658
+ `qa run` suppresses portal publication even with `--post-verdict`; the flag
659
+ does not turn a local ID into a Map destination. `qa publish` refuses such a
660
+ packet with `refusal.code: local_spec`, including under `--dry-run` or
661
+ `--republish`. Commerce reads, served-page probes and requested typed-card
662
+ orders still run; Run Telemetry follows its existing consent controls.
640
663
 
641
664
  ```bash
642
665
  npm run campaigns-os -- qa run \
@@ -646,7 +669,7 @@ npm run campaigns-os -- qa run \
646
669
 
647
670
  ### Publish a stored verdict (`qa publish`)
648
671
 
649
- "Run local, publish when clean" is one command, not a rerun. A run kept local
672
+ For saved-Map packets, "run local, publish when clean" is one command, not a rerun. A run kept local
650
673
  with `--no-post-verdict` writes the same full verdict under `qa-output/` and
651
674
  the same committed sidecar as a publishing run; `qa publish` posts that stored
652
675
  verdict to the QA portal through the rail `qa run` uses, without re-running
@@ -678,6 +701,7 @@ order placed — with a named `refusal.code`:
678
701
 
679
702
  | `refusal.code` | What it means |
680
703
  |---|---|
704
+ | `local_spec` | The packet has `local_spec_id` and no saved Map destination. Keep the verdict local; moving to a saved Map requires fresh preparation and evidence. |
681
705
  | `spec_hash_mismatch` | The verdict's `spec_hash` is not the packet's current spec (`spec.local_path`, hashed the way every spec-identity check hashes it, so `sha256:` prefix and case do not matter). A verdict for a spec that has since changed is not evidence about the current one: re-run `qa run`, which publishes by default. The result carries both hashes. |
682
706
  | `spec_hash_absent` | The verdict carries no `spec_hash`. Re-run `qa run`. |
683
707
  | `already_published` | The run's Run Record records this verdict's `run_id` as published (by the run itself, or by an earlier `qa publish`). Pass `--republish` to post it again; the existing portal link is in the output either way. |
@@ -777,9 +801,12 @@ retire that guard once #36 ships and `cartLines` is populated.
777
801
  Analytics correctness has two deliberately separate evidence phases in one QA
778
802
  run:
779
803
 
780
- 1. The campaign-root visit inventories declared providers, containers, pixels,
781
- and other observable tags. It does not prove or disprove Purchase, even if a
782
- stray Purchase-shaped event appears there.
804
+ 1. The inventory visit inventories declared providers, containers, pixels,
805
+ and other observable tags on one page: the campaign root, or a built entry
806
+ when the root cannot be captured (see
807
+ [Which page the inventory captures](#which-page-the-inventory-captures)).
808
+ It does not prove or disprove Purchase, even if a stray Purchase-shaped
809
+ event appears there.
783
810
  2. The existing canonical typed-card order run supplies Purchase evidence. For
784
811
  each planned order, the topology classifier must recognize the final URL as
785
812
  that plan's receipt, then the runner waits the full `--analytics-settle`
@@ -823,6 +850,98 @@ analytics block to gate. The SDK's own data layer is a separate, always-on
823
850
  reading taken on the same order — see [Purchase data layer](#purchase-data-layer-dl_purchase)
824
851
  under Test Orders.
825
852
 
853
+ ### Which page the inventory captures
854
+
855
+ The inventory starts at the campaign root composed from the campaign identity
856
+ (`public_route_slug` plus `route_root`). A partial build (a topology with a
857
+ partial build scope, or pages excluded from the build) may have no page there:
858
+ the root is then whatever the host answers, such as a directory index or a
859
+ generic fallback. So the root is visited only when it is in scope. On a full
860
+ build it always is; on a partial build it is in scope only when a built,
861
+ in-scope topology page is served at the root. When the root is out of scope,
862
+ answers non-2xx, or fails to load (a timeout, a refused connection), the leg
863
+ tries each funnel's built entry in turn: the first in-scope page on a partial
864
+ build (the same entry the partial-scope planner selects), otherwise the first
865
+ entry-like page. It captures the first page that answers 2xx. A response with no
866
+ HTTP status counts as an answer. A closed page or a disconnected browser is not
867
+ a per-page failure: it is the `analytics-correctness:runner` blocker.
868
+
869
+ `analytics-correctness:capture` records the page it used:
870
+ `evidence.capture_page` holds `url` (the URL requested, query redacted),
871
+ `source` (`campaign_root` or `built_entry`), `page_id`, `funnel_id` and
872
+ `http_status`. `evidence.final_url` is the page URL after redirects and
873
+ settling. When a built entry was used, `evidence.root_fallback` says why the
874
+ root was not: `reason` is `out_of_built_scope`, `non_2xx` (with the root's
875
+ `http_status`), or `navigation_error` (with its `error_code`).
876
+
877
+ When no page is captured, the leg emits `analytics-correctness:capture` alone,
878
+ with no per-vendor assertion measured against an empty page. There are two
879
+ outcomes, and `evidence.attempts` lists each page tried:
880
+
881
+ | `evidence.reason` | When | Result |
882
+ |---|---|---|
883
+ | `no_in_scope_page_captured` | The root is out of the built scope and no built entry other than the root is left to try, so nothing was loaded | `skipped`: there is no page whose tags could be measured |
884
+ | `no_capture_page_answered` | At least one page was tried, and every one answered non-2xx or failed to load | `FAIL`/`BLOCKER`: the declared analytics went unmeasured, so a later passing order cannot report the run ready |
885
+
886
+ Step routing is path-based in every certified family. Page-kit builds each
887
+ page to its own `<route>/index.html`, and QA strips the query string from a
888
+ CampaignSpec route. So `/campaign`, `/campaign/` and `/campaign/index.html` are
889
+ one page, and a query string does not name a different page. A topology page
890
+ whose own URL declares a query (for example `/campaign/?step=checkout`) is
891
+ still never merged into the root on its path alone. Unless its query is
892
+ exactly the root's own (parameter order aside), it does not put the root in
893
+ scope, it is captured as its own entry, and its `capture_page` carries
894
+ `query_routed: true`, since the redacted URL alone would read as the root. An
895
+ entry on a different path is never marked `query_routed`, whatever query it
896
+ carries. A URL with no query of its own names the page at that path whatever
897
+ query the other URL carries.
898
+
899
+ ### Local-serve review (`manual_review`)
900
+
901
+ A local proof run renders the development environment on purpose (see
902
+ [Local proof mode](#local-proof-mode-deploytarget-local-serve)), and the
903
+ starter templates gate every vendor loader out of that render. A pixel that
904
+ did not fire there is the render's design, not a campaign defect. So on a
905
+ local-serve run, a failing fire-dependent check becomes `manual_review` at
906
+ `warn` instead of a blocker. The fire-dependent checks are
907
+ `analytics-correctness:tag:*`, `analytics-correctness:oob:*` and
908
+ `analytics-correctness:purchase-fires`.
909
+
910
+ The run qualifies only when all of these hold:
911
+
912
+ - the packet's `deploy.target` is `local-serve`;
913
+ - the analytics capture target (else the base URL) is a loopback URL;
914
+ - the Assembly Report records the development render:
915
+ `stages.assembly.evidence.build_environment` is `development`. A production
916
+ build served on localhost, or a build whose environment was never recorded,
917
+ keeps its blockers.
918
+
919
+ Each failing check is then downgraded only when the page it measured is on
920
+ record as loopback. For `tag:*` and `oob:*`, the check's own URL, the passing
921
+ capture's `capture_page.url` and its `final_url` must all be loopback, so a
922
+ built-entry fallback on a remote host, or a localhost root that redirected to
923
+ a production host, keeps the blocker. For `purchase-fires`, there must be at
924
+ least one judged receipt, and every one needs a loopback `receipt_url` and
925
+ `receipt_document_url` (the page URL read after the receipt analytics
926
+ settled). A missing or unparseable URL keeps the blocker.
927
+
928
+ What always stays a blocker:
929
+
930
+ - `analytics-correctness:data-layer-purchase:<path>`. It counts the SDK's own
931
+ `dl_purchase`, which the development render still pushes, so a miss on
932
+ localhost can be a real defect.
933
+ - A capture or runner failure: `analytics-correctness:runner`, a check whose
934
+ evidence carries an `error_code`, and a `purchase-fires` reading whose
935
+ `capture_error_plan_ids` is missing or not empty. The environment explains a
936
+ silent pixel, not an unmeasured one.
937
+
938
+ A downgraded check keeps its evidence and adds `reason:
939
+ local_serve_development_render`, `local_serve_status: fail`, the recorded
940
+ `build_environment`, the recorded `production_parity` (`status:
941
+ not_recorded` when none is on the report, with a note unless it passed), and a
942
+ `follow_up`: re-run `qa run` against the PR preview (a production render) with
943
+ `--base-url <preview-url>`. That run gates these checks.
944
+
826
945
  ## Analytics parity (dataLayer / GTM)
827
946
 
828
947
  The analytics-parity leg proves the live **dataLayer event stream + GTM/pixel
@@ -842,14 +961,22 @@ npm run campaigns-os -- qa run \
842
961
  ```
843
962
 
844
963
  This receipt-to-receipt parity example names `--analytics-candidate`
845
- explicitly. When that flag is omitted, the candidate is the campaign identity's
846
- composed root (`public_route_slug` plus `route_root`), not the raw
847
- `--base-url` value.
964
+ explicitly, and that URL is captured as given. When that flag is omitted, the
965
+ candidate is chosen the same way as the correctness inventory (see
966
+ [Which page the inventory captures](#which-page-the-inventory-captures)): the
967
+ campaign identity's composed root (`public_route_slug` plus `route_root`), not
968
+ the raw `--base-url` value, when it is in scope and answers 2xx, else the first
969
+ built entry that does. `analytics-parity:capture` then records the same
970
+ `capture_page` and `root_fallback` evidence. The candidate is captured before
971
+ the baseline, and when no candidate page is captured the leg emits
972
+ `analytics-parity:capture` alone, without loading the baseline:
973
+ `no_in_scope_page_captured` (skipped) or `no_capture_page_answered`
974
+ (`FAIL`/`BLOCKER`).
848
975
 
849
976
  | Flag | Meaning |
850
977
  |---|---|
851
978
  | `--analytics-baseline <url>` | Legacy funnel URL to capture as the parity baseline (enables the leg) |
852
- | `--analytics-candidate <url>` | Migrated URL to capture; defaults to the identity-composed campaign root |
979
+ | `--analytics-candidate <url>` | Migrated URL to capture as given; defaults to the identity-composed campaign root, or the first built entry when that root is out of the built scope or does not answer |
853
980
  | `--analytics-hosts a,b` | Extra host substrings to treat as analytics tag-fires (Everflow is built in) |
854
981
  | `--analytics-settle <ms>` | Wait after analytics page loads and after a recognized typed-order receipt for async tags to fire (default 5000); receipt settling must fit inside the order deadline |
855
982
 
@@ -1674,6 +1801,19 @@ review. Nested Google Maps/payment keys and inert HTML do not count as campaign
1674
1801
  credentials. This small static grammar deliberately leaves many real pages
1675
1802
  unknown; a literal inside arbitrary code is not proof of effective configuration.
1676
1803
 
1804
+ A page script that does not parse is not treated as dynamic. The browser throws
1805
+ a `SyntaxError` on it and nothing in it runs, so the binding reads its
1806
+ declarations as unavailable (`script_unavailable_or_limit`) and QA adds a
1807
+ separate `script-parse:<page_id>` blocker in the same `api-metadata` family.
1808
+ Its `actual` names each script by path (inline scripts as `inline script`)
1809
+ with the line and column, and its evidence lists a fixed diagnostic category
1810
+ per script, never text from the script. Classic scripts are parsed as scripts
1811
+ and `type="module"` scripts as modules, with the type stripped of surrounding
1812
+ ASCII whitespace and compared case-insensitively as the browser does; classic
1813
+ `nomodule` scripts are not fetched or parsed (a module script ignores
1814
+ `nomodule` and is parsed), and script srcs resolve against the page's first `<base href>`. Doctor runs the same parse over the built output before deploy; see
1815
+ `built_output.script_syntax` in [the Build Packet doc](build-packet.md).
1816
+
1677
1817
  External executable scripts other than the recognized jsDelivr Campaign Cart
1678
1818
  loader/index are inspected only on the page's origin. Each page admits at most
1679
1819
  6 such references; each run fetches at most 24 distinct URLs (deduplicated),
@@ -118,7 +118,8 @@ be recorded without the bytes actually moving somewhere.
118
118
  ### Fixes that touch only policy-ignored paths
119
119
 
120
120
  A fix living entirely in paths the policy ignores — `src/` other than
121
- `src/cli.mjs`, `scripts/`, tests and fixtures — carries a same-surface CHANGELOG
121
+ `src/cli.mjs`, `src/agent/` and `src/doctor/`, `scripts/`, tests and
122
+ fixtures — carries a same-surface CHANGELOG
122
123
  section (`X.Y.Z+agent.N`) and **no ledger entry**. There is nothing for an entry
123
124
  to claim: every change item must map to a classified changed path in the range,
124
125
  and an ignored path is never classified, so an entry written for such a PR is
@@ -127,9 +128,10 @@ path-less item fails the same way, because no classified change of its class
127
128
  exists in the range. The ignore list and its stated reasons are in
128
129
  [`contracts/agent-relevant-change-policy.v1.json`](../contracts/agent-relevant-change-policy.v1.json).
129
130
 
130
- The dividing line inside `src/` is `src/cli.mjs`: an explicit rule classifies it
131
- as `cli_surface`, so any change to it is agent-relevant and owes an entry, even
132
- when the behaviour change originates in a helper module beside it.
131
+ The classified paths inside `src/` are `src/cli.mjs`, `src/agent/` and
132
+ `src/doctor/`: an explicit rule classifies each as `cli_surface`, so any change
133
+ there is agent-relevant and owes an entry, even when the behaviour change
134
+ originates in a helper module beside it.
133
135
 
134
136
  ### Amendments
135
137
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  How a checkout of this repository at one commit becomes a usable installed runtime, and how a consumer decides whether a prepared one is still trustworthy. Everything below is generated from `contracts/runtime-recipe.campaigns-os-node-v1.json`, which is the only authority for these values.
10
10
 
11
- Recipe kind `campaigns-os-node-v1`, revision `1.0.2`, validated by `schemas/campaigns-os-runtime-recipe.v1.schema.json` (`Campaigns OS Runtime Recipe v1`). Supported surface at generation time: `1.41.2`.
11
+ Recipe kind `campaigns-os-node-v1`, revision `1.0.2`, validated by `schemas/campaigns-os-runtime-recipe.v1.schema.json` (`Campaigns OS Runtime Recipe v1`). Supported surface at generation time: `1.43.2`.
12
12
 
13
13
  ## What this is
14
14
 
@@ -16,7 +16,7 @@ that the copy on disk moved.
16
16
  `skills.json` carries one top-level field:
17
17
 
18
18
  ```json
19
- "bundle_revision": "1.41.2+skills.1"
19
+ "bundle_revision": "1.43.2+skills.1"
20
20
  ```
21
21
 
22
22
  The spelling is `<package version>+skills.<n>`:
@@ -25,7 +25,7 @@ The spelling is `<package version>+skills.<n>`:
25
25
  skills ship with (`check-skill-versions.mjs` fails if the two disagree);
26
26
  - `<n>` is a plain counter, not a semver component. It says "this is the *n*th
27
27
  skill-text revision published against that package version" and it **resets
28
- with the prefix**. `1.41.2+skills.1` is therefore ahead of `1.40.0+skills.7`.
28
+ with the prefix**. `1.43.2+skills.1` is therefore ahead of `1.40.0+skills.7`.
29
29
 
30
30
  It is one identity for the bundle as a whole, on purpose. Per-skill versions
31
31
  still exist and still gate per-skill changes, but an agent that loaded one skill
@@ -37,7 +37,7 @@ The first body line of every bundled `SKILL.md`, immediately after the
37
37
  frontmatter, is exactly:
38
38
 
39
39
  ```
40
- Bundle revision: 1.41.2+skills.1
40
+ Bundle revision: 1.43.2+skills.1
41
41
  ```
42
42
 
43
43
  followed by a short paragraph telling the agent to run the check below at the
@@ -48,7 +48,7 @@ text the agent is actually reading, not from a file it would have to go and open
48
48
  ## The check
49
49
 
50
50
  ```bash
51
- npx --no-install campaigns-os tooling status --skills-revision 1.41.2+skills.1
51
+ npx --no-install campaigns-os tooling status --skills-revision 1.43.2+skills.1
52
52
  ```
53
53
 
54
54
  The value is compared against the bundle revision of the **CLI the command runs
@@ -90,20 +90,20 @@ reports the choice as `skills.scope` (`requested`, `installed_platforms`, or
90
90
  "revision_check": "match",
91
91
  "skills_revision": {
92
92
  "status": "match",
93
- "requested": "1.41.2+skills.1",
93
+ "requested": "1.43.2+skills.1",
94
94
  "spelling": "bundle",
95
- "on_disk": "1.41.2+skills.1",
95
+ "on_disk": "1.43.2+skills.1",
96
96
  "on_disk_skill": null,
97
- "message": "match (1.41.2+skills.1)"
97
+ "message": "match (1.43.2+skills.1)"
98
98
  }
99
99
  ```
100
100
 
101
101
  The text view prints one named line, as a header above the rest of the status:
102
102
 
103
103
  ```
104
- Skills revision: match (1.41.2+skills.1)
105
- Skills revision: mismatch: loaded 1.39.0+skills.1, on disk 1.41.2+skills.1 — start a fresh session
106
- Skills revision: unchecked (on disk 1.41.2+skills.1)
104
+ Skills revision: match (1.43.2+skills.1)
105
+ Skills revision: mismatch: loaded 1.39.0+skills.1, on disk 1.43.2+skills.1 — start a fresh session
106
+ Skills revision: unchecked (on disk 1.43.2+skills.1)
107
107
  ```
108
108
 
109
109
  `unchecked` is the state when the flag is absent. It is not an error — an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextcommerce/campaigns-os",
3
- "version": "1.41.2",
3
+ "version": "1.43.2",
4
4
  "description": "Toolkit for agent-assisted NEXT campaign builds.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -149,6 +149,7 @@
149
149
  "docs/diagnostics.md",
150
150
  "docs/progress-snapshots.md",
151
151
  "demo",
152
- "docs/demo-preview.md"
152
+ "docs/demo-preview.md",
153
+ "docs/local-setup.md"
153
154
  ]
154
155
  }
@@ -28,8 +28,13 @@
28
28
  "type": "object",
29
29
  "additionalProperties": true,
30
30
  "required": ["map_id", "public_route_slug", "campaign_directory", "live_url_path", "spec_hash"],
31
+ "oneOf": [
32
+ { "properties": { "map_id": { "type": "string", "minLength": 1 } }, "not": { "required": ["local_spec_id"] } },
33
+ { "required": ["local_spec_id", "spec_material_hash"], "properties": { "map_id": { "type": "null" } } }
34
+ ],
31
35
  "properties": {
32
- "map_id": { "type": "string", "minLength": 1 },
36
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
37
+ "map_id": { "type": ["string", "null"], "minLength": 1 },
33
38
  "public_route_slug": { "type": "string", "minLength": 1 },
34
39
  "campaign_directory": { "type": "string", "minLength": 1 },
35
40
  "live_url_path": { "type": "string", "minLength": 1 },
@@ -66,9 +66,14 @@
66
66
  "type": "object",
67
67
  "additionalProperties": false,
68
68
  "required": ["map_id"],
69
+ "oneOf": [
70
+ { "properties": { "map_id": { "type": "string", "minLength": 1 } }, "not": { "required": ["local_spec_id"] } },
71
+ { "required": ["local_spec_id", "local_path"], "properties": { "map_id": { "type": "null" }, "local_path": { "type": "string", "minLength": 1 }, "spec_url": { "type": "null" } } }
72
+ ],
69
73
  "properties": {
74
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
70
75
  "map_id": {
71
- "type": "string",
76
+ "type": ["string", "null"],
72
77
  "minLength": 1,
73
78
  "description": "Saved Map Builder identity used for reopen, /api/spec/<map-id>, QA verdicts, and provenance."
74
79
  },
@@ -6,6 +6,8 @@
6
6
  "type": "object",
7
7
  "additionalProperties": true,
8
8
  "required": ["schema_version", "funnels"],
9
+ "if": { "required": ["spec_identity"], "properties": { "spec_identity": { "required": ["local_spec_id"] } } },
10
+ "then": { "not": { "required": ["map_id"] } },
9
11
  "properties": {
10
12
  "schema_version": {
11
13
  "enum": ["4.2", "4.3"],
@@ -364,8 +366,10 @@
364
366
  "specIdentity": {
365
367
  "type": "object",
366
368
  "additionalProperties": true,
369
+ "not": { "required": ["map_id", "local_spec_id"] },
367
370
  "properties": {
368
371
  "source": { "type": "string", "description": "Observed values: campaign-map-builder, hand-authored-simulation, local-experimental." },
372
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
369
373
  "map_id": { "type": "string" },
370
374
  "id": { "type": "string" },
371
375
  "map_url": { "type": "string" },
@@ -71,6 +71,7 @@
71
71
  "build_fingerprint_algorithm"
72
72
  ],
73
73
  "properties": {
74
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$" },
74
75
  "map_id": {
75
76
  "type": [
76
77
  "string",
@@ -29,6 +29,7 @@
29
29
  "description": "Same literal as the full verdict — the sidecar is the same contract projected, not a second schema lineage."
30
30
  },
31
31
  "run_id": { "type": "string", "minLength": 1 },
32
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
32
33
  "campaign_slug": {
33
34
  "type": "string",
34
35
  "minLength": 1,
@@ -29,6 +29,7 @@
29
29
  "minLength": 1,
30
30
  "description": "The QA run identity, also the verdict filename under qa-output/<map-id>/. The portal receiver additionally restricts it to [A-Za-z0-9_-]{1,64}."
31
31
  },
32
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
32
33
  "campaign_slug": {
33
34
  "type": "string",
34
35
  "minLength": 1,
@@ -155,6 +155,7 @@
155
155
  "additionalProperties": false,
156
156
  "description": "Best-effort run identity — the join keys that make telemetry useful. Missing identity never blocks capture.",
157
157
  "properties": {
158
+ "local_spec_id": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions. Null or omitted means no local identity." },
158
159
  "map_id": { "type": ["string", "null"] },
159
160
  "campaign_slug": { "type": ["string", "null"] },
160
161
  "template_family": { "type": ["string", "null"] },
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-lifecycle-orientation
3
- version: 1.0.3
3
+ version: 1.0.9
4
4
  description: Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing any stage or changing any state.
5
5
  ---
6
6
 
7
- Bundle revision: 1.41.2+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.41.2+skills.1`
7
+ Bundle revision: 1.43.2+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.43.2+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -70,9 +70,13 @@ output sidecar, theme evidence and normalized inputs
70
70
  separate system, and QA then tests a deployed URL
71
71
  (`docs/qa-and-test-orders.md`).
72
72
 
73
- Keep the two identities apart. The **Map ID** identifies the saved campaign map
74
- and keys QA evidence storage; the **public route slug** is the shopper-facing
75
- path segment. Read both from the packet and never substitute one for the other.
73
+ Keep stable campaign identity separate from routing. The **Map ID** identifies
74
+ a saved campaign map and keys its QA evidence storage. A local-spec packet
75
+ instead carries **local_spec_id**, keeps `map_id` null, and stores QA under
76
+ `local-spec-<local_spec_id>`. The **public route slug** is the shopper-facing
77
+ path segment. Read the packet's identity kind and route separately; a shared
78
+ route cannot make evidence from another local spec belong to this campaign
79
+ (`docs/build-packet.md`, "Local-spec entry").
76
80
 
77
81
  Campaign pages are typed. The page-type vocabulary is `presell`, `landing`,
78
82
  `select`, `checkout`, `upsell`, `downsell` and `thankyou`; `select` is where a
@@ -95,8 +99,9 @@ only from implementation files this skill may not cite, so do not branch on one.
95
99
  ## Read the stage record
96
100
 
97
101
  `campaigns-os next --packet <packet> --json` (tier `A`: it captures a progress
98
- snapshot under the target and, under Run Telemetry consent, POSTs that
99
- observation off the machine) reads the recorded state and names the next
102
+ snapshot under the target and, for saved-Map packets under Run Telemetry
103
+ consent, can POST that observation off the machine; local-spec observations
104
+ stay local) reads the recorded state and names the next
100
105
  incomplete stage among `setup`, `build`, `polish`, `deploy` and `qa`. That is a
101
106
  writing, potentially sending command. If all you need is where the run stands,
102
107
  prefer the readback below.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-readback-classification
3
- version: 1.0.3
3
+ version: 1.0.9
4
4
  description: Classify a selected campaign from the readback projection's v2 fields and write a read-only handoff without turning diagnosis into permission.
5
5
  ---
6
6
 
7
- Bundle revision: 1.41.2+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.41.2+skills.1`
7
+ Bundle revision: 1.43.2+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.43.2+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read