@nextcommerce/campaigns-os 1.48.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 (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/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 +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  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/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -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