@nextcommerce/campaigns-os 1.55.0 → 1.59.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 (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +124 -22
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-run-evidence
3
- version: 1.0.32
3
+ version: 1.0.37
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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.32
3
+ version: 1.0.37
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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.37
3
+ version: 1.0.42
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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
@@ -100,7 +100,9 @@ Build rules:
100
100
  - If setup/build needs starter-template files, copy the template family atomically with its dependent pages, `_includes/`, `_layouts/`, `assets/css/`, and `assets/js/`; copying only checkout/receipt pages is incomplete.
101
101
  - Resolve SDK routing meta tags to deployed campaign-root paths, not spec literals. For example, `next-success-url: upsell/` in the spec should become `/<public_route_slug>/upsell/` in built HTML.
102
102
  - If an order bump package comes from `packages.prepurchase_*` and is not one of the main `bundles[]`, default `package_sync=false` and `show_line_total_price=false` unless the CampaignSpec explicitly says the add-on quantity must sync with the main bundle.
103
- - For two-step package-selection-before-checkout flows, use the selector page as the pre-checkout step, encode the selected cart with `forcePackageId`, preserve attribution/tracking params, and strip `forcePackageId` from the visible checkout URL after SDK initialization.
103
+ - Multi-step checkout paths run `select` → `checkout_step`… → `checkout`; only the `checkout` page takes payment. On a `select` page the swap-mode bundle selector (`data-next-bundle-selector` with `data-next-selection-mode="swap"`) writes the cart and the SDK persists it between pages; a checkout button or link (`href` from the page's `next_step`) goes to the next page. Use `forcePackageId` only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
104
+ - Each `checkout_step` page's checkout form (`<form data-next-checkout="form">`) carries `data-next-checkout-step="<its next_step>"` and `data-next-step-number="<step_number>"` (frontmatter `next_step` and `step_number`, 1 for the first step on its path). The `checkout` page's form carries neither.
105
+ - On `select` and `checkout_step` pages, frontmatter `next_url` is the Checkout's post-payment destination, the same meaning it has on the `checkout` page, and the layout renders it as the `next-success-url` meta tag: the SDK reads that tag for express orders placed on the page. `next_step` is the page's forward link. A landing page keeps `next_url` as its call-to-action target; if it has express checkout buttons, set its `next-success-url` to the Checkout's own `next_url` from the Build Context `page_map`, never to the landing page's `next_url`. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no `success_url` key; `success_url` stays a CampaignSpec field on `checkout` pages.
104
106
  - Record intentional drops from source HTML in the assembly report, especially payment/provider changes such as "PayPal removed because CampaignSpec available_payment_methods excludes it." Polish must inherit these decisions.
105
107
  - Preserve any existing Build Context `theme` inspection state and Assembly Report `theme` application state. If build applies, skips, or invalidates generated theme CSS, update `report.theme` rather than leaving stale evidence.
106
108
  - After page-kit build, inspect rendered `_site` output: body exists, Campaign Cart runtime markers exist, `sdk_hints.meta_tags` rendered, route meta points at the campaign root, and copied funnel attribution/runtime baggage is gone.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os
3
- version: 1.0.52
3
+ version: 1.0.57
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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.35
3
+ version: 2.0.40
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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.36
3
+ version: 1.1.42
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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
@@ -71,14 +71,29 @@ Responsibilities:
71
71
  - For bundle selectors and order bumps, verify active/inactive visual state after interaction, mobile label wrapping, badge placement, and selected/unchecked state. A native hidden input state that disagrees with the SDK class state is a polish blocker. So is a selected/active visual driven by a *non-SDK static attribute the SDK never clears* — e.g. a hardcoded `data-selected="true"` left on the initial card. (The native `selected` attribute on `<option>`/custom selects *is* SDK/browser-managed, so do not flag that.) The selected style must follow the SDK-managed class (e.g. `.next-selected` — see the family's bundle-selector selectors in `contracts/template-brand-contract.<family>.v0.json`) so a single-select swap clears the previous selection. Verify by interaction that the previously-selected item deselects after another is picked — a selector that only ever paints "selected" on click is a blocker.
72
72
  - For exit-intent pops and promo-code inputs, polish the wrapper/copy states without breaking SDK coupon/voucher apply hooks or `cart.hasCoupon("CODE")` conditional labels.
73
73
  - If `report.theme` or `context.theme` exists, verify brand-theme load order after `next-core.css`, source-token parity for primary color/CTA/surface/text/font/radius when present, and SDK safety. When the brand layer is missing, stale, low-confidence, or unsafe to apply, record the first repair-loop defect or an explicit skipped reason.
74
- - Before recording a terminal Polish status, install the package-owned browser
75
- once with `npx --no-install campaigns-os qa install-browser`, serve the
76
- current build, and run `campaigns-os polish capture --packet <packet> --base-url <served-build-url>`
74
+ - When at least one mapped page has a design route, before recording a terminal
75
+ Polish status, install the package-owned browser once with
76
+ `npx --no-install campaigns-os qa install-browser`, serve the current build,
77
+ and run `campaigns-os polish capture --packet <packet> --base-url <served-build-url>`
77
78
  (tier `A`: it writes the polish evidence and assembly report under the target
78
79
  and fetches the served build at `--base-url`).
79
80
  The package captures every mapped route at fixed desktop/mobile viewports and
80
81
  attaches `stages.polish.evidence.visual_review.page_load`. Never hand-author,
81
82
  copy, or repair that object directly.
83
+ - If every mapped page is template stock (`skip_reason`), there is no design
84
+ route for that capture. On a local loopback preview, or a hosted preview at
85
+ the packet's non-loopback HTTP(S) `deploy.preview_url` that differs from
86
+ `deploy.production_url` by origin or route path, doctor carries the missing
87
+ evidence forward as a warning. Query or fragment text on the production
88
+ page does not make it a distinct preview. If the hosted preview URL is not
89
+ yet recorded, run
90
+ `campaigns-os qa policy set --packet <packet> --preview-url <url>` first,
91
+ then `campaigns-os next --packet <packet>`. Run
92
+ `campaigns-os next qa --packet <packet>`, then
93
+ `campaigns-os qa run --packet <packet> --base-url <preview-url> --browser
94
+ --test-order common`. The hosted preview URL satisfies the deploy handoff;
95
+ after QA records its verdict, run `campaigns-os next --packet <packet>` for
96
+ closeout. QA against the packet's production URL keeps the checkpoint blocked.
82
97
  - When a readability warning appears, report it with its colours, ratio, requirement and pages. Offer the operator options inside the brand palette (a darker or lighter shade of the same hue, the palette's text colour, or a larger, bold label where the design allows). Do not recolour the merchant's design until the operator chooses. Accept a warning only with the refs, reason and name the operator gives you at the QC handoff.
83
98
  - Record Polish as `completed`, `skipped`, or `blocked` with
84
99
  `campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-qa
3
- version: 1.3.36
3
+ version: 1.3.41
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.55.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.55.0+skills.1`
7
+ Bundle revision: 1.59.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.59.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
@@ -110,6 +110,7 @@ Rules:
110
110
  - After the base checkout test order redirects to upsell, click the rendered SDK upsell accept/decline controls to prove the live upsell path. Do not fabricate upsell lines with a direct API call.
111
111
  - Valid test-order modes are `common`, `checkout`, `accept`, `decline`, `both`, `full`, `off`, and explicit accept/decline paths such as `accept-decline-accept`.
112
112
  - Browser test orders default to `--max-test-orders 6` (an accidental-flood guard, not a permission gate). Planning happens before browser launch; when `full` exceeds the cap, use explicit sample paths or rerun with the exact larger cap printed by the command (a linear three-offer graph plans nine orders, so use `--max-test-orders 9`). That cap bounds planned paths; `--max-order-creations` bounds real order creations, defaults to the planned path count, and is reserved before each submit click. A run that hits it stops that path with an explicit budget assertion — read it as a safety stop, not as a checkout defect, and account for the orders already created before raising it.
113
+ - The checkout a test order drives is the Checkout on the tested path: the page typed `checkout`, which takes payment, never the first page typed checkout. On a multi-step path (`select` → `checkout_step`… → `checkout`) the runner enters the cart on the select page (bundle cards plus a checkout button; `--select-package` clicks the matching `data-next-bundle-card`) or on the first step, submits each `checkout_step` form and asserts it lands on the step's declared next page (`checkout_step_submitted`), and submits the order only on the Checkout. A campaign may hold several Checkouts, one per split-test path.
113
114
  - Resolve paths from the selected checkout's `expected_next_url`, then follow reachable offer `expected_accept_url` / `expected_decline_url` edges. Treat only declared receipt/thank-you pages and genuine cross-origin handoffs as terminals; an absent same-origin route is unresolved, not an external handoff.
114
115
  - `--test-order common` is the default depth. When every actual terminal path fits under the flood cap (`--max-test-orders`, 6 by default), it runs them all and records effective depth `full`, reason `under_cap`. Above the cap it runs checkout, first-offer accept/decline and the shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, until the cap is reached, and names any page left out. It never trims the checkout/accept/decline/receipt sample to fit a lower cap, and it must not synthesize a receipt path from offer count.
115
116
  - A page counts as exercised only when an order of its own funnel clicked its decline control. Reaching the page, or clicking only its accept, does not count. `browser-test-order:upsell-action-coverage` reports this for every page with upsell actions in every funnel of the run, and is `pass` or `warn` only when coverage is certain (orders placed, every offer page listed with its own unshared URL, every plan matched to one funnel, every click on a declared page of the order's funnel): `warn` names each page whose decline no order clicked and each funnel no order ran through, `pass` means every page's decline was clicked. Anything uncertain is `manual_review` naming the pages and why (`evidence.reason`, `evidence.not_assessable`, `evidence.uncertainty`), including a `--test-order off` run or attempts that all failed before an order reference. Read `warn` and `manual_review` as missing proof, not as a defect.
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.55.0+skills.1",
4
+ "bundle_revision": "1.59.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.52",
19
+ "version": "1.0.57",
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.35",
27
+ "version": "2.0.40",
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.37",
35
+ "version": "1.0.42",
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.36",
43
+ "version": "1.1.42",
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.36",
51
+ "version": "1.3.41",
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.32",
59
+ "version": "1.0.37",
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.32",
67
+ "version": "1.0.37",
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.32",
75
+ "version": "1.0.37",
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.32",
83
+ "version": "1.0.37",
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."
@@ -38,7 +38,7 @@ export const TEMPLATE_SLICE_REQUIRED_GROUPS = Object.freeze([
38
38
  ]);
39
39
 
40
40
  const TEMPLATE_FILES_COPIED_REQUIRED_FIELDS = Object.freeze(["status", "required_groups", "groups", "paths"]);
41
- const RUNTIME_PAGE_TYPES = new Set(["checkout", "select", "upsell", "downsell", "thankyou", "receipt"]);
41
+ const RUNTIME_PAGE_TYPES = new Set(["checkout", "checkout_step", "select", "upsell", "downsell", "thankyou", "receipt"]);
42
42
  const RAW_HTML_CONVERSION_STATUSES = new Set(["pending", "in_progress", "completed", "not_required", "blocked"]);
43
43
  const SOURCE_ASSET_STRATEGIES = new Set(["pagekit_campaign_asset_root", "external_cdn", "raw_passthrough", "not_applicable", "unknown"]);
44
44
  const ROUTE_REWRITE_POLICIES = new Set(["campaignspec_routes_via_campaign_link", "pagekit_public_routes", "raw_passthrough", "not_applicable", "unknown"]);
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, extname, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
 
6
+ import { CHECKOUT_FLOW_PAGE_TYPES } from "../campaign-spec/dist/index.js";
6
7
  import { cmd } from "./install-invocation.mjs";
7
8
  import { canonicalJson } from "./polish-capture.mjs";
8
9
  import { escapeRegExp } from "./repo-scan.mjs";
@@ -537,7 +538,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
537
538
 
538
539
  const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
539
540
  const paymentMethods = collectSpecPaymentMethods(spec);
540
- const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
541
+ const hasExitPop = activePages?.some((page) => isCheckoutFlowPage(page) && page?.exit_intent?.enabled === true) === true;
541
542
  const hasOrderBump = hasOrderBumpSignals(spec);
542
543
  // With no CampaignSpec surface to fill them, the template's promo
543
544
  // placeholders are removed: "none" for both.
@@ -718,8 +719,15 @@ function addQuestion(questions, id, { detail = null, options = [] } = {}) {
718
719
  });
719
720
  }
720
721
 
722
+ // select, checkout_step and checkout all render the SDK checkout surface, so
723
+ // payment-page features (exit pop, promo input) and the conversion goal apply
724
+ // to each of them (campaigns-os#641).
725
+ function isCheckoutFlowPage(page) {
726
+ return CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type);
727
+ }
728
+
721
729
  function inferConversionGoal(activePages = []) {
722
- const hasCheckout = activePages.some((page) => page?.type === "checkout");
730
+ const hasCheckout = activePages.some((page) => isCheckoutFlowPage(page));
723
731
  const hasUpsell = activePages.some((page) => /upsell|downsell/i.test(String(page?.type || "")));
724
732
  if (hasCheckout && hasUpsell) return "direct response funnel with post-purchase offers";
725
733
  if (hasCheckout) return "single-product direct response funnel";
@@ -837,9 +845,10 @@ function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
837
845
  surfaces.push("promo codes (funnels[].promo_codes)");
838
846
  }
839
847
  const pages = Array.isArray(activePages) ? activePages : [];
840
- // Checkout-only and enabled: true, the rule hasExitPop, doctor's exit-pop
841
- // contract and QA's coupon orders use for these surfaces.
842
- const checkoutSurface = (page, key) => page?.type === "checkout" && page?.[key]?.enabled === true;
848
+ // Checkout-flow pages only (select, checkout_step, checkout) and enabled:
849
+ // true, the rule hasExitPop, doctor's exit-pop contract and QA's coupon
850
+ // orders use for these surfaces.
851
+ const checkoutSurface = (page, key) => isCheckoutFlowPage(page) && page?.[key]?.enabled === true;
843
852
  if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
844
853
  surfaces.push("an exit-intent offer (exit_intent)");
845
854
  }
@@ -355,3 +355,94 @@ export function computeBuildFingerprint(outputDir, { exclude = [] } = {}) {
355
355
  error: null,
356
356
  };
357
357
  }
