@nextcommerce/campaigns-os 1.61.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -0,0 +1,216 @@
1
+ // `campaigns-os spec derive`, live campaign half (#721): refresh the offer and
2
+ // package fields the CampaignSpec mirrors from the Campaigns App.
3
+ //
4
+ // Field classes (#432): a page package's ref, name, price (recurring price
5
+ // included), product, variant and availability are mirrored from the Campaigns App's packages; the offer
6
+ // records (`offers[]`, and the copy a page carries in `pages[].offers[]`) are
7
+ // mirrored from its offers. Which packages and offers a page carries, and a
8
+ // package's qty and role flags (is_upsell, is_order_bump, default_selected,
9
+ // configurable), are authored and never written here. A price is owned by the
10
+ // Campaigns App: it reaches the spec only from the live campaign, never from
11
+ // the repo.
12
+ //
13
+ // The live campaign is the body of the one read-only GET of
14
+ // `{proxy-base}/api/campaign` that doctor and `qa run` already make under the
15
+ // public campaign key (live-campaign-refs.mjs). This module is pure: it
16
+ // plans rows in spec-derive.mjs's shape from the spec and that body, and
17
+ // spec-derive.mjs applies them under its write guard.
18
+
19
+ // The page package fields the live campaign is the authority for, as Map
20
+ // Builder exports them: the recurring price fields a subscription package
21
+ // carries, and `variant_attributes`, which the campaign states as
22
+ // `product_variant_attribute_values`.
23
+ export const MIRRORED_PACKAGE_FIELDS = Object.freeze([
24
+ "name",
25
+ "price",
26
+ "price_retail",
27
+ "product_name",
28
+ "product_sku",
29
+ "product_variant_name",
30
+ "product_purchase_availability",
31
+ "product_inventory_availability",
32
+ "image",
33
+ "is_recurring",
34
+ "price_recurring",
35
+ "interval",
36
+ "interval_count",
37
+ "variant_attributes",
38
+ ]);
39
+
40
+ // A spec field whose live value sits under another key.
41
+ const LIVE_FIELD = Object.freeze({ variant_attributes: "product_variant_attribute_values" });
42
+
43
+ // The offer record fields (root `offers[]`), and the subset a page's copy
44
+ // (`pages[].offers[]`) carries.
45
+ export const MIRRORED_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit", "packages"]);
46
+ export const MIRRORED_PAGE_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit"]);
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ // A ref as the campaign states it, compared as its string form (5 and "5"
53
+ // are one ref), or null when the entry carries none.
54
+ function refOf(value) {
55
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
56
+ if (typeof value === "string" && value.trim()) return value.trim();
57
+ return null;
58
+ }
59
+
60
+ function canonicalJson(value) {
61
+ if (value === undefined) return "undefined";
62
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
63
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
64
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
65
+ }
66
+
67
+ function pathLabel(path) {
68
+ return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
69
+ }
70
+
71
+ function clone(value) {
72
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
73
+ }
74
+
75
+ // Every active page with the JSON path it lives at, as spec-derive.mjs reads
76
+ // them (funnels[] first; the legacy funnel_pages[] only without it).
77
+ function activePagesWithPaths(spec) {
78
+ const pages = [];
79
+ const visit = (page, path) => {
80
+ if (isPlainObject(page) && page.enabled !== false && typeof page.id === "string" && page.id.trim()) pages.push({ page, path });
81
+ };
82
+ if (Array.isArray(spec?.funnels)) {
83
+ spec.funnels.forEach((funnel, funnelIndex) => {
84
+ (Array.isArray(funnel?.pages) ? funnel.pages : []).forEach((page, pageIndex) => visit(page, ["funnels", funnelIndex, "pages", pageIndex]));
85
+ });
86
+ } else if (Array.isArray(spec?.funnel_pages)) {
87
+ spec.funnel_pages.forEach((page, index) => visit(page, ["funnel_pages", index]));
88
+ }
89
+ return pages;
90
+ }
91
+
92
+ function indexByRef(list) {
93
+ const map = new Map();
94
+ for (const entry of Array.isArray(list) ? list : []) {
95
+ const ref = isPlainObject(entry) ? refOf(entry.ref_id) : null;
96
+ if (ref && !map.has(ref)) map.set(ref, entry);
97
+ }
98
+ return map;
99
+ }
100
+
101
+ // The rows that bring one mirrored record in line with the live one: a field
102
+ // the live record states is written when the spec's value differs, and only
103
+ // where the spec already carries the key or the live value says something
104
+ // (not null, false or an empty list, which is how Map Builder leaves a
105
+ // one-time package's recurring fields out), so a refresh does not pad the
106
+ // spec with empty keys.
107
+ function mirrorRows({ record, live, fields, path, source, pageId = null }) {
108
+ const changes = [];
109
+ const unchanged = [];
110
+ for (const field of fields) {
111
+ const key = LIVE_FIELD[field] || field;
112
+ if (!Object.hasOwn(live, key) || live[key] === undefined) continue;
113
+ const value = live[key];
114
+ const before = Object.hasOwn(record, field) ? record[field] : undefined;
115
+ if (before === undefined && (value === null || value === false || (Array.isArray(value) && !value.length))) continue;
116
+ const row = { field: pathLabel([...path, field]), path: [...path, field], before: clone(before), after: clone(value), source, ...(pageId ? { page_id: pageId } : {}) };
117
+ if (canonicalJson(before) === canonicalJson(value)) unchanged.push(row);
118
+ else changes.push(row);
119
+ }
120
+ return { changes, unchanged };
121
+ }
122
+
123
+ /**
124
+ * The plan for the mirrored offer and package fields: `changes` and
125
+ * `unchanged` rows in spec-derive.mjs's shape, `not_derived` for a record the
126
+ * spec lists that the live campaign does not serve (reported, never removed:
127
+ * which records a page carries is authored), and `summary` counts. `campaign`
128
+ * is the live campaign body; `campaignRef` names it in each row's source.
129
+ */
130
+ export function planCampaignMirrorDerive({ spec, campaign, campaignRef = null } = {}) {
131
+ const changes = [];
132
+ const unchanged = [];
133
+ const notDerived = [];
134
+ const ref = campaignRef || refOf(campaign?.ref_id ?? campaign?.id);
135
+ const source = `the Campaigns App campaign${ref ? ` ${ref}` : ""} (GET /api/campaign)`;
136
+ const livePackages = indexByRef(campaign?.packages);
137
+ const liveOffers = Array.isArray(campaign?.offers) ? indexByRef(campaign.offers) : null;
138
+ const summary = { packages_checked: 0, offers_checked: 0, offers_added: 0 };
139
+
140
+ for (const { page, path } of activePagesWithPaths(spec)) {
141
+ (Array.isArray(page.packages) ? page.packages : []).forEach((pkg, index) => {
142
+ if (!isPlainObject(pkg)) return;
143
+ const pkgRef = refOf(pkg.ref_id);
144
+ const pkgPath = [...path, "packages", index];
145
+ if (!pkgRef) return;
146
+ const live = livePackages.get(pkgRef);
147
+ if (!live) {
148
+ notDerived.push({ field: pathLabel(pkgPath), page_id: page.id, reason: "package_not_live", detail: `page "${page.id}" carries package ${pkgRef}, which the live campaign does not serve; nothing was changed. Point the page at a package the campaign serves, or restore it in the Campaigns App.` });
149
+ return;
150
+ }
151
+ summary.packages_checked += 1;
152
+ const rows = mirrorRows({ record: pkg, live, fields: MIRRORED_PACKAGE_FIELDS, path: pkgPath, source, pageId: page.id });
153
+ changes.push(...rows.changes);
154
+ unchanged.push(...rows.unchanged);
155
+ });
156
+ (Array.isArray(page.offers) ? page.offers : []).forEach((offer, index) => {
157
+ if (!isPlainObject(offer) || !liveOffers) return;
158
+ const offerRef = refOf(offer.ref_id);
159
+ const offerPath = [...path, "offers", index];
160
+ if (!offerRef) return;
161
+ const live = liveOffers.get(offerRef);
162
+ if (!live) {
163
+ notDerived.push({ field: pathLabel(offerPath), page_id: page.id, reason: "offer_not_live", detail: `page "${page.id}" carries offer ${offerRef}, which the live campaign does not serve; nothing was changed. Remove it from the page, or restore it in the Campaigns App.` });
164
+ return;
165
+ }
166
+ const rows = mirrorRows({ record: offer, live, fields: MIRRORED_PAGE_OFFER_FIELDS, path: offerPath, source, pageId: page.id });
167
+ changes.push(...rows.changes);
168
+ unchanged.push(...rows.unchanged);
169
+ });
170
+ }
171
+
172
+ if (!liveOffers) {
173
+ if (Array.isArray(spec?.offers) && spec.offers.length) {
174
+ notDerived.push({ field: "offers", reason: "offers_not_in_live_campaign", detail: "the live campaign read carried no offers list, so the spec's offer records were not refreshed." });
175
+ }
176
+ } else if (!Array.isArray(spec?.offers)) {
177
+ const records = [...liveOffers.values()].map((live) => recordFromLive(live));
178
+ if (records.length) {
179
+ changes.push({ field: "offers", path: ["offers"], before: spec?.offers === undefined ? undefined : clone(spec.offers), after: records, source });
180
+ summary.offers_added = records.length;
181
+ }
182
+ } else {
183
+ const seen = new Set();
184
+ spec.offers.forEach((offer, index) => {
185
+ if (!isPlainObject(offer)) return;
186
+ const offerRef = refOf(offer.ref_id);
187
+ if (!offerRef) return;
188
+ seen.add(offerRef);
189
+ const live = liveOffers.get(offerRef);
190
+ if (!live) {
191
+ notDerived.push({ field: `offers[${index}]`, reason: "offer_not_live", detail: `offer ${offerRef} is in the spec's offers but not in the live campaign; it was kept, since pages may still name it. Remove it, or restore it in the Campaigns App.` });
192
+ return;
193
+ }
194
+ summary.offers_checked += 1;
195
+ const rows = mirrorRows({ record: offer, live, fields: MIRRORED_OFFER_FIELDS, path: ["offers", index], source });
196
+ changes.push(...rows.changes);
197
+ unchanged.push(...rows.unchanged);
198
+ });
199
+ let next = spec.offers.length;
200
+ for (const [offerRef, live] of liveOffers) {
201
+ if (seen.has(offerRef)) continue;
202
+ changes.push({ field: `offers[${next}]`, path: ["offers", next], op: "insert", before: undefined, after: recordFromLive(live), source });
203
+ next += 1;
204
+ summary.offers_added += 1;
205
+ }
206
+ }
207
+ return { changes, unchanged, not_derived: notDerived, summary };
208
+ }
209
+
210
+ // An offer record as the spec stores one: its ref and the mirrored fields the
211
+ // live offer states.
212
+ function recordFromLive(live) {
213
+ const record = { ref_id: live.ref_id };
214
+ for (const field of MIRRORED_OFFER_FIELDS) if (Object.hasOwn(live, field) && live[field] !== undefined) record[field] = clone(live[field]);
215
+ return record;
216
+ }