@nextcommerce/campaigns-os 1.50.0 → 1.52.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 (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os
3
- version: 1.0.44
3
+ version: 1.0.47
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.50.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+skills.1`
7
+ Bundle revision: 1.52.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.52.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
@@ -95,7 +95,7 @@ Map and run endpoints) with a local CampaignSpec, prepared HTML/assets source, t
95
95
  retains each attributed exception as `ready_with_exceptions`, and one
96
96
  exception never suppresses another blocker.
97
97
  10. Run the package-owned proof path in sequence: ensure `npx --no-install campaigns-os qa install-browser` has completed, run `campaigns-os qa resolve --packet <packet>` (tier `A`: it fetches `--base-url`; `--no-probe` is tier `B` and local), then `campaigns-os qa run --packet <packet> --base-url <url> --browser --test-order common` (tier `C`: it overwrites the stored verdict and assembly report, places real typed-card test orders against the campaign, and posts the verdict and progress; `--no-post-verdict` drops the verdict POST and `--no-remit` the Run Record remit, but both stay tier `C`).
98
- 11. Treat typed-card proof coverage as the control. Global test cards bypass the gateway and create no transactions, so no permission/approval is needed. `common` runs every actual terminal path when they fit under the flood cap (`--max-test-orders`, 6 by default); above the cap it runs checkout, first-offer accept and decline, and a deduplicated shortest real receipt path, then adds one decline path per offer or downsell page not yet declined, up to the cap, and names any page left out. A page counts as covered only when an order clicks its decline; the `browser-test-order:upsell-action-coverage` verdict row warns naming each page whose decline no order clicked. `full` walks every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. The accidental-flood cap remains `6`, and an overflow names the exact explicit `--max-test-orders` raise. Localhost on any port is a Campaigns App Development domain for SDK QA with analytics suppressed; non-localhost preview/production origins still need SDK origin allowlist confirmation.
98
+ 11. Treat typed-card proof coverage as the control: `qa run` has no permission flag. Test orders still land in the store as real orders (global test cards: no charge, no transaction) that someone may have to cancel. Unless the operator has already said test orders are fine for this campaign, ask once, up front in your first turn with your other setup questions, so the answer covers the whole build and QA and neither stops for it later. `common` runs every actual terminal path when they fit under the flood cap (`--max-test-orders`, 6 by default); above the cap it runs checkout, first-offer accept and decline, and a deduplicated shortest real receipt path, then adds one decline path per offer or downsell page not yet declined, up to the cap, and names any page left out. A page counts as covered only when an order clicks its decline; the `browser-test-order:upsell-action-coverage` verdict row warns naming each page whose decline no order clicked. `full` walks every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. The accidental-flood cap remains `6`, and an overflow names the exact explicit `--max-test-orders` raise. Localhost on any port is a Campaigns App Development domain for SDK QA with analytics suppressed; non-localhost preview/production origins still need SDK origin allowlist confirmation.
99
99
  12. Discuss launch only from recorded build, polish, deploy, browser QA, and test-order evidence, or from explicit blockers.
100
100
 
101
101
  ## Session Intake
@@ -141,9 +141,13 @@ hand-edit deployed routing config as the primary promotion path.
141
141
 
142
142
  ## Test-Order Proof Policy
143
143
 
144
- Treat test orders as cheap, repeatable proof: global test cards bypass the
145
- gateway and create no transactions, so they need no permission or approval. The
146
- only real choice is coverage. Record:
144
+ Treat test orders as repeatable proof. `qa run` has no permission flag:
145
+ coverage is its only control. Test orders still land in the store as real
146
+ orders (global test cards: no charge, no transaction) that someone may have to
147
+ cancel. Unless the operator has already said test orders are fine for this
148
+ campaign, ask once, up front in your first turn with your other setup questions,
149
+ so the answer covers the whole build and QA and neither stops for it later.
150
+ Record:
147
151
 
148
152
  - Coverage: `common` (every actual terminal path when they fit under the flood cap; above it, checkout, first-offer accept/decline, a deduplicated shortest real receipt path, and one decline path per offer or downsell page not yet declined, up to the cap), `off`, `checkout`, `decline`, `accept`, `both`, `full`, or explicit paths such as `decline-decline-accept`.
149
153
  - Cart matrix: base cart, base plus bump, specific package refs/quantities.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os-setup
3
- version: 2.0.27
3
+ version: 2.0.30
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.50.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+skills.1`
7
+ Bundle revision: 1.52.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.52.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.28
3
+ version: 1.1.31
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.50.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+skills.1`
7
+ Bundle revision: 1.52.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.52.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-qa
3
- version: 1.3.28
3
+ version: 1.3.31
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.50.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.50.0+skills.1`
7
+ Bundle revision: 1.52.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.52.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
@@ -89,7 +89,7 @@ Rules:
89
89
  - A typed-card path that fails is classified by **what it did to the store** before the runner decides what to do about it. A failure the runner can prove happened before submit (`not_created`) is **re-run once, if the creation budget has a slot no still-unrun planned path needs** — so a transient miss is not reported as a defect in the build, without an early path eating budget the last planned paths need. Under the default budget a path whose submit was *rejected* has already spent its own slot, so it is not re-run and records `evidence.order_creation.rerun_skipped` instead. When the re-run does happen, both attempts appear in `test_orders[]` and `evidence.retry` names the first attempt's error and ref id. A failure that happened **after** the order was created (`created` — most often a receipt that did not render) is **never resubmitted**: the runner reloads that order's receipt and re-runs only the read-only checks, and `evidence.recovery` carries the original failure, the checks re-run, and whether it cleared. Read `evidence.order_creation` for two separate counts: `submissions_reserved` (platform-side creation slots charged to this path — reserved before a submit click, or charged for a hosted-checkout redirect where no submit click happens — which stand even when the create then failed) and `orders_confirmed_created` (creates the platform was observed to accept) — a spent slot with no confirmed order is the ambiguous case, not an order to reconcile. Recovery clears only on persisted evidence it re-read on that pass: a failed or absent order read-back stops it honestly rather than re-deciding against the original attempt's numbers. An outcome it cannot prove either way (`ambiguous` — an unusable read-back, a lost create response, a network-failed create, a 4xx after an earlier 2xx) stops the path and names the operator check instead of buying again. A pass that only came back after recovery is never indistinguishable from a first-attempt pass, and a failure that survives recovery still blocks.
90
90
  - Analytics correctness is two-phase in the same run: the campaign-root visit inventories declared providers/tags only, then the one canonical typed-card run proves Purchase for each topology-recognized receipt from the signals emitted across every page the path loaded after checkout, read after the full `--analytics-settle` window. The receipt qualifies the order; the journey is measured, because the SDK fires `dl_purchase` (and the outbound Purchase) on the first `?ref_id=` page — the upsell page when the funnel has one — and dedupes it on the receipt. It never places a second analytics order. The receipt document's own reading stays in evidence (`receipt_signals`, `fired_on`) as the diagnostic of which document fired.
91
91
  - A missing or topology-unrecognized receipt is `MANUAL_REVIEW`/`WARN`; a recognized receipt with no dataLayer, outbound Meta, or outbound GA4 Purchase is `FAIL`/`BLOCKER`. Capture, unreadable-page, and settle-deadline errors on a recognized receipt are explicit non-waivable blockers. The `analytics-correctness:purchase-fires` waiver applies only to a genuine recognized-receipt/no-signal failure, and is recorded with `campaigns-os qa waive --assertion analytics-correctness:purchase-fires --reason "<why>"` (tier `C`: it overwrites the assembly report and doctor output; the lane is scoped to that one assertion and every other is refused).
92
- - Keep QA in a tight sequence: install the Playwright browser, resolve topology, run browser QA plus typed-card proof with `--test-order common` by default. Test orders need no permission step. Pause only for missing inputs, out-of-scope runtime pages that block checkout proof, or merchant-specific uncertainty.
92
+ - Keep QA in a tight sequence: install the Playwright browser, resolve topology, run browser QA plus typed-card proof with `--test-order common` by default. `qa run` has no permission flag: coverage is its only control. Test orders still land in the store as real orders that someone may have to cancel, so unless the operator has already said test orders are fine for this campaign, ask once, up front in your first turn with your other setup questions. When the operator has answered, at the start of this session or earlier in the build, follow that answer and do not pause QA to ask again. When nobody asked, ask once before the first test order rather than skip the question. Otherwise pause only for missing inputs, out-of-scope runtime pages that block checkout proof, or merchant-specific uncertainty.
93
93
  - Use `--browser` for rendered browser evidence. Browser QA must use the package-owned Playwright flow, not external agent/browser skills.
94
94
  - Saved-Map QA publishes to the QA portal under the existing consent and flag controls; report the portal link only when publication succeeds. Pass `--no-post-verdict` (or `--local-only`) to keep that verdict local. This stays tier `C`: served-page probes, requested orders, Run Telemetry and progress retain their own controls. See `docs/qa-and-test-orders.md` for the saved-Map publication policy.
95
95
  - Local-spec QA always keeps verdicts and progress local. `qa run` suppresses portal publication even with `--post-verdict`; `qa publish` refuses local-spec packets with `local_spec`. Report the local verdict/sidecar as evidence, never a dashboard link. A matching route cannot replace a matching `local_spec_id`, and QA refuses a foreign or stale local Assembly Report. Run Telemetry still follows its consent controls.
@@ -116,11 +116,12 @@ Rules:
116
116
  - A path with remaining actions may stop cleanly only at a terminal recognized in that selected topology. Missing accept/decline controls on any other page are blockers. A cross-origin handoff is a valid terminal navigation but is not receipt-rendering or persisted-receipt proof.
117
117
  - Keep multi-funnel and `tiers:common` / `tiers:full` plans isolated to each selected checkout's own funnel graph, tiers, and recognized terminals; never borrow an unrelated funnel's receipt page.
118
118
  - Accepted-upsell proof is valid only when the browser observes the order upsell API mutation and the final order evidence contains the selected upsell package. A checkout bump line marked `is_upsell` is not accepted-upsell proof.
119
- - Test orders are safe to fire any time: global test cards bypass the gateway, create no transactions, and need no merchant-specific sandbox routing confirmation. Localhost on any port is globally available as a Campaigns App Development domain and suppresses Campaigns analytics; non-localhost preview/production origins must be allowlisted for the campaign API key so the SDK loads — that is about SDK initialization, not test-order permission.
119
+ - Test orders are real orders in the store, but global test cards bypass the gateway: no charge, no transaction, and no merchant-specific sandbox routing to confirm. Localhost on any port is globally available as a Campaigns App Development domain and suppresses Campaigns analytics; non-localhost preview/production origins must be allowlisted for the campaign API key so the SDK loads — that is about SDK initialization, not test-order permission.
120
120
  - Launch readiness is separate from Campaigns OS proof. If QA passes on local/preview, still surface production storefront URL, live payment methods, shipping markets, legal/support URLs, analytics expectations, and merchant-side configuration as real-shopper readiness items before launch.
121
121
  - For multi-market campaigns, verify at least one non-default currency/country path: currency display, shipping method names/prices, available payment methods, and market-specific copy.
122
122
  - Treat missing deploy URL, missing polish status, or unresolved doctor blockers as launch blockers.
123
123
  - Report blockers, warnings, and residual risks.
124
+ - At the end of QA, run `next` and present the QC handoff once. Ask the operator which open warnings to accept and why. Run `checkpoint accept` only with the refs, reason, and name the operator gave you in this conversation. Never write `qc_accepts`, `qc_results`, or QA verdict files by hand. The up-front test-order permission does not cover accepts.
124
125
  - QA follows build and polish; it does not edit campaign code.
125
126
 
126
127
  Canonical test-order flow:
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.50.0+skills.1",
4
+ "bundle_revision": "1.52.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.44",
19
+ "version": "1.0.47",
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.27",
27
+ "version": "2.0.30",
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.29",
35
+ "version": "1.0.32",
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.28",
43
+ "version": "1.1.31",
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.28",
51
+ "version": "1.3.31",
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.24",
59
+ "version": "1.0.27",
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.24",
67
+ "version": "1.0.27",
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.24",
75
+ "version": "1.0.27",
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.24",
83
+ "version": "1.0.27",
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."
@@ -317,7 +317,7 @@ function validateTemplateSlicePaths(copied, location, targetRepo, warnings, read
317
317
 
318
318
  function checkEnum(value, allowed, code, warnings, addIssue) {
319
319
  if (value == null) return;
320
- if (!allowed.has(value)) addIssue(warnings, code, `${code} has unknown value "${value}".`);
320
+ if (!allowed.has(value)) addIssue(warnings, code, `${code} has unknown value ${JSON.stringify(value)}; allowed values: ${[...allowed].join(", ")}.`);
321
321
  }
322
322
 
323
323
  function isObject(value) {
@@ -88,6 +88,18 @@ function loadContracts(repoRoot = ROOT) {
88
88
  };
89
89
  }
90
90
 
91
+ // A theme write error as the Assembly Report's theme.warnings[] carries it
92
+ // (schema $defs.themeIssue): detail, when present, must be an object, so an
93
+ // issue without one omits the key rather than writing null.
94
+ export function themeIssueForReport(error) {
95
+ const detail = error?.detail;
96
+ return {
97
+ code: error?.code,
98
+ message: error?.message,
99
+ ...(detail && typeof detail === "object" && !Array.isArray(detail) ? { detail } : {}),
100
+ };
101
+ }
102
+
91
103
  function issue(code, message, detail = null) {
92
104
  return detail ? { code, message, detail } : { code, message };
93
105
  }
@@ -12,11 +12,15 @@ export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
12
12
  "campaign-build-brief.json",
13
13
  ]);
