@nextcommerce/campaigns-os 1.48.0 → 1.50.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 (38) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/agents/claude/CLAUDE.md +5 -4
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +173 -0
  8. package/contracts/release-ledger.json +333 -0
  9. package/contracts/supported-surface.json +4 -4
  10. package/docs/brand-theme-bridge.md +12 -6
  11. package/docs/build-packet.md +8 -3
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/qa-and-test-orders.md +21 -7
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/package.json +1 -1
  18. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  19. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +5 -4
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/brand-theme.mjs +13 -2
  31. package/src/cli.mjs +11 -9
  32. package/src/install-mode.mjs +0 -8
  33. package/src/invocation.mjs +3 -1
  34. package/src/qa-binding-evidence.mjs +21 -0
  35. package/src/qa-browser.mjs +30 -1
  36. package/src/qa-node.mjs +10 -2
  37. package/src/stage-record.mjs +303 -22
  38. package/src/theme-gate.mjs +3 -3
@@ -146,7 +146,11 @@
146
146
  "const": "campaigns-os-page-binding/v0"
147
147
  },
148
148
  "observation": {
149
- "const": "static_declaration"
149
+ "enum": [
150
+ "static_declaration",
151
+ "sdk_request"
152
+ ],
153
+ "description": "static_declaration: the key the page's HTML and same-origin config scripts declare. sdk_request: the key the Campaign Cart SDK sent to the Campaigns API in the browser pass (qa run --browser)."
150
154
  },
151
155
  "outcome": {
152
156
  "enum": [
@@ -174,7 +178,8 @@
174
178
  "enum": [
175
179
  "meta",
176
180
  "inline",
177
- "config_script"
181
+ "config_script",
182
+ "sdk_request"
178
183
  ]
179
184
  }
180
185
  },
@@ -182,7 +187,7 @@
182
187
  "const": "not_verified"
183
188
  }
184
189
  },
185
- "description": "Credential-free static declaration comparison, not observed execution or Campaign App identity. No keys, hashes, fragments, source URLs or inferred resource IDs."
190
+ "description": "Credential-free comparison of the page's key with the expected key: the static declaration, or the key the SDK sent in the browser pass. Not Campaign App identity. No keys, hashes, fragments, source URLs or inferred resource IDs."
186
191
  },
