@nextcommerce/campaigns-os 1.52.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. package/src/tooling-setup.mjs +1 -1
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
 
3
+ import { contrastToolkit } from "./contrast.mjs";
3
4
  import { shellToken } from "./shell-token.mjs";
4
5
  import {
5
6
  existsSync,
@@ -677,18 +678,15 @@ function mixColor(value, target, amount) {
677
678
  });
678
679
  }
679
680
 
680
- function relativeLuminance({ r, g, b }) {
681
- const channel = (value) => {
682
- const c = value / 255;
683
- return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
684
- };
685
- return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
686
- }
681
+ // Luminance and ratio are the shared WCAG 2.x measurement Polish and QA use.
682
+ // Ratios are compared unrounded; only recorded values are rounded, for
683
+ // display.
684
+ const contrast = contrastToolkit();
685
+ const { contrastRatio, displayRatio } = contrast;
687
686
 
688
- function contrastRatio(luminanceA, luminanceB) {
689
- const lighter = Math.max(luminanceA, luminanceB);
690
- const darker = Math.min(luminanceA, luminanceB);
691
- return (lighter + 0.05) / (darker + 0.05);
687
+ // The 8-bit channels colorToRgb reads, as the [0,1] sRGB the helper takes.
688
+ function relativeLuminance({ r, g, b }) {
689
+ return contrast.relativeLuminance([r / 255, g / 255, b / 255]);
692
690
  }
693
691
 
694
692
  // Pick the foreground color (dark vs light) most legible on `bgValue`. A light
@@ -708,9 +706,11 @@ function readableForeground(bgValue, choices = {}) {
708
706
  const darkContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(darkValue)));
709
707
  const lightContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(lightValue)));
710
708
  const useDark = darkContrast >= lightContrast;
709
+ const ratio = Math.max(darkContrast, lightContrast);
711
710
  return {
712
711
  value: useDark ? darkValue : lightValue,
713
- contrast: Math.round(Math.max(darkContrast, lightContrast) * 100) / 100,
712
+ ratio,
713
+ contrast: displayRatio(ratio),
714
714
  on: useDark ? "dark" : "light",
715
715
  };
716
716
  }