14
14
 
15
+ // answer_fields: the brief fields whose values close the question, as
16
+ // evaluateCampaignBuildBrief reads them. The guided-questions warning names
17
+ // them so an answer given in conversation can be written where it counts.
15
18
  const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
16
19
  {
17
20
  id: "page_design_authority",
18
21
  priority: 1,
19
22
  field: "design_authority",
23
+ answer_fields: ["design_authority.<page_id>.source"],
20
24
  question: "Which source controls each campaign page: the provided design export, the selected template, or a template adapted to another page?",
21
25
  reason: "Page-by-page authority prevents checkout, OTO, and receipt pages from drifting into unrelated starter-template composition.",
22
26
  },
@@ -24,6 +28,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
24
28
  id: "brand_palette_cta",
25
29
  priority: 2,
26
30
  field: "brand",
31
+ answer_fields: ["brand.commerce_palette_source", "brand.cta_style"],
27
32
  question: "Which palette and CTA style should commerce pages use?",
28
33
  reason: "Commerce pages need a business-approved brand layer instead of silent template defaults.",
29
34
  },
@@ -31,6 +36,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
31
36
  id: "variant_media_rules",
32
37
  priority: 3,
33
38
  field: "media",
39
+ answer_fields: ["media.sold_variants", "media.allow_other_variant_colors"],
34
40
  question: "Which product variants or colors are actually sold, and may media show other variants?",