187
192
  "pageUrlRef": {
188
193
  "type": "object",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-lifecycle-orientation
3
- version: 1.0.22
3
+ version: 1.0.24
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.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-readback-classification
3
- version: 1.0.22
3
+ version: 1.0.24
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.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-run-evidence
3
- version: 1.0.22
3
+ version: 1.0.24
4
4
  description: Interpret existing Campaigns OS doctor, QA and proof-depth evidence without claiming more proof than the artifacts contain.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: contribution-intake
3
- version: 1.0.22
3
+ version: 1.0.24
4
4
  description: Turn a suggestion about the agent surface into a classified, evidence-checked proposal and, only with attended approval, one issue on this repository's tracker.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-build
3
- version: 1.0.27
3
+ version: 1.0.29
4
4
  description: Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -107,6 +107,6 @@ Build rules:
107
107
  - Run page-kit build and SDK/template lint available in the target repo.
108
108
  - Record build with `campaigns-os record build --packet <packet>` after every page-kit build (tier `C`: it overwrites `stages.assembly` and, when the output changed, resets `stages.polish` to `required` in the assembly report, and stamps the doctor output stale; `--dry-run` is tier `none`). It stamps `stages.assembly.build_fingerprint` with the fingerprint doctor computes from `_site/<slug>/` and the Design Source Package material fingerprint when the report has one. Never type or copy these fields by hand.
109
109
  - Capture the machine-readable build summary as an artifact: `npx campaign-build --json > .campaign-runtime/page-kit-build-summary.json` (requires `next-campaign-page-kit` >= 0.1.4). Doctor's `built_output.build_summary` check verifies per-page build status and Page Kit shape warnings (`NESTED_NO_PERMALINK`, `DUPLICATE_OUTPUT`, `MISSING_FRONTMATTER`, `LAYOUT_NOT_FOUND`, `NO_CAMPAIGN`) from this artifact. If the installed page-kit predates `--json`, record that in the assembly report instead of skipping silently.
110
- - Update the assembly report with commands, evidence, warnings, blockers, and next owner. If a brand theme was applied, record `report.theme.status`, `css_path`, `commerce_pages`, `load_order=after-next-core`, evidence, and any first repair-loop defect.
110
+ - Update the assembly report with commands, evidence, warnings, blockers, and next owner. If a brand theme was applied, run `campaigns-os record theme --packet <p>` (tier `C`) after `record build`: it reads each built commerce page's stylesheet links and records `report.theme` (status applied, `load_order=after-next-core`, `css_path`, `commerce_pages`, evidence). It refuses, writing nothing, when a page that loads `next-core.css` does not load the brand layer after it. Don't hand-edit `report.theme`; a first repair-loop defect goes through `record polish`.
111
111
 
112
112
  Build does not replace polish or QA. Hand off to `next-campaigns-polish` when the campaign is runnable.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os
3
- version: 1.0.42
3
+ version: 1.0.44
4
4
  description: Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os-setup
3
- version: 2.0.25
3
+ version: 2.0.27
4
4
  description: Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly installed as next-campaigns-setup; renamed 2026-08 to stop colliding with the published NextCommerceCo/skills scaffolder of that name.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-polish
3
- version: 1.1.26
3
+ version: 1.1.28
4
4
  description: Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
@@ -48,7 +48,8 @@ Use this after build has produced a runnable page-kit campaign.
48
48
  Theme gate: `campaigns-os next polish` (tier `A`, like every `next` form: additive writes under `.campaign-runtime/` plus a stage-progress POST under Run Telemetry consent; `--no-write` and `--no-remit` are each tier `B`) blocks when theme inspect found a
49
49
  generatable brand theme that is not yet applied to commerce pages. Do not work
50
50
  around the gate — apply the brand layer (`theme generate`, tier `B`; copy into
51
- campaign assets, load after `next-core.css`, record `report.theme`) or record an
51
+ campaign assets, load after `next-core.css`, rebuild, `campaigns-os record build`,
52
+ then `campaigns-os record theme --packet <p>`, tier `C`) or record an
52
53
  explicit waiver (`campaigns-os theme waive --packet <p> --reason "<why>" --waived-by "<named human>"` — tier `C`, because the waiver overwrites the assembly report and doctor output; placeholders are refused).
53
54
 
54
55
  Responsibilities:
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-qa
3
- version: 1.3.26
3
+ version: 1.3.28
4
4
  description: Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof.
5
5
  ---
6
6
 