@@ -732,11 +732,12 @@ function declaredForeground(bgValue, declaredValue) {
732
732
  if (!bgRgb || !declared) return null;
733
733
  const bgLuminance = relativeLuminance(bgRgb);
734
734
  const declaredLuminance = relativeLuminance(colorToRgb(declared));
735
- const contrast = contrastRatio(bgLuminance, declaredLuminance);
736
- if (contrast < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
735
+ const ratio = contrastRatio(bgLuminance, declaredLuminance);
736
+ if (ratio < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
737
737
  return {
738
738
  value: declared,
739
- contrast: Math.round(contrast * 100) / 100,
739
+ ratio,
740
+ contrast: displayRatio(ratio),
740
741
  on: declaredLuminance < bgLuminance ? "dark" : "light",
741
742
  };
742
743
  }
@@ -768,7 +769,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
768
769
  const declared = backgroundTarget === CTA_BACKGROUND_TARGET ? declaredForeground(backgroundValue, ctaForeground) : null;
769
770
  const readable = declared || readableForeground(backgroundValue, choices);
770
771
  if (!readable) continue;
771
- if (minContrast && readable.contrast < minContrast) {
772
+ if (minContrast && readable.ratio < minContrast) {
772
773
  warnings.push(issue(
773
774
  "theme.foreground.low_contrast",
774
775
  declared
@@ -779,7 +780,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
779
780
  }
780
781
  // Scale confidence by the achieved contrast so a strong derivation reads as
781
782
  // trustworthy as a direct mapping (AAA >= 7:1 high, AA >= 4.5:1 medium).
782
- const confidence = readable.contrast >= 7 ? "high" : readable.contrast >= 4.5 ? "medium" : "low";
783
+ const confidence = readable.ratio >= 7 ? "high" : readable.ratio >= 4.5 ? "medium" : "low";
783
784
  mappings.push({
784
785
  source: backgroundTarget,
785
786
  target: foregroundTarget,
@@ -893,9 +894,9 @@ function darkestDeclaredBodyText(rootTokens, bodyBackground) {
893
894
  if (!value) continue;
894
895
  const luminance = relativeLuminance(colorToRgb(value));
895
896
  if (luminance >= bgLuminance) continue;
896
- const contrast = contrastRatio(bgLuminance, luminance);
897
- if (contrast < BODY_TEXT_MIN_CONTRAST) continue;
898
- if (!best || luminance < best.luminance) best = { name, value, luminance, contrast: Math.round(contrast * 100) / 100 };
897
+ const ratio = contrastRatio(bgLuminance, luminance);
898
+ if (ratio < BODY_TEXT_MIN_CONTRAST) continue;
899
+ if (!best || luminance < best.luminance) best = { name, value, luminance, contrast: displayRatio(ratio) };
899
900
  }
900
901
  return best;
901
902
  }
@@ -0,0 +1,275 @@
1
+ // The campaign intent summary: a short, deterministic orientation rendered
2
+ // from the normalized Campaign Build Brief for the stage prompts and every
3
+ // `next` result. It is output only: nothing persists it and no routing,
4
+ // gate, price or commerce decision reads it.
5
+ //
6
+ // Every displayed brief value carries the marker its `_meta.field_sources`
7
+ // entry proves, and nothing stronger: an entry counts only when build-brief's
8
+ // fieldSourceEntryMatches accepts it (exactly {kind, value_fingerprint}, a
9
+ // recorded kind, and the fingerprint of the current value). Values are
10
+ // rendered as quoted data and never interpreted. A collection the summary
11
+ // cannot read as its expected shape is said to be not recorded, and a blank
12
+ // value reads not stated; neither is ever presented as complete.
13
+ //
14
+ // Pure: equal inputs give byte-identical output. No file, clock or
15
+ // environment access; a failure is handed to the caller's onError.
16
+ import { fieldSourceEntryMatches, SUMMARY_FIELDS } from "./build-brief.mjs";
17
+ import { canonicalJson } from "./polish-capture.mjs";
18
+
19
+ export const SUMMARY_WORD_LIMIT = 150;
20
+
21
+ const NO_BRIEF_TEXT = "No Campaign Build Brief is recorded for this campaign. Ask the operator for purpose and audience before business-sensitive choices; never assume them.";
22
+ const NOT_SUMMARISED_TEXT = "The Campaign Build Brief could not be summarised.";
23
+ const COMMERCE_TEXT = "products, prices and offers come from CampaignSpec/API values; cart, checkout and post-purchase behaviour stay with the SDK.";
24
+
25
+ // The provenance-stamped fields, in the order build-brief exports them. The
26
+ // page-keyed field names a concrete page id in place of "<page>", joined by
27
+ // plain concatenation so no character of the id is interpreted.
28
+ const [AUDIENCE, CONVERSION_GOAL, TONE, PALETTE_SOURCE, PRIMARY_ACCENT, CTA_STYLE, AVOID, PAGE_SOURCE, BLOCK_PLACEHOLDERS] = SUMMARY_FIELDS;
29
+ const [PAGE_SOURCE_PREFIX, PAGE_SOURCE_SUFFIX, ...PAGE_SOURCE_REST] = PAGE_SOURCE.split("<page>");
30
+ if (PAGE_SOURCE_SUFFIX === undefined || PAGE_SOURCE_REST.length) {
31
+ throw new Error(`SUMMARY_FIELDS must name the page-keyed design authority field with exactly one "<page>" placeholder; got ${JSON.stringify(PAGE_SOURCE)}.`);
32
+ }
33
+ const pageSourceField = (pageId) => PAGE_SOURCE_PREFIX + pageId + PAGE_SOURCE_SUFFIX;
34
+
35
+ const NOT_RECORDED = "not_recorded";
36
+ // An active page whose id is missing or blank keeps its place in the journey.
37
+ const PAGE_ID_NOT_RECORDED = "(page id not recorded)";
38
+ const MARKERS = Object.freeze({ stated: "[stated]", source: "[from source]", default: "[default]", [NOT_RECORDED]: "[source not recorded]" });
39
+ // Weakest first: a value whose origin was not recorded claims the least of
40
+ // the marks; a part that reads not stated (null) claims less than any mark.
41
+ const STRENGTH = Object.freeze([NOT_RECORDED, "default", "source", "stated"]);
42
+
43
+ // design_authority.<page>.source values the generator writes; any other
44
+ // non-null value is shown verbatim in its own group.
45
+ const SUPPLIED_DESIGN = "provided_design_export";
46
+ const TEMPLATE = "template";
47
+
48
+ const VALUE_WORDS = 12;
49
+ const FINAL_CUT_WORDS = 8;
50
+ const LIST_CUT_LINES = Object.freeze([7, 4, 3]);
51
+
52
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
53
+ const own = (object, key) => (isPlainObject(object) && Object.hasOwn(object, key) ? object[key] : undefined);
54
+ const valueAt = (brief, path) => path.split(".").reduce((value, key) => own(value, key), brief);
55
+ const singleLine = (text) => text.replace(/\r\n|[\n\r\u2028\u2029]/g, " ");
56
+ // Whitespace only, counting NEL (U+0085), which String.prototype.trim keeps.
57
+ const isBlank = (value) => typeof value === "string" && !value.replace(/\u0085/g, "").trim();
58
+ const notStated = (value) => value == null || isBlank(value);
59
+
60
+ // C0, DEL and C1 control characters (ESC and NEL among them) written as \t or
61
+ // \uXXXX escapes, so none reaches a prompt or terminal raw.
62
+ const escapeControls = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => (char === "\t" ? "\\t" : `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`));
63
+ // Inside quotes, `\` and `"` are escaped first, so no value can close them.
64
+ const escapeQuoted = (text) => escapeControls(text.replace(/[\\"]/g, "\\$&"));
65
+
66
+ // stated | source | default | not_recorded, or null for an absent value.
67
+ function provenanceOf(brief, field, value) {
68
+ if (value == null) return null;
69
+ const entry = own(own(own(brief, "_meta"), "field_sources"), field);
70
+ if (!fieldSourceEntryMatches(entry, value)) return NOT_RECORDED;
71
+ return entry.kind;
72
+ }
73
+
74
+ // The weakest mark among the parts a line or group shows; a part that reads
75
+ // not stated makes it null, so it never claims a mark that part lacks.
76
+ function weakest(provenances) {
77
+ if (!provenances.length || provenances.some((provenance) => provenance == null)) return null;
78
+ return STRENGTH[Math.min(...provenances.map((provenance) => STRENGTH.indexOf(provenance)))];
79
+ }
80
+
81
+ // A brief value as quoted data: line breaks collapse to spaces, the value is
82
+ // trimmed and cut to `maxWords` words with "…", and escaped.
83
+ function quoted(value, maxWords, cut) {
84
+ const text = singleLine(typeof value === "string" ? value : canonicalJson(value)).trim();
85
+ const words = text.split(/\s+/).filter(Boolean);
86
+ if (words.length > maxWords) cut.happened = true;
87
+ const kept = words.length > maxWords ? `${words.slice(0, maxWords).join(" ")}…` : text;
88
+ return `"${escapeQuoted(kept)}"`;
89
+ }
90
+
91
+ // A list cut to its first k items plus "+N more", where N counts what the
92
+ // dropped items stand for (one each by default).
93
+ function listItems(items, k, render, cut, weight = () => 1) {
94
+ if (k == null || items.length <= k) return items.map(render);
95
+ cut.happened = true;
96
+ const more = items.slice(k).reduce((sum, item) => sum + weight(item), 0);
97
+ return [...items.slice(0, k).map(render), `+${more} more`];
98
+ }
99
+ // A page id as displayed: unquoted, with line breaks collapsed to spaces and
100
+ // control characters escaped.
101
+ const pageIdText = (id) => (id == null ? PAGE_ID_NOT_RECORDED : escapeControls(singleLine(id)));
102
+
103
+ function marked(value, provenance, maxWords, cut) {
104
+ return `${quoted(value, maxWords, cut)} ${MARKERS[provenance]}`;
105
+ }
106
+
107
+ // A blank string reads not stated, like an absent value.
108
+ function field(brief, path) {
109
+ const raw = valueAt(brief, path);
110
+ const value = notStated(raw) ? null : raw;
111
+ return { value, provenance: provenanceOf(brief, path, value) };
112
+ }
113
+
114
+ // Line 1 and the line-5 palette clause have page-keyed generator values, so
115
+ // they show a value only when the brief file states it.
116
+ function statedOnly({ value, provenance }) {
117
+ return provenance === "stated" ? { value, provenance } : { value: null, provenance: null };
118
+ }
119
+
120
+ // Line 4: each active page in exactly one group, in the fixed group order;
121
+ // each group carries the weakest marker among its pages. Authority and stamp
122
+ // are read with the page id exactly as recorded; a page without one, or with
123
+ // a blank source, reads not stated.
124
+ function authorityGroups(brief, pageIds) {
125
+ const authority = own(brief, "design_authority");
126
+ const supplied = { kind: "supplied", source: SUPPLIED_DESIGN, pages: [], provenances: [] };
127
+ const template = { kind: "template", source: TEMPLATE, pages: [], provenances: [] };
128
+ const others = new Map();
129
+ const unstated = { kind: "not_stated", source: null, pages: [], provenances: [] };
130
+ for (const pageId of pageIds) {
131
+ const recorded = pageId == null ? null : own(own(authority, pageId), "source");
132
+ const source = notStated(recorded) ? null : recorded;
133
+ const provenance = pageId == null ? null : provenanceOf(brief, pageSourceField(pageId), source);
134
+ let group = unstated;
135
+ if (source === SUPPLIED_DESIGN) group = supplied;
136
+ else if (source === TEMPLATE) group = template;
137
+ else if (source != null) {
138
+ const key = canonicalJson(source);
139
+ if (!others.has(key)) others.set(key, { kind: "other", source, pages: [], provenances: [] });
140
+ group = others.get(key);
141
+ }
142
+ group.pages.push(pageId);
143
+ group.provenances.push(provenance);
144
+ }
145
+ return [supplied, template, ...others.values(), unstated]
146
+ .filter((group) => group.pages.length)
147
+ .map(({ provenances, ...group }) => ({ ...group, provenance: weakest(provenances) }));
148
+ }
149
+
150
+ function readModel(brief, activePageIds) {
151
+ // Page ids stay as recorded and are escaped only when rendered; a missing,
152
+ // non-string or blank id is null and keeps its place.
153
+ const pageIds = (Array.isArray(activePageIds) ? activePageIds : []).map((id) => (typeof id === "string" && !isBlank(id) ? id : null));
154
+ const avoid = field(brief, AVOID);
155
+ const avoidItems = avoid.value == null ? [] : Array.isArray(avoid.value) ? avoid.value : [avoid.value];
156
+ // Questions not recorded as a list are null, never read as none.
157
+ const questions = own(brief, "questions");
158
+ return {
159
+ purpose: statedOnly(field(brief, CONVERSION_GOAL)),
160
+ audience: field(brief, AUDIENCE),
161
+ journey: pageIds,
162
+ authority: authorityGroups(brief, pageIds),
163
+ palette: statedOnly(field(brief, PALETTE_SOURCE)),
164
+ buttons: field(brief, CTA_STYLE),
165
+ accent: field(brief, PRIMARY_ACCENT),
166
+ tone: field(brief, TONE),
167
+ avoid: { ...avoid, items: avoidItems, itemNotStated: avoidItems.some(notStated) },
168
+ placeholders: field(brief, BLOCK_PLACEHOLDERS),
169
+ questions: Array.isArray(questions) ? questions.map((question) => (typeof question?.id === "string" && !isBlank(question.id) ? singleLine(question.id.trim()) : "unnamed question")) : null,
170
+ };
171
+ }
172
+
173
+ // The nine lines, with the line-3/4/7 lists cut to `cuts[line]` items and the
174
+ // line-1/2/5/6 values cut to `valueWords` words. Line 4 cut to k keeps its
175
+ // first k groups, each with its first k page ids; "+N more" counts pages.
176
+ function renderLines(model, cuts, valueWords) {
177
+ const cut = { happened: false };
178
+ const value = (item, fallback) => (item.value == null ? fallback : marked(item.value, item.provenance, valueWords, cut));
179
+ const group = (entry) => {
180
+ const ids = listItems(entry.pages, cuts[4], pageIdText, cut).join(", ");
181
+ if (entry.kind === "supplied") return `${ids} follow the supplied design ${MARKERS[entry.provenance]}`;
182
+ if (entry.kind === "template") return `${ids} follow the template ${MARKERS[entry.provenance]}`;
183
+ if (entry.kind === "other") return `${ids} use ${marked(entry.source, entry.provenance, VALUE_WORDS, cut)}`;
184
+ return `${ids} not stated`;
185
+ };
186
+ const avoid = model.avoid.value == null
187
+ ? "avoid not stated"
188
+ : `avoid ${model.avoid.items.length ? listItems(model.avoid.items, cuts[7], (item) => (notStated(item) ? "not stated" : quoted(item, VALUE_WORDS, cut)), cut).join("; ") : "none"} ${MARKERS[model.avoid.provenance]}`;
189
+ const placeholders = model.placeholders.value == null
190
+ ? "not stated"
191
+ : typeof model.placeholders.value === "boolean"
192
+ ? `${model.placeholders.value ? "yes" : "no"} ${MARKERS[model.placeholders.provenance]}`
193
+ : marked(model.placeholders.value, model.placeholders.provenance, VALUE_WORDS, cut);
194
+ const lines = [
195
+ `Purpose: ${value(model.purpose, "not stated")}.`,
196
+ `Audience: ${value(model.audience, "not stated")}.`,
197
+ model.journey.length ? `Journey: ${listItems(model.journey, cuts[3], pageIdText, cut).join(" → ")} (CampaignSpec).` : "Journey: not stated.",
198
+ model.authority.length ? `Visual authority: ${listItems(model.authority, cuts[4], group, cut, (entry) => entry.pages.length).join("; ")}.` : "Visual authority: not stated.",
199
+ `Palette and buttons: ${model.palette.value == null ? "palette source not stated" : `palette from ${marked(model.palette.value, "stated", valueWords, cut)}`}; button style ${value(model.buttons, "not stated")}; accent ${value(model.accent, "not stated")}.`,
200
+ `Tone: ${value(model.tone, "not stated")}.`,
201
+ `Preserve: ${avoid}; template placeholders removed: ${placeholders}.`,
202
+ `Commerce: ${COMMERCE_TEXT}`,
203
+ `Open brief questions: ${model.questions == null ? "not recorded" : model.questions.length ? model.questions.map(escapeControls).join(", ") : "none"}.`,
204
+ ];
205
+ const text = lines.join("\n");
206
+ return { text, wordCount: wordCount(text), cut: cut.happened };
207
+ }
208
+
209
+ function wordCount(text) {
210
+ return text.split(/\s+/).filter(Boolean).length;
211
+ }
212
+
213
+ // Within the word bound: cut one list at a time, in order, to its first k
214
+ // items (k from 5 down to 1) until the bound holds: line 7, then line 4, then
215
+ // line 3. Only if it still fails, cut the line-1/2/5/6 values to 8 words.
216
+ // Lines 8 and 9 are never cut, and nothing is padded.
217
+ function renderWithinBound(model) {
218
+ const cuts = {};
219
+ let rendered = renderLines(model, cuts, VALUE_WORDS);
220
+ for (const line of LIST_CUT_LINES) {
221
+ for (let k = 5; k >= 1 && rendered.wordCount > SUMMARY_WORD_LIMIT; k -= 1) {
222
+ cuts[line] = k;
223
+ rendered = renderLines(model, cuts, VALUE_WORDS);
224
+ }
225
+ }
226
+ if (rendered.wordCount > SUMMARY_WORD_LIMIT) rendered = renderLines(model, cuts, FINAL_CUT_WORDS);
227
+ return rendered;
228
+ }
229
+
230
+ function isPartial(model, cut) {
231
+ const values = [model.purpose, model.audience, model.palette, model.buttons, model.accent, model.tone, model.avoid, model.placeholders];
232
+ const missing = values.some((item) => item.value == null) || model.avoid.itemNotStated || !model.journey.length || model.journey.includes(null) || !model.authority.length;
233
+ const unrecorded = [...values, ...model.authority].some((item) => item.provenance === NOT_RECORDED);
234
+ const ungrouped = model.authority.some((group) => group.kind === "other" || group.kind === "not_stated");
235
+ return missing || unrecorded || ungrouped || model.questions == null || model.questions.length > 0 || cut;
236
+ }
237
+
238
+ const unavailable = (text) => ({ status: "unavailable", text, lines: [], word_count: wordCount(text), open_questions: [] });
239
+
240
+ // Never throws. No brief reads the fixed no-brief text. A brief the summary
241
+ // cannot be computed from (for example a value nested too deeply to
242
+ // fingerprint) reads unavailable with its own text, and the error is passed
243
+ // to `onError` so the caller can name it.
244
+ export function summarizeCampaignBrief({ brief, activePageIds = [], onError } = {}) {
245
+ if (!isPlainObject(brief)) return unavailable(NO_BRIEF_TEXT);
246
+ try {
247
+ return summarize(brief, activePageIds);
248
+ } catch (error) {
249
+ onError?.(error);
250
+ return unavailable(NOT_SUMMARISED_TEXT);
251
+ }
252
+ }
253
+
254
+ function summarize(brief, activePageIds) {
255
+ const model = readModel(brief, activePageIds);
256
+ const rendered = renderWithinBound(model);
257
+ const line = (label, value, provenance) => ({ label, value, provenance });
258
+ return {
259
+ status: isPartial(model, rendered.cut) ? "partial" : "available",
260
+ text: rendered.text,
261
+ lines: [
262
+ line("Purpose", model.purpose.value, model.purpose.provenance),
263
+ line("Audience", model.audience.value, model.audience.provenance),
264
+ line("Journey", model.journey, null),
265
+ line("Visual authority", model.authority.map(({ pages, source, provenance }) => ({ page_ids: pages, source, provenance })), weakest(model.authority.map((group) => group.provenance))),
266
+ line("Palette and buttons", { commerce_palette_source: model.palette.value, cta_style: model.buttons.value, primary_accent: model.accent.value }, weakest([model.palette.provenance, model.buttons.provenance, model.accent.provenance])),
267
+ line("Tone", model.tone.value, model.tone.provenance),
268
+ line("Preserve", { avoid: model.avoid.value, block_placeholders: model.placeholders.value }, weakest([model.avoid.itemNotStated ? null : model.avoid.provenance, model.placeholders.provenance])),
269
+ line("Commerce", COMMERCE_TEXT, null),
270
+ line("Open brief questions", model.questions, null),
271
+ ],
272
+ word_count: rendered.wordCount,
273
+ open_questions: model.questions ?? [],
274
+ };
275
+ }
@@ -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",
@@ -169,6 +186,32 @@ export function loadCampaignBuildBriefFile(path) {
169
186
  return { path: resolvedPath, format, value: parsed };
170
187
  }
171
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.
172
215
  export function createCampaignBuildBriefArtifact({
173
216
  inputPath = null,
174
217
  inputSource = null,
@@ -178,19 +221,24 @@ export function createCampaignBuildBriefArtifact({
178
221
  templateFamily = null,
179
222
  sourceAssetCrawl = null,
180
223
  commerceZoneFindings = [],
224
+ boundReportMode = null,
225
+ previousNormalizedBrief = null,
181
226
  } = {}) {
182
227
  const loaded = inputPath ? loadCampaignBuildBriefFile(inputPath) : null;
183
- const mode = loaded ? "prepared" : "guided_draft";
228
+ const errors = [];
229
+ const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
184
230
  const baseBrief = loaded
185
231
  ? cloneJsonOrEmpty(loaded.value)
186
232
  : draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
187
233
 
188
- const errors = [];
189
234
  const normalized = normalizeCampaignBuildBrief(baseBrief, {
190
235
  mode,
236
+ modeSource,
191
237
  inputPath: loaded?.path || null,
192
238
  inputFormat: loaded?.format || "generated",
193
239
  inputSource,
240
+ fileValue: loaded ? loaded.value : null,
241
+ previousNormalizedBrief,
194
242
  spec,
195
243
  activePages,
196
244
  pageMappings,
@@ -222,9 +270,11 @@ export function createCampaignBuildBriefArtifact({
222
270
  normalized.questions = evaluation.questions;
223
271
  normalized.gates = gates;
224
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).
225
275
  const blocking = mode === "prepared"
226
276
  ? blockerGates
227
- : [];
277
+ : gates.slice(0, errors.length);
228
278
 
229
279
  return {
230
280
  mode,
@@ -278,24 +328,26 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null, normali
278
328
  if (questions.length) {
279
329
  errors.push({
280
330
  code: "build_brief.questions_unanswered",
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.`,
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>.`,
282
332
  });
283
333
  }
284
334
  } else {
285
335
  if (questions.length) {
286
336
  // 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.
337
+ // brief file saved with record brief: the guided draft is regenerated
338
+ // on every intake, and a brief file replaces it whole.
289
339
  warnings.push({
290
340
  code: "build_brief.guided_questions",
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 `
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> `
293
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. "
294
- + "Once a stage has recorded evidence, that re-run needs --force, which clears the evidence.",
344
+ + "Saving keeps every stage's recorded evidence unless the brief's content changes.",
295
345
  });
296
346
  }
347
+ // Input errors block in both modes; evaluation gates stay warnings here.
297
348
  for (const gate of blockerGates) {
298
- 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." });
299
351
  }
300
352
  }
301
353
 
@@ -351,6 +403,7 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
351
403
  brief._meta = {
352
404
  generated_at: new Date().toISOString(),
353
405
  mode: meta.mode,
406
+ mode_source: meta.modeSource,
354
407
  input_path: meta.inputPath || null,
355
408
  input_format: meta.inputFormat || null,
356
409
  input_source: meta.inputSource || null,
@@ -368,20 +421,119 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
368
421
  brief.template_residue_policy = normalizeResiduePolicy(brief.template_residue_policy);
369
422
  brief.qa_policy = normalizeQaPolicy(brief.qa_policy);
370
423
  brief.confidence = objectOrEmpty(brief.confidence);
424
+ brief._meta.field_sources = summaryFieldSources(brief, meta);
371
425
 
372
426
  return brief;
373
427
  }
374
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
+
375
527
  function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
376
528
  const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
377
- const designAuthority = {};
378
- 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) => {
379
531
  const mapped = pageMap.get(page.id);
380
- designAuthority[page.id] = {
532
+ return [page.id, {
381
533
  source: mapped?.path ? "provided_design_export" : "template",
382
534
  reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
383
- };
384
- }
535
+ }];
536
+ }));
385
537
 
386
538
  const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
387
539
  const paymentMethods = collectSpecPaymentMethods(spec);