35
41
  reason: "Variant ambiguity often creates wrong-color carousels and unavailable-product claims.",
36
42
  },
@@ -38,6 +44,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
38
44
  id: "bundle_pricing_presentation",
39
45
  priority: 4,
40
46
  field: "offer_presentation.bundle_cards",
47
+ answer_fields: ["offer_presentation.bundle_cards.primary_price"],
41
48
  question: "How should bundle cards present pricing: simple unit price, savings-led, or full accounting?",
42
49
  reason: "CampaignSpec owns prices; the brief owns which shopper-facing price story is appropriate.",
43
50
  },
@@ -45,13 +52,16 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
45
52
  id: "promo_urgency_copy",
46
53
  priority: 5,
47
54
  field: "promo_urgency",
48
- question: "Which promo, savings, and urgency language is approved for timers, banners, and exit-pop surfaces?",
49
- reason: "Promo placeholders and unsupported scarcity claims are business decisions, not implementation details.",
55
+ answer_fields: ["promo_urgency.header_claim_source", "promo_urgency.timer_label"],
56
+ answer_hint: 'promo_urgency.header_claim_source "campaign_offers" and promo_urgency.timer_label the template timer\'s label to fill them, or both "none" to remove them',
57
+ question: "Should the starter template's own promo placeholders (demo countdown timers, promo banners, placeholder voucher codes, exit-pop offers) be filled from this campaign's promo codes and offers, or removed?",
58
+ reason: "Template promo placeholders must not go live with demo values. The source design's own promo, proof and urgency copy is the merchant's content: it is built as designed and is not part of this question.",
50
59
  },