7
- Bundle revision: 1.48.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.48.0+skills.1`
7
+ Bundle revision: 1.50.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+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
package/skills.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "campaigns-os-skills",
3
3
  "description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
4
- "bundle_revision": "1.48.0+skills.1",
4
+ "bundle_revision": "1.50.0+skills.1",
5
5
  "homepage": "https://github.com/NextCommerceCo/campaigns-os",
6
6
  "retired_skills": [
7
7
  {
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "id": "next-campaigns-os",
18
18
  "name": "Campaigns OS Lifecycle",
19
- "version": "1.0.42",
19
+ "version": "1.0.44",
20
20
  "path": "skills/next-campaigns-os/SKILL.md",
21
21
  "domain": "campaigns",
22
22
  "description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "id": "next-campaigns-os-setup",
26
26
  "name": "Campaigns OS Setup",
27
- "version": "2.0.25",
27
+ "version": "2.0.27",
28
28
  "path": "skills/next-campaigns-os-setup/SKILL.md",
29
29
  "domain": "campaigns",
30
30
  "description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
@@ -32,7 +32,7 @@
32
32
  {
33
33
  "id": "next-campaigns-build",
34
34
  "name": "Campaign Build",
35
- "version": "1.0.27",
35
+ "version": "1.0.29",
36
36
  "path": "skills/next-campaigns-build/SKILL.md",
37
37
  "domain": "campaigns",
38
38
  "description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
@@ -40,7 +40,7 @@
40
40
  {
41
41
  "id": "next-campaigns-polish",
42
42
  "name": "Campaign Polish",
43
- "version": "1.1.26",
43
+ "version": "1.1.28",
44
44
  "path": "skills/next-campaigns-polish/SKILL.md",
45
45
  "domain": "campaigns",
46
46
  "description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
@@ -48,7 +48,7 @@
48
48
  {
49
49
  "id": "next-campaigns-qa",
50
50
  "name": "Campaign QA",
51
- "version": "1.3.26",
51
+ "version": "1.3.28",
52
52
  "path": "skills/next-campaigns-qa/SKILL.md",
53
53
  "domain": "campaigns",
54
54
  "description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "id": "campaign-lifecycle-orientation",
58
58
  "name": "Campaign Lifecycle Orientation",
59
- "version": "1.0.22",
59
+ "version": "1.0.24",
60
60
  "path": "skills/campaign-lifecycle-orientation/SKILL.md",
61
61
  "domain": "campaigns",
62
62
  "description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
@@ -64,7 +64,7 @@
64
64
  {
65
65
  "id": "campaign-run-evidence",
66
66
  "name": "Campaign Run Evidence",
67
- "version": "1.0.22",
67
+ "version": "1.0.24",
68
68
  "path": "skills/campaign-run-evidence/SKILL.md",
69
69
  "domain": "campaigns",
70
70
  "description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "id": "campaign-readback-classification",
74
74
  "name": "Campaign Readback Classification",
75
- "version": "1.0.22",
75
+ "version": "1.0.24",
76
76
  "path": "skills/campaign-readback-classification/SKILL.md",
77
77
  "domain": "campaigns",
78
78
  "description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
@@ -80,7 +80,7 @@
80
80
  {
81
81
  "id": "contribution-intake",
82
82
  "name": "Contribution Intake",
83
- "version": "1.0.22",
83
+ "version": "1.0.24",
84
84
  "path": "skills/contribution-intake/SKILL.md",
85
85
  "domain": "campaigns",
86
86
  "description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
@@ -25,7 +25,7 @@ export const THEME_REPORT_STATUSES = new Set(["applied", "skipped", "blocked", "
25
25
  export const THEME_CONFIDENCES = new Set(["high", "medium", "low", "none"]);
26
26
 
27
27
  const SKIP_DIRS = new Set([".git", "node_modules", "_site", "dist", "build", ".next", "coverage", "qa-output"]);
28
- const BRAND_LAYER_FILENAMES = new Set(["brand-theme.css", "checkout-brand.css"]);
28
+ export const BRAND_LAYER_FILENAMES = new Set(["brand-theme.css", "checkout-brand.css"]);
29
29
 
30
30
  function isObject(value) {
31
31
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -574,6 +574,17 @@ function discoverThemeCandidates({ sourceRoot, pageMappings = [], manifest = nul
574
574
  candidates.push(candidateFromFile(path, role, source, referencedBy));
575
575
  }
576
576
 
577
+ // A stylesheet that pages of different roles link is the funnel's shared
578
+ // layer, not the first linking page's: it ranks as shared.
579
+ function addPageReference(path, role, referencedBy) {
580
+ const existing = seenFiles.has(resolve(path))
581
+ ? candidates.find((candidate) => candidate.source === "mapped_html_reference" && resolve(candidate.path) === resolve(path))
582
+ : null;
583
+ if (!existing) return addFile(path, role, "mapped_html_reference", referencedBy);
584
+ existing.referenced_by.push(...referencedBy);
585
+ if (existing.role !== role) existing.role = "shared";
586
+ }
587
+
577
588
  for (const conventional of [
578
589
  "assets/css/tokens.css",
579
590
  "assets/css/landing/tokens.css",
@@ -595,7 +606,7 @@ function discoverThemeCandidates({ sourceRoot, pageMappings = [], manifest = nul
595
606
  const content = readFileSync(htmlPath, "utf8");
596
607
  for (const ref of extractCssRefs(content)) {
597
608
  for (const cssPath of candidateCssPathsForRef(sourceRoot, htmlPath, ref)) {
598
- addFile(cssPath, candidateRoleFromPath(ref, role), "mapped_html_reference", [{ page_id: mapping.page_id || null, path: mapping.path, ref }]);
609
+ addPageReference(cssPath, candidateRoleFromPath(ref, role), [{ page_id: mapping.page_id || null, path: mapping.path, ref }]);
599
610
  }
600
611
  }
601
612
  }
package/src/cli.mjs CHANGED
@@ -324,6 +324,8 @@ Usage:
324
324
  campaigns-os record setup --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # record setup complete once the campaign output directory exists: Build Context scaffold.required=false and stages.setup completed, validated against their schemas before either is written
325
325
  campaigns-os record build --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
326
326
  campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
327
+ campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
328
+ campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs; refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
327
329
  campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
328
330
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
329
331
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -1069,8 +1071,8 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1069
1071
  }
1070
1072
 
1071
1073
  if (command === "record") {
1072
- const { recordStageCommand } = await import("./stage-record.mjs");
1073
- writeResult(recordStageCommand(args), args, 0);
1074
+ const { recordCommand } = await import("./stage-record.mjs");
1075
+ writeResult(await recordCommand(args), args, 0);
1074
1076
  return;
1075
1077
  }
1076
1078
 
@@ -4466,8 +4468,9 @@ function themeStarterPaletteAdvisory(themeGate, packetPath, residueState) {
4466
4468
  + `\`${cmd("theme")} waive --packet ${packetArg} --reason "<why the starter palette is acceptable>"\` `