358
+
359
+ // The manifest as `{ path, sha256 }` entries, the shape `record build` keeps
360
+ // on stages.assembly.build_manifest beside build_fingerprint so a later
361
+ // mismatch can name the files that differ instead of only two hashes.
362
+ export function buildManifestEntries(fingerprint) {
363
+ const lines = String(fingerprint?.manifest || "").split("\n");
364
+ const entries = [];
365
+ for (let index = 0; index + 1 < lines.length; index += 2) {
366
+ if (lines[index]) entries.push({ path: lines[index], sha256: lines[index + 1] });
367
+ }
368
+ return entries;
369
+ }
370
+
371
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
372
+
373
+ // The recorded manifest, only when it is evidence about the recorded build:
374
+ // well-formed entries whose canonical manifest hashes to the recorded
375
+ // fingerprint. A record made before build kept a manifest, or one rewritten
376
+ // by hand, reads as unavailable (null) rather than as a misleading diff.
377
+ export function recordedBuildManifest(assembly) {
378
+ const entries = assembly?.build_manifest;
379
+ const recorded = assembly?.build_fingerprint;
380
+ if (!Array.isArray(entries) || typeof recorded !== "string") return null;
381
+ if (!entries.every((entry) => typeof entry?.path === "string" && entry.path && typeof entry?.sha256 === "string" && SHA256_HEX.test(entry.sha256))) return null;
382
+ const manifest = entries.map((entry) => `${entry.path}\n${entry.sha256}\n`).join("");
383
+ return `sha256:${sha256Hex(Buffer.from(manifest, "utf8"))}` === recorded ? entries : null;
384
+ }
385
+
386
+ // macOS and iCloud name a sync conflict copy `<name> 2.<ext>` (then ` 3`,
387
+ // ...), on files and on folders. A trailing " N" alone is too common in real
388
+ // names ("chapter 12.md"), so a path counts as a copy only when the same path
389
+ // without the suffix also exists. The flag is a hint, never a verdict.
390
+ const CONFLICT_COPY_SEGMENT = /^(.+) (?:[2-9]|[1-9]\d+)(\.[^.]+)?$/;
391
+ export function isSyncConflictCopyPath(path, knownPaths) {
392
+ const segments = String(path).split("/");
393
+ const known = [...knownPaths];
394
+ return segments.some((segment, index) => {
395
+ const match = CONFLICT_COPY_SEGMENT.exec(segment);
396
+ if (!match) return false;
397
+ const original = [...segments.slice(0, index), `${match[1]}${match[2] ?? ""}`].join("/");
398
+ return index === segments.length - 1
399
+ ? known.includes(original)
400
+ : known.some((candidate) => candidate.startsWith(`${original}/`));
401
+ });
402
+ }
403
+
404
+ export const BUILD_DRIFT_PATH_LIMIT = 20;
405
+
406
+ /**
407
+ * Compare the output on disk (a computeBuildFingerprint result) with the
408
+ * manifest stages.assembly recorded. Returns
409
+ * { manifest: "recorded", extra, missing, changed, conflict_copies, summary }
410
+ * with sorted path lists, or { manifest: "unavailable", summary } when the
411
+ * record carries no usable manifest. `summary` is one sentence for a
412
+ * refusal: each list capped at BUILD_DRIFT_PATH_LIMIT with "+N more".
413
+ */
414
+ export function describeBuildOutputDrift(assembly, current) {
415
+ const recorded = recordedBuildManifest(assembly);
416
+ if (!recorded) {
417
+ return {
418
+ manifest: "unavailable",
419
+ summary: "The build record carries no file manifest (it was recorded before record build kept one), so the differing paths cannot be named; re-run record build to record one.",
420
+ };
421
+ }
422
+ const before = new Map(recorded.map((entry) => [entry.path, entry.sha256]));
423
+ const now = new Map(buildManifestEntries(current).map((entry) => [entry.path, entry.sha256]));
424
+ const extra = [...now.keys()].filter((path) => !before.has(path));
425
+ const missing = [...before.keys()].filter((path) => !now.has(path));
426
+ const changed = [...now.keys()].filter((path) => before.has(path) && before.get(path) !== now.get(path));
427
+ const knownPaths = new Set([...before.keys(), ...now.keys()]);
428
+ const isCopy = (path) => isSyncConflictCopyPath(path, knownPaths);
429
+ const conflictCopies = extra.filter(isCopy);
430
+ const list = (label, paths) => {
431
+ if (paths.length === 0) return null;
432
+ const shown = paths.slice(0, BUILD_DRIFT_PATH_LIMIT).map((path) => (isCopy(path) ? `${path} [sync conflict copy]` : path));
433
+ const more = paths.length > BUILD_DRIFT_PATH_LIMIT ? ` (+${paths.length - BUILD_DRIFT_PATH_LIMIT} more)` : "";
434
+ return `${paths.length} ${label}: ${shown.join(", ")}${more}`;
435
+ };
436
+ const parts = [list("extra", extra), list("missing", missing), list("changed", changed)].filter(Boolean);
437
+ const hint = conflictCopies.length
438
+ ? ` ${conflictCopies.length} extra file name(s) look like macOS/iCloud sync conflict copies ("<name> 2.<ext>" beside "<name>.<ext>"); if they are, remove them (or move the repo out of the synced folder) and rebuild.`
439
+ : "";
440
+ return {
441
+ manifest: "recorded",
442
+ extra,
443
+ missing,
444
+ changed,
445
+ conflict_copies: conflictCopies,
446
+ summary: `Against the manifest build recorded: ${parts.length ? parts.join("; ") : "no path differs"}.${hint}`,
447
+ };
448
+ }
@@ -79,6 +79,134 @@ function isLocalAbsolutePath(value) {
79
79
  return isNonEmptyString(value) && !isAbsoluteHttpUrl(value) && isAbsolute(value);
80
80
  }