51
60
  {
52
61
  id: "payment_methods_trust",
53
62
  priority: 6,
54
63
  field: "commerce_surfaces.payment_methods_allowed",
64
+ answer_fields: ["commerce_surfaces.payment_methods_allowed"],
55
65
  question: "Which payment methods and trust badges may appear?",
56
66
  reason: "Templates often carry demo wallets or badges that must not survive without approval.",
57
67
  },
@@ -59,6 +69,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
59
69
  id: "canonical_display_names",
60
70
  priority: 7,
61
71
  field: "canonical_display.product_name_source",
72
+ answer_fields: ["canonical_display.product_name_source"],
62
73
  question: "Should CampaignSpec display names win, or may runtime/catalog names override them?",
63
74
  reason: "Name drift across source, spec, and runtime data is hard to spot after assembly.",
64
75
  },
@@ -66,6 +77,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
66
77
  id: "regulated_claims",
67
78
  priority: 8,
68
79
  field: "campaign_intent.compliance",
80
+ answer_fields: ["campaign_intent.compliance.approved_benefit_language", "campaign_intent.compliance.forbidden_claims"],
69
81
  question: "Are there regulated claims, forbidden phrases, or approved benefit statements the build must follow?",
70
82
  reason: "Health, financial, and other regulated offers need explicit copy boundaries.",
71
83
  conditional: "regulated",
@@ -227,7 +239,18 @@ export function createCampaignBuildBriefArtifact({
227
239
  };
228
240
  }
229
241
 
230
- export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {}) {
242
+ // "brand_palette_cta (brand.commerce_palette_source, brand.cta_style)": each
243
+ // open question with the brief fields that close it.
244
+ function describeOpenQuestions(questions) {
245
+ return questions.map((question) => {
246
+ const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
247
+ if (isNonEmptyString(entry?.answer_hint)) return `${question?.id} (${entry.answer_hint})`;
248
+ const fields = entry?.answer_fields || (isNonEmptyString(question?.field) ? [question.field] : []);
249
+ return fields.length ? `${question?.id} (${fields.join(", ")})` : String(question?.id);
250
+ }).join(", ");
251
+ }
252
+
253
+ export function validateCampaignBuildBriefArtifact(brief, { spec = null, normalizedPath = BUILD_BRIEF_NORMALIZED_REL_PATH } = {}) {
231
254
  const errors = [];
232
255
  const warnings = [];
233
256
  const ready = [];
@@ -255,14 +278,20 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {})
255
278
  if (questions.length) {
256
279
  errors.push({
257
280
  code: "build_brief.questions_unanswered",
258
- message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${questions.map((question) => question.id).join(", ")}.`,
281
+ message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and re-run start or prepare-build.`,
259
282
  });
260
283
  }
261
284
  } else {
262
285
  if (questions.length) {
286
+ // An answer given in conversation is not recorded until it is in a
287
+ // brief file that start/prepare-build reads: the guided draft is
288
+ // regenerated on every run, and a brief file replaces it whole.
263
289
  warnings.push({
264
290
  code: "build_brief.guided_questions",
265
- message: `Generated Campaign Build Brief draft has ${questions.length} high-impact business question(s) to confirm: ${questions.map((question) => question.id).join(", ")}.`,
291
+ message: `Generated Campaign Build Brief draft has ${questions.length} high-impact business question(s) to confirm: ${describeOpenQuestions(questions)}. `
292
+ + `An answer counts only once it is in a brief file: copy ${normalizedPath} to campaign-build-brief.json in the target repo, set those fields, and re-run start or prepare-build with the same arguments `
293
+ + "(the file is found there automatically, or pass --brief <file>). The file replaces the draft, so start from the copy to keep the fields the draft already filled. "
294
+ + "Once a stage has recorded evidence, that re-run needs --force, which clears the evidence.",
266
295
  });
267
296
  }
268
297
  for (const gate of blockerGates) {
@@ -358,6 +387,9 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
358
387
  const paymentMethods = collectSpecPaymentMethods(spec);
359
388
  const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
360
389
  const hasOrderBump = hasOrderBumpSignals(spec);
390
+ // With no CampaignSpec surface to fill them, the template's promo
391
+ // placeholders are removed: "none" for both.
392
+ const fillsTemplatePromo = templatePromoSurfaces({ spec, activePages }).length > 0;
361
393
 
362
394
  return {
363
395
  schema_version: BUILD_BRIEF_SCHEMA,
@@ -394,8 +426,8 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
394
426
  },
395
427
  },
396
428
  promo_urgency: {
397
- header_claim_source: hasPromoSignals(spec) ? "campaign_offers" : "none",
398
- timer_label: hasPromoSignals(spec) ? null : "Limited-time offer",
429
+ header_claim_source: fillsTemplatePromo ? "campaign_offers" : "none",
430
+ timer_label: fillsTemplatePromo ? null : "none",
399
431
  show_promo_code_in_timer: false,
400
432
  exit_pop: {
401
433
  enabled: hasExitPop,
@@ -427,7 +459,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
427
459
  brand: "low",
428
460
  media: variantSignals.length === 1 ? "medium" : "low",
429
461
  offer_presentation: "low",
430
- promo_urgency: hasPromoSignals(spec) ? "low" : "medium",
462
+ promo_urgency: fillsTemplatePromo ? "low" : "medium",
431
463
  commerce_surfaces: paymentMethods.length ? "medium" : "low",
432
464
  template_family: templateFamily || null,
433
465
  },
@@ -466,10 +498,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
466
498
  options: ["discounted unit price", "savings-led", "full accounting"],
467
499
  });