4467
4469
  + "— which downgrades those rows to warn severity and keeps the shipped palette visible in the "
4468
4470
  + "verdict; or hand-author the brand layer (write brand-theme.css, list it after next-core.css in "
4469
- + "commerce-page frontmatter styles, rebuild, then record report.theme.status=applied with "
4470
- + "load_order=after-next-core), per docs/brand-theme-bridge.md. This notice waives nothing on its own.",
4471
+ + "commerce-page frontmatter styles, rebuild, then run "
4472
+ + `\`${cmd("record")} build --packet ${packetArg}\` and \`${cmd("record")} theme --packet ${packetArg}\`), `
4473
+ + "per docs/brand-theme-bridge.md. This notice waives nothing on its own.",
4471
4474
  };
4472
4475
  }
4473
4476
 
@@ -4650,7 +4653,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4650
4653
  } else if (result.stage === "deploy") {
4651
4654
  if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4652
4655
  const plan = localServePlan(packet);
4653
- push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then record the localhost URL on deploy.preview_url and stages.deploy in the assembly report. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
4656
+ push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${packetPath} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
4654
4657
  } else {
4655
4658
  push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"}, then record deploy.preview_url (or production_url) on the packet and stages.deploy in the assembly report.`);
4656
4659
  }
@@ -4814,7 +4817,7 @@ Rules:
4814
4817
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
4815
4818
  - For two-step package-selection flows, treat the selector page as the pre-checkout step and pass the selected cart to checkout with forcePackageId; preserve normal tracking params and strip forcePackageId from visible checkout URLs after SDK initialization.
4816
4819
  - After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