81
81
 
82
+ // Commit-ready JSON has a finite set of filesystem fields. Route roots and URL
83
+ // paths are deliberately absent: a leading slash does not make one a file.
84
+ function portableArtifactPath(targetRepo, path, { baseDir = targetRepo } = {}) {
85
+ const rel = relative(resolve(baseDir), resolve(path));
86
+ return !rel ? "." : rel.startsWith(".") ? rel : `./${rel}`;
87
+ }
88
+
89
+ const ARTIFACT_PATH_FIELDS = Object.freeze({
90
+ "campaign-runtime-build-packet/v0": [
91
+ "spec.local_path", "design_source_package.path", "source_html.root",
92
+ "source_html.pages.*.path", "source_html.pages.*.page_kit.target_path",
93
+ "source_html.pages.*.page_kit.output_path", "build_brief.input_path",
94
+ "build_brief.normalized_path", "assembly.target_repo", "assembly.output_dir",
95
+ "assembly.commerce_catalog.path",
96
+ ],
97
+ "campaign-runtime-build-context/v0": [
98
+ "packet_path", "report_path", "intake.spec_path", "intake.source_root",
99
+ "intake.target_repo", "intake.brief_path", "intake.design_manifest_path",
100
+ "intake.packet_path", "design_source_package.path", "spec.path", "source.root",
101
+ "build_brief.input_path", "build_brief.normalized_path",
102
+ "page_map.*.source_path", "page_map.*.output_path",
103
+ "page_map.*.page_kit.target_path", "page_map.*.page_kit.output_path",
104
+ "scaffold.target_repo", "scaffold.output_dir", "scaffold.handoff_artifact",
105
+ "theme.source_files.*.path", "theme.generated.css_path", "theme.generated.report_path",
106
+ ],
107
+ "campaign-runtime-assembly-report/v0": [
108
+ "inputs.packet_path", "inputs.context_path", "inputs.build_brief_path",
109
+ "inputs.spec_path", "inputs.source.root", "inputs.target_repo",
110
+ "design_source_package.path", "build_brief.input_path", "build_brief.normalized_path",
111
+ // `**` also reaches each stage's archived entries.
112
+ "theme.css_path", "stages.**.inputs.*", "stages.**.outputs.*",
113
+ ],
114
+ "campaigns-os-doctor-output/v0": [
115
+ "packet_path", "assembly_report_path", "spec_path", "emitted_packet_path",
116
+ "derived.packet_path", "derived.assembly_report_path", "derived.spec_path",
117
+ "derived.target_repo", "derived.source_root", "derived.output_dir", "derived.target_output_dir",
118
+ "errors.*.detail.spec_path", "errors.*.detail.input_path",
119
+ "warnings.*.detail.spec_path", "warnings.*.detail.input_path",
120
+ ],
121
+ "campaigns-os-build-brief/v1": ["_meta.input_path"],
122
+ });
123
+
124
+ const ARTIFACT_COMMAND_FIELDS = Object.freeze({
125
+ "campaign-runtime-build-packet/v0": ["source_html.pages.*.skip_reason"],
126
+ "campaign-runtime-build-context/v0": [
127
+ "decisions.*.decision", "decisions.*.evidence.*",
128
+ "prompts_required.*.message", "page_map.*.skip_reason", "source.manifest_warnings.*",
129
+ ],
130
+ "campaign-runtime-assembly-report/v0": [
131
+ "next.command", "stages.**.commands.*",
132
+ "decisions.*.decision", "decisions.*.evidence.*",
133
+ "**.message", "**.blockers.*", "**.warnings.*",
134
+ ],
135
+ "campaigns-os-doctor-output/v0": [
136
+ "next.command", "next.actions.*", "next_actions.*.command", "**.message", "**.blockers.*", "**.warnings.*",
137
+ ],
138
+ });
139
+
140
+ function fieldMatches(pattern, segments) {
141
+ const parts = pattern.split(".");
142
+ function matches(at, index) {
143
+ if (at === parts.length) return index === segments.length;
144
+ if (parts[at] === "**") return matches(at + 1, index) || (index < segments.length && matches(at, index + 1));
145
+ return index < segments.length && (parts[at] === "*" || parts[at] === segments[index]) && matches(at + 1, index + 1);
146
+ }
147
+ return matches(0, 0);
148
+ }
149
+
150
+ function portableArtifactPaths(value, targetRepo, { artifactPath = null } = {}) {
151
+ const schema = isObject(value) ? value.schema_version : null;
152
+ const pathFields = ARTIFACT_PATH_FIELDS[schema] || [];
153
+ const commandFields = ARTIFACT_COMMAND_FIELDS[schema] || [];
154
+ const artifactDir = artifactPath ? dirname(resolve(artifactPath)) : null;
155
+ const packet = schema === "campaign-runtime-build-packet/v0";
156
+ const context = schema === "campaign-runtime-build-context/v0";
157
+ const report = schema === "campaign-runtime-assembly-report/v0";
158
+ const sourceField = packet ? value.source_html?.root : context ? value.source?.root : report ? value.inputs?.source?.root : null;
159
+ const sourceBase = packet ? artifactDir : targetRepo;
160
+ const sourceRoot = isNonEmptyString(sourceField) && sourceBase ? resolve(sourceBase, sourceField) : null;
161
+ const outputField = packet ? value.assembly?.output_dir : context ? value.scaffold?.output_dir : null;
162
+ const outputBase = targetRepo;
163
+ const outputDir = isNonEmptyString(outputField) && outputBase ? resolve(outputBase, outputField) : null;
164
+ const roots = [...new Set([targetRepo, sourceRoot].filter(Boolean).flatMap((root) => [resolve(root), canonicalPath(root)]))]
165
+ .filter((root) => root !== "/")
166
+ .sort((left, right) => right.length - left.length);
167
+ function fieldBase(segments) {
168
+ const path = segments.join(".");
169
+ if (packet) {
170
+ if (path === "source_html.pages.*.path") return sourceRoot;
171
+ if (path === "source_html.pages.*.page_kit.target_path") return outputDir;
172
+ if (path === "source_html.pages.*.page_kit.output_path") return targetRepo;
173
+ if (path === "assembly.output_dir") return targetRepo;
174
+ return artifactDir;
175
+ }
176
+ if (context) {
177
+ if (path === "page_map.*.source_path") return sourceRoot;
178
+ if (path === "page_map.*.page_kit.target_path") return outputDir;
179
+ if (path === "design_source_package.path") return artifactDir;
180
+ }
181
+ if (report && path === "design_source_package.path") return artifactDir;
182
+ if (schema === "campaigns-os-build-brief/v1") return artifactDir;
183
+ return targetRepo;
184
+ }
185
+ function portableCommand(command) {
186
+ let output = command;
187
+ for (const root of roots) {
188
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ const replacement = portableArtifactPath(targetRepo, root);
190
+ output = output.replace(new RegExp(`(^|[\\s"'\`(=,;\\[<])${escaped}(?=$|[/\\s"'\`.,:;)\\]>])`, "g"),
191
+ (_match, prefix) => `${prefix}${replacement}`);
192
+ }
193
+ return output;
194
+ }
195
+ function visit(entry, segments = []) {
196
+ if (Array.isArray(entry)) return entry.map((item) => visit(item, [...segments, "*"]));
197
+ if (isObject(entry)) return Object.fromEntries(Object.entries(entry).map(([field, item]) => [field, visit(item, [...segments, field])]));
198
+ if (typeof entry !== "string") return entry;
199
+ if (Array.isArray(value) && segments.length === 1 && isLocalAbsolutePath(entry)) return portableArtifactPath(targetRepo, entry);
200
+ if (pathFields.some((pattern) => fieldMatches(pattern, segments)) && isLocalAbsolutePath(entry)) {
201
+ const baseDir = fieldBase(segments);
202
+ return baseDir ? portableArtifactPath(targetRepo, entry, { baseDir }) : entry;
203
+ }
204
+ if (commandFields.some((pattern) => fieldMatches(pattern, segments))) return portableCommand(entry);
205
+ return entry;
206
+ }
207
+ return visit(value);
208
+ }
209
+
82
210
  function resolveFromFile(filePath, targetPath) {
83
211
  if (!isNonEmptyString(targetPath)) return null;
84
212
  if (isAbsoluteHttpUrl(targetPath)) return targetPath;
@@ -111,6 +239,7 @@ export {
111
239
  sha256File,
112
240
  relFromDir,
113
241
  isLocalAbsolutePath,
242
+ portableArtifactPaths,
114
243
  resolveFromFile,
115
244
  filesystemPathsMatch,
116
245
  extractFrontmatterValue,