468
500
  }
469
- if (hasPromoSignals(spec) && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
501
+ const promoSurfaces = templatePromoSurfaces({ spec, activePages });
502
+ if (promoSurfaces.length && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
470
503
  addQuestion(questions, "promo_urgency_copy", {
471
- detail: "CampaignSpec appears to include promo/offer signals, but promo copy authority is incomplete.",
472
- options: ["actual voucher code", "bundle savings claim", "no promo banner"],
504
+ detail: `CampaignSpec maps ${promoSurfaces.join(" and ")}, which would fill the template's promo placeholders. Missing promo_urgency.header_claim_source ("campaign_offers" or "none") or promo_urgency.timer_label (the template timer's label, or "none").`,
505
+ options: ["fill from the campaign's promo codes and offers", "remove the template's promo placeholders"],
473
506
  });
474
507
  }
475
508
  if (!normalizePaymentList(brief.commerce_surfaces?.payment_methods_allowed).length) {
@@ -638,16 +671,30 @@ export function collectSpecPaymentMethods(spec) {
638
671
  return [...methods].sort();
639
672
  }
640
673
 
641
- function hasPromoSignals(spec) {
642
- let found = false;
643
- visitValues(spec, (value, keyPath) => {
644
- if (found) return;
645
- const path = keyPath.join(".").toLowerCase();
646
- if (/(promo|voucher|coupon|discount|offer|timer|urgency|exit_intent)/.test(path) && value != null && value !== false) {
647
- found = true;
648
- }
649
- });
650
- return found;
674
+ // The CampaignSpec surfaces that fill a starter template's own promo
675
+ // placeholders: the template's promo banner and countdown timer read
676
+ // funnels[].promo_codes, and its exit-pop offer reads a page's exit_intent or
677
+ // promo_code_input. The campaign's offer catalog and discount pricing are not
678
+ // among them (bundle_pricing_presentation covers those), and neither is any
679
+ // promo, proof or urgency copy of the source design: that is the merchant's
680
+ // content, built as designed.
681
+ function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
682
+ const surfaces = [];
683
+ const funnels = Array.isArray(spec?.funnels) ? spec.funnels : [];
684
+ if (funnels.some((funnel) => Array.isArray(funnel?.promo_codes) && funnel.promo_codes.length > 0)) {
685
+ surfaces.push("promo codes (funnels[].promo_codes)");
686
+ }
687
+ const pages = Array.isArray(activePages) ? activePages : [];
688
+ // Checkout-only and enabled: true, the rule hasExitPop, doctor's exit-pop
689
+ // contract and QA's coupon orders use for these surfaces.
690
+ const checkoutSurface = (page, key) => page?.type === "checkout" && page?.[key]?.enabled === true;
691
+ if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
692
+ surfaces.push("an exit-intent offer (exit_intent)");
693
+ }
694
+ if (pages.some((page) => checkoutSurface(page, "promo_code_input"))) {
695
+ surfaces.push("a promo-code input (promo_code_input)");
696
+ }
697
+ return surfaces;
651
698
  }
652
699
 
653
700
  function hasOrderBumpSignals(value, keyPath = []) {
@@ -16,6 +16,8 @@ import { createHash } from "node:crypto";
16
16
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
17
17
  import { basename, join, relative, sep } from "node:path";
18
18
 
19
+ import { isFileReadFailure } from "./cart-placeholders.mjs";
20
+
19
21
  const HTML_EXT = ".html";
20
22
 
21
23
  // The route tokens inferPageType reads for the funnel roles, exported so a
@@ -56,7 +58,26 @@ export function inferPageType(routeOrName) {
56
58
  return "page";
57
59
  }
58
60
 
59
- function listHtmlFiles(root) {
61
+ // Whether a symbolic link may stand for a built page: its name ends in .html,
62
+ // or its target is a directory, or its target cannot be inspected (missing,
63
+ // EACCES, ELOOP, any file-system error). A link to a regular file (or any
64
+ // other non-directory) not named .html is no page. Only file-system errors
65
+ // are caught; anything else throws.
66
+ function linkMayBePage(full, name) {
67
+ if (name.toLowerCase().endsWith(HTML_EXT)) return true;
68
+ try {
69
+ return statSync(full).isDirectory();
70
+ } catch (error) {
71
+ if (!isFileReadFailure(error)) throw error;
72
+ return true;
73
+ }
74
+ }
75
+
76
+ // `links`, when given, collects every symbolic link that may stand for a page
77
+ // (see linkMayBePage). They are never pages themselves (build output holds no
78
+ // links, and a link is not followed), but a caller that must account for every
79
+ // built page can name them.
80
+ function listHtmlFiles(root, links = null) {
60
81
  const files = [];
61
82
  if (!existsSync(root) || !statSync(root).isDirectory()) return files;
62
83
  const walk = (dir) => {
@@ -67,9 +88,11 @@ function listHtmlFiles(root) {
67
88
  const full = join(dir, entry.name);
68
89
  if (entry.isDirectory()) walk(full);
69
90
  else if (entry.isFile() && entry.name.toLowerCase().endsWith(HTML_EXT)) files.push(full);
91
+ else if (links && entry.isSymbolicLink() && linkMayBePage(full, entry.name)) links.push(full);
70
92
  }
71
93
  };
72
94
  walk(root);
95
+ links?.sort();
73
96
  return files.sort();
74
97
  }
75
98
 
@@ -101,7 +124,13 @@ function resolveSiteRoot(targetRepo) {
101
124
  *
102
125
  * @param {string} targetRepo Absolute path to the page-kit target repo (or a
103
126
  * `_site/` directory, or a campaign directory).
104
- * @param {{ slug?: string|null }} [options]
127
+ * @param {{ slug?: string|null, includeLinkedPages?: boolean }} [options]
128
+ * `includeLinkedPages` adds `linked_pages`: every symbolic link under the
129
+ * campaign directory that may stand for a page (named .html, or its target
130
+ * a directory, or its target not inspectable; each one entry, its
131
+ * `built_path` the link itself; skipped as pages, not followed), in the
132
+ * same shape as `pages`. A link to a regular file not named .html is
133
+ * dropped. Off by default; without it the result is unchanged.
105
134
  * @returns {{
106
135
  * ok: boolean,
107
136
  * error?: string,
@@ -114,7 +143,7 @@ function resolveSiteRoot(targetRepo) {
114
143
  * slug_candidates?: string[],
115
144
  * }}
116
145
  */
117
- export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
146
+ export function resolveBuiltSiteScope(targetRepo, { slug = null, includeLinkedPages = false } = {}) {
118
147
  const base = { ok: false, target_repo: targetRepo, site_root: null, slug: "", campaign_dir: null, pages: [], html_count: 0 };
119
148
  if (!targetRepo || !existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
120
149
  return { ...base, error: `Built campaign directory does not exist: ${targetRepo}` };
@@ -149,7 +178,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
149
178
  return { ...base, site_root: siteRoot, slug: resolvedSlug, error: `Campaign directory does not exist: ${campaignDir}` };
150
179
  }
151
180
 
152
- const pages = listHtmlFiles(campaignDir).map((file) => {
181
+ const toPage = (file) => {
153
182
  const route = routeForFile(campaignDir, file);
154
183
  return {
155
184
  page_id: pageIdForRoute(route),
@@ -157,10 +186,13 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
157
186
  route,
158
187
  built_path: file,
159
188
  };
160
- });
189
+ };
190
+ const links = includeLinkedPages ? [] : null;
191
+ const pages = listHtmlFiles(campaignDir, links).map(toPage);
192
+ const linked = links ? { linked_pages: links.map(toPage) } : {};
161
193
 
162
194
  if (!pages.length) {
163
- return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, error: `No built HTML pages found under ${campaignDir}.` };
195
+ return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, ...linked, error: `No built HTML pages found under ${campaignDir}.` };
164
196
  }
165
197
 
166
198
  return {
@@ -171,6 +203,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
171
203
  campaign_dir: campaignDir,
172
204
  pages,
173
205
  html_count: pages.length,
206
+ ...linked,
174
207
  };
175
208
  }
176
209