4817
- - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, record report.theme.status, css_path, commerce_pages, load_order=after-next-core, evidence, and any repair-loop defect.
4820
+ - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
4818
4821
  - Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet)}`;
4819
4822
  }
4820
4823
 
@@ -4925,9 +4928,8 @@ Read first:
4925
4928
  Nothing ships anywhere: the page-kit build produces _site/ output and you serve ${serveDir} on localhost (any static server, any port) for QA. Localhost on any port is a Campaigns App Development domain, so the SDK initialises there without an origin allowlist entry and Campaigns analytics events are suppressed.
4926
4929
 
4927
4930
  Once the server is up:
4928
- 1. Record the localhost URL (origin plus ${liveUrlPath}) on the packet at deploy.preview_url.
4929
- 2. Update the assembly report's stages.deploy.status to "completed" with that URL and the serve command in outputs.
4930
- 3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
4931
+ 1. Run \`${cmd("record")} deploy --packet ${packetPath} --base-url <localhost origin>${liveUrlPath}\`. It requests every built page under that URL, then records the URL on the packet at deploy.preview_url and stages.deploy as completed with the URL in outputs; it refuses, writing nothing, if a page does not answer.
4932
+ 2. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
4931
4933
 
4932
4934
  If the served build cannot be reached, set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
4933
4935
  }
@@ -282,11 +282,3 @@ export function invocationPrefixFor(root, pkg = null) {
282
282
  prefixCache.set(root, prefix);
283
283
  return prefix;
284
284
  }
285
-
286
- // Rewrites every command spelled with the canonical bare `campaigns-os <verb>`
287
- // into the given prefix. Internal bookkeeping (deviation tracking, gate
288
- // registries, tests) keeps the canonical spelling; only what is printed or
289
- // emitted for an operator or agent to copy is rewritten. Skill names such as
290
- // next-campaigns-os-setup, file names (campaigns-os.mjs), and already-prefixed
291
- // forms (`npx --no-install campaigns-os`, `npm run campaigns-os --`) are left
292
- // alone.
@@ -65,7 +65,7 @@ const COMMANDS = frozen({
65
65
  theme: { subcommands: ["generate", "inspect", "waive"] },
66
66
  checkpoint: { subcommands: ["waive"] },
67
67
  polish: { subcommands: ["capture"] },
68
- record: { subcommands: ["build", "polish", "setup"] },
68
+ record: { subcommands: ["build", "deploy", "polish", "setup", "theme"] },
69
69
  "validate-assembly-report": {},
70
70
  "install-agent-context": { dryRun: true },
71
71
  "install-skills": { dryRun: true },
@@ -92,8 +92,10 @@ const SUBCOMMAND_OVERRIDES = frozen({
92
92
  "spec derive": { dryRun: true },
93
93
  "qa publish": { dryRun: true },
94
94
  "record build": { dryRun: true },
95
+ "record deploy": { dryRun: true },
95
96
  "record polish": { dryRun: true },
96
97
  "record setup": { dryRun: true },
98
+ "record theme": { dryRun: true },
97
99
  "qa run": { autoEnd: true },
98
100
  "run start": { sweepRoot: "session" },
99
101
  "run end": { sweepRoot: "session", dryRun: true },
@@ -188,6 +188,27 @@ export async function observeBinding({ source, page, expected, scriptLoader, par
188
188
  return result(values[0] === expected.value ? 'match' : 'mismatch', 'credential_comparison');
189
189
  }
190
190
 
191
+ // The Campaign Cart SDK sends the page's raw key, with no scheme, as
192
+ // `Authorization` on every Campaigns API call, so the browser pass can see the
193
+ // key a page actually uses, whatever its scripts look like. The value is
194
+ // compared as sent: if the SDK ever adds a scheme, every page reads mismatch
195
+ // rather than passing. The API host is matched by its shape,
196
+ // campaigns.apps.<name>.com, without naming it.
197
+ const SDK_API_HOST = /^campaigns\.apps\.[a-z0-9-]+\.com$/;
198
+ export function isSdkApiRequest(url) {
199
+ try { const parsed = new URL(url); return parsed.protocol === 'https:' && SDK_API_HOST.test(parsed.hostname) && parsed.pathname.startsWith('/api/'); } catch { return false; }
200
+ }
201
+
202
+ // The keys a page sent are compared here and dropped; only the outcome is
203
+ // kept. With nothing sent, or no single expected key, the static read stands
204
+ // (null).
205
+ export function sentBinding(sentKeys, expected) {
206
+ if (!sentKeys.length || expected?.conflict || !expected?.value) return null;
207
+ return { schema_version: BINDING_SCHEMA, observation: 'sdk_request',
208
+ outcome: sentKeys.every(value => value === expected.value) ? 'match' : 'mismatch',
209
+ reason: 'credential_comparison', source_kinds: ['sdk_request'], identity: 'not_verified' };
210
+ }
211
+
191
212
  export function bindingAssertion(page, evidence) {
192
213
  // No source URLs, values, hashes, masked fragments, or inferred App IDs.
193
214
  return { id: `page-binding:${page.page_id}`, family: 'api-metadata', page: page.page_id,
@@ -45,6 +45,7 @@ import {
45
45
  import { ORDER_BUMP_PROBE_INPUT, orderBumpEvidenceScript } from "./qa-order-bump.mjs";
46
46
  import { assessPurchaseDataLayer, expectedOrderReferences, purchaseDataLayerAssertion, purchaseDataLayerProbe } from "./qa-purchase-data-layer.mjs";
47
47
  import { isBumpRow } from "./commercial-journey.mjs";
48
+ import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
48
49
  import {
49
50
  RESIDUE_PAGE_TYPES,
50
51
  demoAssetConfig,
@@ -962,6 +963,14 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
962
963
  failure: request.failure()?.errorText || "request failed",
963
964
  });
964
965
  });
966
+ // The key each SDK request carried. It stays in this function: sentBinding
967
+ // keeps only whether it matched.
968
+ const sentKeys = [];
969
+ const keyReads = [];
970
+ browserPage.on("request", (request) => {
971
+ if (!isSdkApiRequest(request.url())) return;
972
+ keyReads.push(request.headerValue("authorization").then((value) => { if (value !== null) sentKeys.push(value); }, () => {}));
973
+ });
965
974
 
966
975
  // Measurements describe the existing sequence; readiness never shortens it.
967
976
  const observationStarted = performance.now();
@@ -1017,6 +1026,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
1017
1026
  assertions.push(runtimeIssueAssertion(page, "browser-console-errors", actionableConsoleErrors));
1018
1027
  }
1019
1028
  observation.failed_requests_sampled_after_ms = elapsedMilliseconds(observationStarted);
1029
+ await Promise.all(keyReads);
1030
+ const sent = sentBinding(sentKeys, options.bindingExpected);
1031
+ if (sent) assertions.push(bindingAssertion(page, sent));
1020
1032
  if (failedRequests.length) {
1021
1033
  assertions.push(assertion({
1022
1034
  id: `browser-request-failures:${page.page_id}`,
@@ -5485,6 +5497,22 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5485
5497
  const displayed = new Set(resolved.displayed_package_ids);
5486
5498
  const summaryIds = new Set(resolved.summary_package_ids);
5487
5499
  const matchedSummaryIds = new Set();
5500
+ // A checkout order bump persists with is_upsell: true (the platform's
5501
+ // reporting tag), yet the checkout summary displays it. Such a line charges
5502
+ // a displayed row; an is_upsell line the summary does not show is a
5503
+ // post-purchase upsell, out of scope here, never a stray charge.
5504
+ let bumpLineCount = 0;
5505
+ for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
5506
+ const resolution = events ? campaignPackageResolutionForLine(events, line, {
5507
+ selected_packages,
5508
+ preferred_refs: resolved.summary_package_ids,
5509
+ }) : null;
5510
+ const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
5511
+ if (ref && summaryIds.has(ref)) {
5512
+ matchedSummaryIds.add(ref);
5513
+ bumpLineCount += 1;
5514
+ }
5515
+ }
5488
5516
  const extra = [];
5489
5517
  const unresolved = [];
5490
5518
  const matchedQuantities = [];
@@ -5532,6 +5560,7 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
5532
5560
  displayed_package_ids: [...displayed],
5533
5561
  summary_package_ids: [...summaryIds],
5534
5562
  non_upsell_line_count: nonUpsellLines.length,
5563
+ ...(bumpLineCount ? { order_bump_line_count: bumpLineCount } : {}),
5535
5564
  extra,
5536
5565
  missing,
5537
5566
  matched_quantities: matchedQuantities,
@@ -5601,7 +5630,7 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
5601
5630
  return assertion({
5602
5631
  ...base,
5603
5632
  status: STATUS.PASS,
5604
- actual: `${reconciliation.non_upsell_line_count} non-upsell line(s) reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
5633
+ actual: `${reconciliation.non_upsell_line_count} non-upsell line(s)${reconciliation.order_bump_line_count ? ` and ${reconciliation.order_bump_line_count} order bump line(s)` : ""} reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
5605
5634
  evidence: reconciliation,
5606
5635
  });
5607
5636
  }
package/src/qa-node.mjs CHANGED
@@ -2329,7 +2329,7 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2329
2329
  });
2330
2330
  assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2331
2331
  if (args.browser === true) {
2332
- assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2332
+ const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
2333
2333
  brandContract: resolved.brandContract,
2334
2334
  // With no generatable brand theme on the local preview, the starter
2335
2335
  // template is the design: its residue is a warning (local-preview-policy.mjs).
@@ -2337,7 +2337,15 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2337
2337
  ? SEVERITY.WARN
2338
2338
  : residueSeverityForThemeGate(gate.status),
2339
2339
  supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2340
- }));
2340
+ bindingExpected,
2341
+ });
2342
+ // A page-binding row from the browser is the key the SDK actually sent;
2343
+ // it takes the place of that page's static read.
2344
+ for (const observed of browserAssertions) {
2345
+ const at = observed.id.startsWith("page-binding:") ? assertions.findIndex((entry) => entry.id === observed.id) : -1;
2346
+ if (at >= 0) assertions[at] = observed;
2347
+ else assertions.push(observed);
2348
+ }
2341
2349
  }
2342
2350
 
2343
2351
  const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions });