@nextcommerce/campaigns-os 1.50.0 → 1.53.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -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 +282 -14
  13. package/contracts/release-ledger.json +1233 -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/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  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 +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -1,10 +1,27 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, statSync } from "node:fs";
2
3
  import { basename, extname, join, resolve } from "node:path";
3
4
  import { parse as parseYaml } from "yaml";
4
5
 
6
+ import { cmd } from "./install-invocation.mjs";
7
+ import { canonicalJson } from "./polish-capture.mjs";
5
8
  import { escapeRegExp } from "./repo-scan.mjs";
6
9
 
7
10
  export const BUILD_BRIEF_SCHEMA = "campaigns-os-build-brief/v1";
11
+ // The brief value of a packet that carries no Campaign Build Brief, for both
12
+ // material partitions and for every stage stamp made against it.
13
+ export const BRIEF_ABSENT = "absent";
14
+ // The fields the campaign intent summary prints, in its order. `<page>` stands
15
+ // for each page id present in design_authority; _meta.field_sources keys use
16
+ // the concrete id (design_authority.checkout.source).
17
+ export const SUMMARY_FIELDS = Object.freeze(["campaign_intent.audience", "campaign_intent.conversion_goal", "campaign_intent.tone", "brand.commerce_palette_source", "brand.primary_accent", "brand.cta_style", "brand.avoid", "design_authority.<page>.source", "template_residue_policy.block_placeholders"]);
18
+ const BRIEF_MODE_SOURCES = Object.freeze({ FIELD: "field", LEGACY_REPORT: "legacy_report", GENERATED: "generated" });
19
+ // Codes of the errors normalization itself raises. They block in either mode;
20
+ // evaluation gates block only a prepared brief.
21
+ const NORMALIZATION_ERROR_CODES = Object.freeze(new Set(["build_brief.type", "build_brief.schema_version", "build_brief.brief_mode"]));
22
+ // Top-level keys that never count as material: normalization and evaluation
23
+ // output, and the declared mode. qa_policy.enforcement is fixed package text.
24
+ const NON_MATERIAL_BRIEF_KEYS = Object.freeze(new Set(["_meta", "status", "questions", "gates", "confidence", "schema_version", "brief_mode"]));
8
25
  export const BUILD_BRIEF_NORMALIZED_REL_PATH = ".campaign-runtime/input/campaign-build-brief.normalized.json";
9
26
  export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
10
27
  "campaign-build-brief.yaml",
@@ -12,11 +29,15 @@ export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
12
29
  "campaign-build-brief.json",
13
30
  ]);
14
31
 
32
+ // answer_fields: the brief fields whose values close the question, as
33
+ // evaluateCampaignBuildBrief reads them. The guided-questions warning names
34
+ // them so an answer given in conversation can be written where it counts.
15
35
  const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
16
36
  {
17
37
  id: "page_design_authority",
18
38
  priority: 1,
19
39
  field: "design_authority",
40
+ answer_fields: ["design_authority.<page_id>.source"],
20
41
  question: "Which source controls each campaign page: the provided design export, the selected template, or a template adapted to another page?",
21
42
  reason: "Page-by-page authority prevents checkout, OTO, and receipt pages from drifting into unrelated starter-template composition.",
22
43
  },
@@ -24,6 +45,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
24
45
  id: "brand_palette_cta",
25
46
  priority: 2,
26
47
  field: "brand",
48
+ answer_fields: ["brand.commerce_palette_source", "brand.cta_style"],
27
49
  question: "Which palette and CTA style should commerce pages use?",
28
50
  reason: "Commerce pages need a business-approved brand layer instead of silent template defaults.",
29
51
  },
@@ -31,6 +53,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
31
53
  id: "variant_media_rules",
32
54
  priority: 3,
33
55
  field: "media",
56
+ answer_fields: ["media.sold_variants", "media.allow_other_variant_colors"],
34
57
  question: "Which product variants or colors are actually sold, and may media show other variants?",
35
58
  reason: "Variant ambiguity often creates wrong-color carousels and unavailable-product claims.",
36
59
  },
@@ -38,6 +61,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
38
61
  id: "bundle_pricing_presentation",
39
62
  priority: 4,
40
63
  field: "offer_presentation.bundle_cards",
64
+ answer_fields: ["offer_presentation.bundle_cards.primary_price"],
41
65
  question: "How should bundle cards present pricing: simple unit price, savings-led, or full accounting?",
42
66
  reason: "CampaignSpec owns prices; the brief owns which shopper-facing price story is appropriate.",
43
67
  },
@@ -45,13 +69,16 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
45
69
  id: "promo_urgency_copy",
46
70
  priority: 5,
47
71
  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.",
72
+ answer_fields: ["promo_urgency.header_claim_source", "promo_urgency.timer_label"],
73
+ 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',
74
+ 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?",
75
+ 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
76
  },
51
77
  {
52
78
  id: "payment_methods_trust",
53
79
  priority: 6,
54
80
  field: "commerce_surfaces.payment_methods_allowed",
81
+ answer_fields: ["commerce_surfaces.payment_methods_allowed"],
55
82
  question: "Which payment methods and trust badges may appear?",
56
83
  reason: "Templates often carry demo wallets or badges that must not survive without approval.",
57
84
  },
@@ -59,6 +86,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
59
86
  id: "canonical_display_names",
60
87
  priority: 7,
61
88
  field: "canonical_display.product_name_source",
89
+ answer_fields: ["canonical_display.product_name_source"],
62
90
  question: "Should CampaignSpec display names win, or may runtime/catalog names override them?",
63
91
  reason: "Name drift across source, spec, and runtime data is hard to spot after assembly.",
64
92
  },
@@ -66,6 +94,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
66
94
  id: "regulated_claims",
67
95
  priority: 8,
68
96
  field: "campaign_intent.compliance",
97
+ answer_fields: ["campaign_intent.compliance.approved_benefit_language", "campaign_intent.compliance.forbidden_claims"],
69
98
  question: "Are there regulated claims, forbidden phrases, or approved benefit statements the build must follow?",
70
99
  reason: "Health, financial, and other regulated offers need explicit copy boundaries.",
71
100
  conditional: "regulated",
@@ -157,6 +186,32 @@ export function loadCampaignBuildBriefFile(path) {
157
186
  return { path: resolvedPath, format, value: parsed };
158
187
  }
159
188
 
189
+ // The mode a brief file declares, in rule order: brief_mode "prepared" or
190
+ // "guided"; without brief_mode, prepared only when the report this write
191
+ // replaces already recorded a prepared brief for the campaign (so it keeps the
192
+ // blocking it was built under); otherwise guided. Any other brief_mode value
193
+ // is a normalization error. The file's own _meta is never read.
194
+ function briefModeFor(loaded, boundReportMode, errors) {
195
+ if (!loaded) return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
196
+ const declared = isObject(loaded.value) && Object.hasOwn(loaded.value, "brief_mode");
197
+ if (declared && loaded.value.brief_mode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.FIELD };
198
+ if (declared && loaded.value.brief_mode === "guided") return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
199
+ if (declared) {
200
+ errors.push({
201
+ code: "build_brief.brief_mode",
202
+ field: "brief_mode",
203
+ message: `Campaign Build Brief brief_mode must be "prepared" or "guided" (got ${JSON.stringify(loaded.value.brief_mode)}); omit it for a guided brief.`,
204
+ });
205
+ return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
206
+ }
207
+ if (boundReportMode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.LEGACY_REPORT };
208
+ return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
209
+ }
210
+
211
+ // `boundReportMode`: build_brief.mode on the Assembly Report this write
212
+ // replaces, when that report belongs to the same campaign (mode rule 3).
213
+ // `previousNormalizedBrief`: the normalized brief on disk before this write,
214
+ // read for field provenance only.
160
215
  export function createCampaignBuildBriefArtifact({
161
216
  inputPath = null,
162
217
  inputSource = null,
@@ -166,19 +221,24 @@ export function createCampaignBuildBriefArtifact({
166
221
  templateFamily = null,
167
222
  sourceAssetCrawl = null,
168
223
  commerceZoneFindings = [],
224
+ boundReportMode = null,
225
+ previousNormalizedBrief = null,
169
226
  } = {}) {
170
227
  const loaded = inputPath ? loadCampaignBuildBriefFile(inputPath) : null;
171
- const mode = loaded ? "prepared" : "guided_draft";
228
+ const errors = [];
229
+ const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
172
230
  const baseBrief = loaded
173
231
  ? cloneJsonOrEmpty(loaded.value)
174
232
  : draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
175
233
 
176
- const errors = [];
177
234
  const normalized = normalizeCampaignBuildBrief(baseBrief, {
178
235
  mode,
236
+ modeSource,
179
237
  inputPath: loaded?.path || null,
180
238
  inputFormat: loaded?.format || "generated",
181
239
  inputSource,
240
+ fileValue: loaded ? loaded.value : null,
241
+ previousNormalizedBrief,
182
242
  spec,
183
243
  activePages,
184
244
  pageMappings,
@@ -210,9 +270,11 @@ export function createCampaignBuildBriefArtifact({
210
270
  normalized.questions = evaluation.questions;
211
271
  normalized.gates = gates;
212
272
 
273
+ // A prepared brief blocks on every blocker gate; a guided one only on the
274
+ // gates its normalization errors produced (the first errors.length gates).
213
275
  const blocking = mode === "prepared"
214
276
  ? blockerGates
215
- : [];
277
+ : gates.slice(0, errors.length);
216
278
 
217
279
  return {
218
280
  mode,
@@ -227,7 +289,18 @@ export function createCampaignBuildBriefArtifact({
227
289
  };
228
290
  }
229
291
 
230
- export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {}) {
292
+ // "brand_palette_cta (brand.commerce_palette_source, brand.cta_style)": each
293
+ // open question with the brief fields that close it.
294
+ function describeOpenQuestions(questions) {
295
+ return questions.map((question) => {
296
+ const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
297
+ if (isNonEmptyString(entry?.answer_hint)) return `${question?.id} (${entry.answer_hint})`;
298
+ const fields = entry?.answer_fields || (isNonEmptyString(question?.field) ? [question.field] : []);
299
+ return fields.length ? `${question?.id} (${fields.join(", ")})` : String(question?.id);
300
+ }).join(", ");
301
+ }
302
+
303
+ export function validateCampaignBuildBriefArtifact(brief, { spec = null, normalizedPath = BUILD_BRIEF_NORMALIZED_REL_PATH } = {}) {
231
304
  const errors = [];
232
305
  const warnings = [];
233
306
  const ready = [];
@@ -255,18 +328,26 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {})
255
328
  if (questions.length) {
256
329
  errors.push({
257
330
  code: "build_brief.questions_unanswered",
258
- message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${questions.map((question) => question.id).join(", ")}.`,
331
+ message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and save it with ${cmd("record")} brief --packet <packet>.`,
259
332
  });
260
333
  }
261
334
  } else {
262
335
  if (questions.length) {
336
+ // An answer given in conversation is not recorded until it is in a
337
+ // brief file saved with record brief: the guided draft is regenerated
338
+ // on every intake, and a brief file replaces it whole.
263
339
  warnings.push({
264
340
  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(", ")}.`,
341
+ message: `Guided Campaign Build Brief has ${questions.length} high-impact business question(s) to confirm: ${describeOpenQuestions(questions)}. `
342
+ + `An answer counts only once it is saved: copy ${normalizedPath} to campaign-build-brief.json in the target repo, set those fields, and run ${cmd("record")} brief --packet <packet> `
343
+ + "(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. "
344
+ + "Saving keeps every stage's recorded evidence unless the brief's content changes.",
266
345
  });
267
346
  }
347
+ // Input errors block in both modes; evaluation gates stay warnings here.
268
348
  for (const gate of blockerGates) {
269
- warnings.push({ code: gate.code || "build_brief.blocker", message: gate.message || "Generated Campaign Build Brief draft has an unresolved blocker." });
349
+ const target = brief.status === "invalid" && NORMALIZATION_ERROR_CODES.has(gate.code) ? errors : warnings;
350
+ target.push({ code: gate.code || "build_brief.blocker", message: gate.message || "Guided Campaign Build Brief has an unresolved blocker." });
270
351
  }
271
352
  }
272
353
 
@@ -322,6 +403,7 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
322
403
  brief._meta = {
323
404
  generated_at: new Date().toISOString(),
324
405
  mode: meta.mode,
406
+ mode_source: meta.modeSource,
325
407
  input_path: meta.inputPath || null,
326
408
  input_format: meta.inputFormat || null,
327
409
  input_source: meta.inputSource || null,
@@ -339,25 +421,127 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
339
421
  brief.template_residue_policy = normalizeResiduePolicy(brief.template_residue_policy);
340
422
  brief.qa_policy = normalizeQaPolicy(brief.qa_policy);
341
423
  brief.confidence = objectOrEmpty(brief.confidence);
424
+ brief._meta.field_sources = summaryFieldSources(brief, meta);
342
425
 
343
426
  return brief;
344
427
  }
345
428
 
429
+ // The concrete summary field paths of a normalized brief: one
430
+ // design_authority.<page>.source per page the brief lists.
431
+ function summaryFieldPaths(brief) {
432
+ return SUMMARY_FIELDS.flatMap((path) => (path === "design_authority.<page>.source"
433
+ ? Object.keys(objectOrEmpty(brief.design_authority)).map((page) => `design_authority.${page}.source`)
434
+ : [path]));
435
+ }
436
+
437
+ // The value at a dotted path, reading the segment after `design_authority`
438
+ // as one page id (a page id may itself contain a dot).
439
+ function valueAtPath(value, path) {
440
+ const parts = path.startsWith("design_authority.") && path.endsWith(".source")
441
+ ? ["design_authority", path.slice("design_authority.".length, -".source".length), "source"]
442
+ : path.split(".");
443
+ let node = value;
444
+ for (const part of parts) {
445
+ if (!isObject(node) || !Object.hasOwn(node, part)) return undefined;
446
+ node = node[part];
447
+ }
448
+ return node;
449
+ }
450
+
451
+ function valueFingerprint(value) {
452
+ return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
453
+ }
454
+
455
+ // Whether `path` is one of the answer_fields of a question still open in the
456
+ // previous normalized brief (design_authority.<page_id>.source matches any page).
457
+ function answersOpenQuestion(path, previous) {
458
+ const questions = Array.isArray(previous?.questions) ? previous.questions : [];
459
+ return questions.some((question) => {
460
+ const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
461
+ return (entry?.answer_fields || []).some((field) => field === path
462
+ || (field === "design_authority.<page_id>.source" && path.startsWith("design_authority.") && path.endsWith(".source")));
463
+ });
464
+ }
465
+
466
+ // _meta.field_sources: {kind, value_fingerprint} per summary field. A
467
+ // generated draft stamps every non-null value `default` (no draft value is
468
+ // copied verbatim from a structured source field). From a file: a value absent
469
+ // or null in the file is `default` when normalization filled one; a value the
470
+ // file sets is `stated` when it answers an open question of the previous
471
+ // brief or differs from the previous value (or there is no previous brief),
472
+ // and otherwise keeps the previous entry. A null value gets no entry.
473
+ function summaryFieldSources(brief, meta) {
474
+ const previous = isObject(meta.previousNormalizedBrief) ? meta.previousNormalizedBrief : null;
475
+ const previousSources = isObject(previous?._meta?.field_sources) ? previous._meta.field_sources : {};
476
+ const fromFile = isObject(meta.fileValue);
477
+ const sources = {};
478
+ for (const path of summaryFieldPaths(brief)) {
479
+ const value = valueAtPath(brief, path);
480
+ if (value === undefined || value === null) continue;
481
+ const entry = (kind) => ({ kind, value_fingerprint: valueFingerprint(value) });
482
+ const inFile = fromFile ? valueAtPath(meta.fileValue, path) : undefined;
483
+ if (!fromFile || inFile === undefined || inFile === null) {
484
+ sources[path] = entry("default");
485
+ } else if (answersOpenQuestion(path, previous) || !previous || canonicalJson(valueAtPath(previous, path) ?? null) !== canonicalJson(value)) {
486
+ sources[path] = entry("stated");
487
+ } else if (Object.hasOwn(previousSources, path) && fieldSourceEntryMatches(previousSources[path], value)) {
488
+ sources[path] = { kind: previousSources[path].kind, value_fingerprint: previousSources[path].value_fingerprint };
489
+ }
490
+ }
491
+ return sources;
492
+ }
493
+
494
+ const FIELD_SOURCE_KINDS = Object.freeze(["stated", "source", "default"]);
495
+
496
+ /**
497
+ * Whether a _meta.field_sources entry can be read for `value`: exactly
498
+ * {kind, value_fingerprint}, a known kind, and the fingerprint of `value`.
499
+ * Any other entry is ignored.
500
+ */
501
+ export function fieldSourceEntryMatches(entry, value) {
502
+ return isObject(entry)
503
+ && Object.keys(entry).length === 2
504
+ && FIELD_SOURCE_KINDS.includes(entry.kind)
505
+ && entry.value_fingerprint === valueFingerprint(value);
506
+ }
507
+
508
+ // The brief's material content, from the normalized artifact: `qa_policy`
509
+ // (without its fixed enforcement note) and `presentation`, every other
510
+ // top-level key that is not normalization output or the declared mode,
511
+ // including keys the operator added.
512
+ export function briefMaterialProjection(normalized) {
513
+ const brief = isObject(normalized) ? normalized : {};
514
+ // Entry-wise copies, so an operator key named __proto__ stays data.
515
+ const presentation = Object.fromEntries(Object.entries(brief).filter(([key]) => !NON_MATERIAL_BRIEF_KEYS.has(key) && key !== "qa_policy"));
516
+ const qaPolicy = Object.fromEntries(Object.entries(objectOrEmpty(brief.qa_policy)).filter(([key]) => key !== "enforcement"));
517
+ return { presentation, qa_policy: qaPolicy };
518
+ }
519
+
520
+ // Each partition as "sha256:<hex>" of its canonical JSON: key order,
521
+ // whitespace, an explicit default and _meta never change it.
522
+ export function briefMaterialFingerprint(normalized) {
523
+ const projection = briefMaterialProjection(normalized);
524
+ return { presentation: valueFingerprint(projection.presentation), qa_policy: valueFingerprint(projection.qa_policy) };
525
+ }
526
+
346
527
  function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
347
528
  const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
348
- const designAuthority = {};
349
- for (const page of activePages || []) {
529
+ // Entry-wise, so a page id named __proto__ stays a page.
530
+ const designAuthority = Object.fromEntries((activePages || []).map((page) => {
350
531
  const mapped = pageMap.get(page.id);
351
- designAuthority[page.id] = {
532
+ return [page.id, {
352
533
  source: mapped?.path ? "provided_design_export" : "template",
353
534
  reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
354
- };
355
- }
535
+ }];
536
+ }));
356
537
 
357
538
  const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
358
539
  const paymentMethods = collectSpecPaymentMethods(spec);
359
540
  const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
360
541
  const hasOrderBump = hasOrderBumpSignals(spec);
542
+ // With no CampaignSpec surface to fill them, the template's promo
543
+ // placeholders are removed: "none" for both.
544
+ const fillsTemplatePromo = templatePromoSurfaces({ spec, activePages }).length > 0;
361
545
 
362
546
  return {
363
547
  schema_version: BUILD_BRIEF_SCHEMA,
@@ -394,8 +578,8 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
394
578
  },
395
579
  },
396
580
  promo_urgency: {
397
- header_claim_source: hasPromoSignals(spec) ? "campaign_offers" : "none",
398
- timer_label: hasPromoSignals(spec) ? null : "Limited-time offer",
581
+ header_claim_source: fillsTemplatePromo ? "campaign_offers" : "none",
582
+ timer_label: fillsTemplatePromo ? null : "none",
399
583
  show_promo_code_in_timer: false,
400
584
  exit_pop: {
401
585
  enabled: hasExitPop,
@@ -427,7 +611,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
427
611
  brand: "low",
428
612
  media: variantSignals.length === 1 ? "medium" : "low",
429
613
  offer_presentation: "low",
430
- promo_urgency: hasPromoSignals(spec) ? "low" : "medium",
614
+ promo_urgency: fillsTemplatePromo ? "low" : "medium",
431
615
  commerce_surfaces: paymentMethods.length ? "medium" : "low",
432
616
  template_family: templateFamily || null,
433
617
  },
@@ -466,10 +650,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
466
650
  options: ["discounted unit price", "savings-led", "full accounting"],
467
651
  });
468
652
  }
469
- if (hasPromoSignals(spec) && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
653
+ const promoSurfaces = templatePromoSurfaces({ spec, activePages });
654
+ if (promoSurfaces.length && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
470
655
  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"],
656
+ 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").`,
657
+ options: ["fill from the campaign's promo codes and offers", "remove the template's promo placeholders"],
473
658
  });
474
659
  }
475
660
  if (!normalizePaymentList(brief.commerce_surfaces?.payment_methods_allowed).length) {
@@ -638,16 +823,30 @@ export function collectSpecPaymentMethods(spec) {
638
823
  return [...methods].sort();
639
824
  }
640
825
 
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;
826
+ // The CampaignSpec surfaces that fill a starter template's own promo
827
+ // placeholders: the template's promo banner and countdown timer read
828
+ // funnels[].promo_codes, and its exit-pop offer reads a page's exit_intent or
829
+ // promo_code_input. The campaign's offer catalog and discount pricing are not
830
+ // among them (bundle_pricing_presentation covers those), and neither is any
831
+ // promo, proof or urgency copy of the source design: that is the merchant's
832
+ // content, built as designed.
833
+ function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
834
+ const surfaces = [];
835
+ const funnels = Array.isArray(spec?.funnels) ? spec.funnels : [];
836
+ if (funnels.some((funnel) => Array.isArray(funnel?.promo_codes) && funnel.promo_codes.length > 0)) {
837
+ surfaces.push("promo codes (funnels[].promo_codes)");
838
+ }
839
+ 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;
843
+ if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
844
+ surfaces.push("an exit-intent offer (exit_intent)");
845
+ }
846
+ if (pages.some((page) => checkoutSurface(page, "promo_code_input"))) {
847
+ surfaces.push("a promo-code input (promo_code_input)");
848
+ }
849
+ return surfaces;
651
850
  }
652
851
 
653
852
  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