@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
@@ -1,6 +1,6 @@
1
- // `campaigns-os record <setup|build|polish>`: record a stage's completion on
2
- // the Build Context and Assembly Report through one validated command instead
3
- // of hand-edited JSON.
1
+ // `campaigns-os record <setup|build|polish|theme|deploy>`: record a stage's
2
+ // completion (or, for theme, an applied brand layer) on the Build Context and
3
+ // Assembly Report through one validated command instead of hand-edited JSON.
4
4
  //
5
5
  // Every value a record stamps is read from the doctor result the `next` ladder
6
6
  // itself reads (doctorPacket over the same packet and sidecars), so a record
@@ -14,16 +14,25 @@
14
14
  //
15
15
  // The Build Context holds setup state only (`scaffold`); build and polish
16
16
  // completion live on the Assembly Report alone, so `record build` and `record
17
- // polish` validate the context they read but write only the report.
17
+ // polish` validate the context they read but write only the report. `record
18
+ // theme` writes `report.theme` only after reading, in each built commerce
19
+ // page, that a brand layer stylesheet is linked after next-core.css. `record
20
+ // deploy` records a local preview: the packet's deploy.preview_url and
21
+ // stages.deploy, after every built page answers on the served loopback URL.
18
22
  import { existsSync, readFileSync } from "node:fs";
19
- import { join, resolve } from "node:path";
23
+ import { dirname, join, relative, resolve } from "node:path";
20
24
  import { fileURLToPath } from "node:url";
21
25
 
22
26
  import Ajv2020 from "ajv/dist/2020.js";
23
27
 
24
- import { computeBuildFingerprint } from "./built-site-scope.mjs";
28
+ import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
+ import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
25
30
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
26
31
  import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
+ import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
33
+ import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
34
+ import { isLoopbackHostname } from "./remit.mjs";
35
+ import { campaignRouteRoot } from "./route-identity.mjs";
27
36
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
28
37
  import { doctorPacket } from "./doctor/inspect.mjs";
29
38
  import { validateAssemblyReport } from "./doctor/checks.mjs";
@@ -39,13 +48,21 @@ import {
39
48
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
40
49
  import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
41
50
  import { withTargetLockSync } from "./target-lock.mjs";
51
+ import { commerceScopeFromScope } from "./theme-gate.mjs";
42
52
 
43
- export const RECORD_STAGES = Object.freeze(["setup", "build", "polish"]);
53
+ export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
44
54
 
45
- // Every flag `record` reads, plus the two any command accepts (run id and
46
- // lifecycle journal). Anything else is refused before a file is read.
47
- const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal"]);
55
+ // Every flag `record` reads, plus the three any command accepts (run id,
56
+ // lifecycle journal, and the deviation reason the deviation notice asks
57
+ // agents to declare). Anything else is refused before a file is read.
58
+ const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
48
59
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
60
+ const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
61
+ const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
62
+ // The page-kit environment the built output was rendered in, recorded on
63
+ // stages.assembly.evidence.build_environment (local proof mode builds in
64
+ // development; doctor and page-kit parity read it).
65
+ export const BUILD_ENVIRONMENTS = Object.freeze([LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT]);
49
66
 
50
67
  // The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
51
68
  // `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
@@ -64,6 +81,13 @@ function schemaValidator(file) {
64
81
  return validators.get(file);
65
82
  }
66
83
 
84
+ // The value at an Ajv instancePath (JSON Pointer) in the validated document.
85
+ function valueAt(document, pointer) {
86
+ return pointer.split("/").slice(1)
87
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
88
+ .reduce((node, key) => (node == null ? undefined : node[key]), document);
89
+ }
90
+
67
91
  // Ajv's instancePath (`/theme/repair_loop_defect`) as the dotted field name
68
92
  // the rest of the toolkit prints (`theme.repair_loop_defect`).
69
93
  function schemaProblems(file, value, label) {
@@ -73,7 +97,11 @@ function schemaProblems(file, value, label) {
73
97
  const problems = [];
74
98
  for (const error of validate.errors || []) {
75
99
  const field = error.instancePath.split("/").filter(Boolean).join(".") || "(root)";
76
- const line = `${label} ${field} ${error.message}`;
100
+ // Ajv's enum message names no values; the allowed list is the remedy.
101
+ const allowed = error.keyword === "enum" && Array.isArray(error.params?.allowedValues)
102
+ ? `: ${error.params.allowedValues.map((allowedValue) => JSON.stringify(allowedValue)).join(", ")} (got ${JSON.stringify(valueAt(value, error.instancePath))})`
103
+ : "";
104
+ const line = `${label} ${field} ${error.message}${allowed}`;
77
105
  if (seen.has(line)) continue;
78
106
  seen.add(line);
79
107
  problems.push(line);
@@ -86,6 +114,11 @@ function typeName(value) {
86
114
  return Array.isArray(value) ? "array" : typeof value;
87
115
  }
88
116
 
117
+ // visual_review keys only `polish capture` writes. `record polish --evidence`
118
+ // refuses them by name and carries the captured values forward.
119
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight"]);
120
+ export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
121
+
89
122
  function refuseRecord(stage, problems) {
90
123
  return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
91
124
  }
@@ -93,25 +126,31 @@ function refuseRecord(stage, problems) {
93
126
  export function parseRecordArgs(args) {
94
127
  const stage = args._[1];
95
128
  if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
96
- throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>.`);
129
+ throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, and record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>].`);
97
130
  }
98
- const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : [])]);
131
+ const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : [])]);
99
132
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
100
133
  if (unknown.length) {
101
134
  throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
102
135
  }
103
- for (const flag of ["context", "report", "run-id", "lifecycle-journal"]) {
136
+ for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason"]) {
104
137
  if (Object.hasOwn(args, flag)) requireArg(args, flag);
105
138
  }
106
139
  if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
107
140
  throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
108
141
  }
109
142
  if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
143
+ const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
144
+ if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
145
+ throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
146
+ }
110
147
  return {
111
148
  stage,
112
149
  packetPath: resolve(requireArg(args, "packet")),
113
150
  evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
151
+ baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
114
152
  dryRun: args["dry-run"] === true,
153
+ buildEnvironment,
115
154
  };
116
155
  }
117
156
 
@@ -170,8 +209,13 @@ export function readPolishEvidenceFile(path) {
170
209
  }
171
210
  if (evidence.visual_review !== undefined && !isObject(evidence.visual_review)) {
172
211
  problems.push(`evidence.visual_review must be an object with a screenshots array (got ${typeName(evidence.visual_review)}).`);
173
- } else if (isObject(evidence.visual_review) && Object.hasOwn(evidence.visual_review, "page_load")) {
174
- problems.push(`evidence.visual_review.page_load is written only by ${cmd("polish")} capture; remove it from the file (the captured value on the report is kept).`);
212
+ } else if (isObject(evidence.visual_review)) {
213
+ // Package-owned keys are refused by name and listed first, so the
214
+ // refusal leads with its code.
215
+ const owned = PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(evidence.visual_review, key));
216
+ if (owned.length) {
217
+ problems.unshift(`${PACKAGE_OWNED_KEY_REFUSAL}: ${owned.map((key) => `evidence.visual_review.${key}`).join(" and ")} ${owned.length === 1 ? "is" : "are"} written only by ${cmd("polish")} capture; remove ${owned.length === 1 ? "it" : "them"} from the file (the captured value on the report is kept).`);
218
+ }
175
219
  }
176
220
  }
177
221
  if (Object.hasOwn(input, "repair_loop_defect") && input.repair_loop_defect !== null && !isObject(input.repair_loop_defect)) {
@@ -224,10 +268,12 @@ function composeSetup(report, context, { now, recordedBy }) {
224
268
  return { report: nextReport, context: nextContext };
225
269
  }
226
270
 
227
- function composeBuild(report, { now, recordedBy, fingerprint }) {
271
+ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null }) {
228
272
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
273
+ const previousAssembly = stageObject(report, "assembly");
229
274
  const assembly = {
230
- ...withoutKeys(stageObject(report, "assembly"), ["source_package_material_fingerprint"]),
275
+ ...withoutKeys(previousAssembly, ["source_package_material_fingerprint"]),
276
+ ...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
231
277
  stage: "assembly",
232
278
  status: "completed",
233
279
  build_fingerprint: fingerprint,
@@ -264,7 +310,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
264
310
  ...input.evidence,
265
311
  visual_review: {
266
312
  ...input.evidence.visual_review,
267
- ...(Object.hasOwn(previousVisual, "page_load") ? { page_load: previousVisual.page_load } : {}),
313
+ ...Object.fromEntries(PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(previousVisual, key)).map((key) => [key, previousVisual[key]])),
268
314
  },
269
315
  }
270
316
  : previous.evidence;
@@ -294,6 +340,223 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
294
340
  return { report: nextReport, context: null };
295
341
  }
296
342
 
343
+ // The stylesheet every family's commerce pages load first; the brand layer
344
+ // must come after it so its --brand--* values win.
345
+ const CORE_STYLESHEET = "next-core.css";
346
+
347
+ function stylesheetHrefs(html) {
348
+ return [...html.matchAll(/<link\b[^>]*>/gi)]
349
+ .map((match) => match[0])
350
+ .filter((tag) => /\brel\s*=\s*["']?[^"'>]*\bstylesheet\b/i.test(tag))
351
+ .map((tag) => (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1])
352
+ .filter(Boolean);
353
+ }
354
+
355
+ const hrefName = (href) => href.split(/[?#]/)[0].split("/").pop();
356
+ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
357
+
358
+ // What `record theme` stands on, read from each built commerce page's
359
+ // stylesheet links in document order. A page that loads next-core.css renders
360
+ // family components, so it must load a brand layer (brand-theme.css or
361
+ // checkout-brand.css) after it, and that file must be in the built output. A
362
+ // page that loads neither renders the design's own markup and needs no brand
363
+ // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
364
+ // that breaks the rule is a refusal naming the page.
365
+ function brandLayerFacts(doctor) {
366
+ const derived = doctor.derived || {};
367
+ const commerce = commerceScopeFromScope(derived.scope);
368
+ if (!commerce.all.length) {
369
+ throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
370
+ }
371
+ const site = resolveBuiltSiteScope(derived.target_repo, { slug: derived.public_route_slug });
372
+ if (!site.ok) throw refuseRecord("theme", [site.error]);
373
+ const byRoute = new Map(site.pages.map((page) => [routeKey(page.route), page]));
374
+ const byId = new Map(site.pages.map((page) => [page.page_id, page]));
375
+ const problems = [];
376
+ const evidence = [];
377
+ const layers = [];
378
+ const styled = [];
379
+ const unstyled = [];
380
+ for (const page of commerce.built) {
381
+ const label = page.page_id || page.route || page.type;
382
+ const built = byRoute.get(routeKey(page.route)) || byId.get(page.page_id);
383
+ if (!built) {
384
+ problems.push(`${label}: no built page in ${relative(derived.target_repo, site.campaign_dir) || "."}; run the page-kit build, then ${cmd("record")} build.`);
385
+ continue;
386
+ }
387
+ const builtRel = relative(derived.target_repo, built.built_path);
388
+ const hrefs = stylesheetHrefs(readFileSync(built.built_path, "utf8"));
389
+ const core = hrefs.findIndex((href) => hrefName(href) === CORE_STYLESHEET);
390
+ const brand = hrefs.findIndex((href, index) => index > core && BRAND_LAYER_FILENAMES.has(hrefName(href)));
391
+ if (core < 0) {
392
+ if (hrefs.some((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)))) {
393
+ problems.push(`${label}: ${builtRel} links a brand layer but not ${CORE_STYLESHEET}. On a page built from the design's own markup, remove the brand layer; only if the page renders family components, load ${CORE_STYLESHEET} before it.`);
394
+ } else {
395
+ unstyled.push(label);
396
+ evidence.push(`${label}: ${builtRel} loads neither ${CORE_STYLESHEET} nor a brand layer (the design's own markup).`);
397
+ }
398
+ continue;
399
+ }
400
+ if (brand < 0) {
401
+ const early = hrefs.find((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)));
402
+ problems.push(early
403
+ ? `${label}: ${builtRel} links ${early} before ${CORE_STYLESHEET}; list it after ${CORE_STYLESHEET} in the page's frontmatter styles and rebuild.`
404
+ : `${label}: ${builtRel} links no brand layer (${[...BRAND_LAYER_FILENAMES].join(" or ")}) after ${CORE_STYLESHEET}.`);
405
+ continue;
406
+ }
407
+ const href = hrefs[brand].split(/[?#]/)[0];
408
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(href)) {
409
+ problems.push(`${label}: ${builtRel} loads its brand layer from another origin (${hrefs[brand]}); the brand layer ships with the campaign, so copy it into the campaign assets and link that copy.`);
410
+ continue;
411
+ }
412
+ const file = href.startsWith("/") ? join(site.site_root, href) : resolve(dirname(built.built_path), href);
413
+ if (!existsSync(file)) {
414
+ problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
415
+ continue;
416
+ }
417
+ layers.push(file);
418
+ styled.push(label);
419
+ evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
420
+ }
421
+ if (problems.length) throw refuseRecord("theme", problems);
422
+ if (!styled.length) {
423
+ const outOfScope = commerce.out_of_scope.map((page) => page.page_id || page.route || page.type);
424
+ throw refuseRecord("theme", [!commerce.built.length
425
+ ? `No commerce page is built in this scope (declared but not built: ${outOfScope.join(", ")}); build them, run ${cmd("record")} build, then record theme.`
426
+ : `No built commerce page loads ${CORE_STYLESHEET} (${unstyled.join(", ")}${outOfScope.length ? `; declared but not built: ${outOfScope.join(", ")}` : ""}), so no page renders family components for a brand layer to style. If shipping without one is intended, record that with ${cmd("theme")} waive.`]);
427
+ }
428
+ const cssPath = relative(derived.target_repo, layers[0]);
429
+ const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
430
+ if (existsSync(generated)) {
431
+ const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
432
+ evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .campaign-runtime/theme/brand-theme.css.`);
433
+ }
434
+ return {
435
+ cssPath,
436
+ commercePages: styled,
437
+ outOfScope: commerce.out_of_scope.map((page) => page.page_id || page.route || page.type),
438
+ evidence,
439
+ };
440
+ }
441
+
442
+ // An applied brand layer replaces any earlier waiver: the gate reads a waiver
443
+ // first, and the two answer the same question opposite ways. Other fields
444
+ // stay: `warnings` come from theme inspect, and `repair_loop_defect` is what
445
+ // `record polish` recorded about the repair loop, history the gate never reads.
446
+ function composeTheme(report, { now, recordedBy, layer }) {
447
+ const theme = {
448
+ ...(isObject(report.theme) ? report.theme : {}),
449
+ status: "applied",
450
+ css_path: layer.cssPath,
451
+ load_order: "after-next-core",
452
+ commerce_pages: layer.commercePages,
453
+ evidence: layer.evidence,
454
+ waiver: null,
455
+ recorded_by: recordedBy,
456
+ recorded_at: now,
457
+ };
458
+ return { report: { ...report, theme }, context: null };
459
+ }
460
+
461
+ // The local preview a `record deploy` URL must name: a loopback http(s)
462
+ // origin serving the packet's route root ("/<slug>/", or "/" for a
463
+ // root-served campaign) of a local-serve packet. Returns the URL as recorded
464
+ // (origin plus route root) and the problems that refuse it.
465
+ function localPreviewUrl(packet, rawUrl) {
466
+ if (!isLocalServePacket(packet)) {
467
+ return { url: null, problems: [`record deploy records a local preview, and this packet's deploy.target is "${packet?.deploy?.target || "unset"}". Serve the build locally with deploy.target local-serve (${cmd("qa")} policy set --packet <p> --deploy-target local-serve), or record a hosted deploy on the packet and stages.deploy.`] };
468
+ }
469
+ let url;
470
+ try {
471
+ url = new URL(String(rawUrl));
472
+ } catch {
473
+ return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug>/.`] };
474
+ }
475
+ const problems = [];
476
+ if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
477
+ if (!isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview is served on localhost, 127.0.0.1 or [::1].`);
478
+ const routeRoot = campaignRouteRoot(packet);
479
+ if (!routeRoot) {
480
+ problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
481
+ } else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
482
+ problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
483
+ }
484
+ return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
485
+ }
486
+
487
+ const PROBE_TIMEOUT_MS = 5_000;
488
+ const PROBE_MAX_REDIRECTS = 3;
489
+
490
+ // One page request. Redirects are followed only within the preview's own
491
+ // origin, so a probe never leaves the machine; a redirect elsewhere is
492
+ // reported, not followed.
493
+ async function probePage(target, fetchImpl) {
494
+ let current = target;
495
+ try {
496
+ for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
497
+ const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
498
+ await response.body?.cancel();
499
+ const location = response.headers?.get?.("location");
500
+ if (!(response.status >= 300 && response.status < 400) || !location) return { url: target, status: response.status };
501
+ const next = new URL(location, current);
502
+ if (next.origin !== new URL(target).origin) return { url: target, status: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
503
+ current = next.href;
504
+ }
505
+ return { url: target, status: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
506
+ } catch (error) {
507
+ const reason = error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error);
508
+ // A local static server usually speaks plain http; say so when https fails.
509
+ return { url: target, status: null, error: new URL(target).protocol === "https:" ? `${reason} (over https; a local static server usually serves http)` : reason };
510
+ }
511
+ }
512
+
513
+ /**
514
+ * What only the running server can say, read before the target lock (like
515
+ * the polish --evidence file): the URL names this campaign's local preview,
516
+ * and every built page under it answers 2xx. The lock re-checks the
517
+ * packet and the built output; the probe is never trusted for either.
518
+ */
519
+ export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
520
+ const packet = readPacketFile("deploy", packetPath);
521
+ const { url, problems } = localPreviewUrl(packet, baseUrl);
522
+ if (problems.length) throw refuseRecord("deploy", problems);
523
+ const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
524
+ if (!site.ok) throw refuseRecord("deploy", [site.error]);
525
+ // The built pages, not the bare route root: a funnel often has no index
526
+ // page there, and servers answer that differently (404, a listing).
527
+ const targets = [...new Set(site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url)))];
528
+ // Requested together; the evidence keeps the built pages' order.
529
+ const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
530
+ const failed = routes.filter((route) => !(route.status >= 200 && route.status < 300));
531
+ if (failed.length) {
532
+ throw refuseRecord("deploy", [
533
+ ...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
534
+ `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
535
+ ]);
536
+ }
537
+ return { url, routes };
538
+ }
539
+
540
+ // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
541
+ // completed with the URL in outputs (what next reads) and the probe as evidence.
542
+ function composeDeploy(report, packet, { now, recordedBy, probe }) {
543
+ const deploy = {
544
+ ...stageObject(report, "deploy"),
545
+ stage: "deploy",
546
+ status: "completed",
547
+ outputs: [probe.url],
548
+ evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
549
+ completed_at: now,
550
+ recorded_by: recordedBy,
551
+ blockers: [],
552
+ };
553
+ return {
554
+ report: { ...report, stages: { ...report.stages, deploy } },
555
+ context: null,
556
+ packet: { ...packet, deploy: { ...packet.deploy, preview_url: probe.url } },
557
+ };
558
+ }
559
+
297
560
  // Every check a written record must pass, over exactly what would be written.
298
561
  function validateRecord(stage, { report, context, packet, fingerprint }) {
299
562
  const problems = [
@@ -352,6 +615,9 @@ function ladderProblems(stage, doctor, report) {
352
615
  if (gate) return [`next answers prepare-build: ${gate.reason}`];
353
616
  const problems = [];
354
617
  for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
618
+ // The rule next's stage picker reads: on the local preview a missing polish
619
+ // is carried forward (local-preview-policy), and next moves on to deploy.
620
+ if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
355
621
  const key = reportKeyForCliStage(earlier);
356
622
  const status = String(report.stages[key]?.status || "");
357
623
  if (!stageIsTerminal(status)) {
@@ -369,7 +635,8 @@ function doctorFacts(stage, doctor, report, packet) {
369
635
  const derived = doctor.derived || {};
370
636
  const binding = bindingProblems(doctor, report, packet);
371
637
  if (binding.length) throw refuseRecord(stage, binding);
372
- const ladder = ladderProblems(stage, doctor, report);
638
+ // The brand layer is applied to built output, so theme is checked as polish is.
639
+ const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
373
640
  if (ladder.length) throw refuseRecord(stage, ladder);
374
641
  if (stage === "setup") {
375
642
  const outputDir = optionalString(derived.target_output_dir);
@@ -386,13 +653,15 @@ function doctorFacts(stage, doctor, report, packet) {
386
653
  if (stage === "build" && derived.scaffold_required === true) {
387
654
  throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
388
655
  }
389
- if (stage === "polish") {
656
+ if (stage === "polish" || stage === "theme" || stage === "deploy") {
390
657
  const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
391
658
  if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
392
659
  throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
393
660
  }
394
661
  if (recorded !== fingerprint) {
395
- throw refuseRecord(stage, [`The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
662
+ throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
663
+ ? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
664
+ : `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
396
665
  }
397
666
  }
398
667
  return {
@@ -416,6 +685,37 @@ function assertOutputUnchanged(stage, facts) {
416
685
  }
417
686
  }
418
687
 
688
+ // Under the lock, the probe is checked against the packet as it is now (it
689
+ // could have been retargeted while the probe ran), and the theme gate, which
690
+ // blocks deploy, must not be blocked.
691
+ function deployFacts(doctor, packet, probe) {
692
+ const { url, problems } = localPreviewUrl(packet, probe.url);
693
+ if (problems.length) throw refuseRecord("deploy", problems);
694
+ if (url !== probe.url) {
695
+ throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
696
+ }
697
+ const gate = doctor.derived?.theme_gate;
698
+ if (gate?.status === "blocked") {
699
+ throw refuseRecord("deploy", [
700
+ `${gate.code}: ${gate.reason}`,
701
+ ...(gate.required_actions || []).map((action) => `required action: ${action.command || action.description}`),
702
+ ]);
703
+ }
704
+ }
705
+
706
+ /**
707
+ * The `record` command as the CLI runs it: `record deploy` first probes the
708
+ * served preview (asynchronously, before the target lock), then every kind
709
+ * records through recordStageCommand.
710
+ */
711
+ export async function recordCommand(args, options = {}) {
712
+ const { stage, packetPath, baseUrl } = parseRecordArgs(args);
713
+ const probe = stage === "deploy" && existsSync(packetPath)
714
+ ? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
715
+ : null;
716
+ return recordStageCommand(args, { ...options, probe });
717
+ }
718
+
419
719
  function readPacketFile(stage, packetPath) {
420
720
  try {
421
721
  return JSON.parse(readFileSync(packetPath, "utf8"));
@@ -434,11 +734,13 @@ function readPacketFile(stage, packetPath) {
434
734
  * target lock, after doctor has read the target and before anything is
435
735
  * composed or written.
436
736
  */
437
- export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null } = {}) {
438
- const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
737
+ export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
738
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment } = parseRecordArgs(args);
439
739
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
740
+ if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
440
741
  // Operator input, not target state: no campaigns-os writer produces it.
441
- const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : null;
742
+ // For deploy, the probe of the running server (probeLocalPreview).
743
+ const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : null;
442
744
  const sidecars = {
443
745
  contextPath: args.context ? resolve(args.context) : undefined,
444
746
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -454,16 +756,24 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
454
756
  // A dry run writes nothing, so it takes no lock and creates no lock files
455
757
  // (the commitAssemblyReport preview convention); it reads in the same order.
456
758
  const run = () => recordUnderLock({
457
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
759
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment,
458
760
  });
459
761
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
460
- const { composed, facts, reportPath, contextPath, after } = recorded;
762
+ const { composed, facts, layer, reportPath, contextPath, after } = recorded;
461
763
 
462
764
  const stageKey = stage === "build" ? "assembly" : stage;
463
- const writes = [...(composed.context ? [contextPath] : []), reportPath];
464
- const ready = [
765
+ const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
766
+ const ready = stage === "deploy" ? [
767
+ `stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
768
+ ...composed.report.stages.deploy.evidence,
769
+ ] : stage === "theme" ? [
770
+ `theme.status = applied, load_order = after-next-core (${layer.cssPath})`,
771
+ ...layer.evidence,
772
+ ...(layer.outOfScope.length ? [`Not built in this scope, so not checked: ${layer.outOfScope.join(", ")}; run record theme again after building them.`] : []),
773
+ ] : [
465
774
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
466
775
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
776
+ ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
467
777
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
468
778
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
469
779
  ];
@@ -477,7 +787,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
477
787
  ...(composed.context ? { context_path: contextPath } : {}),
478
788
  ...(dryRun ? { would_write: writes } : { written: writes }),
479
789
  build_fingerprint: facts.fingerprint || null,
480
- record: composed.report.stages[stageKey],
790
+ record: stage === "theme" ? composed.report.theme : composed.report.stages[stageKey],
481
791
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
482
792
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
483
793
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -496,7 +806,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
496
806
  // re-check, and the post-write doctor read for `next_stage`. No campaigns-os
497
807
  // writer can rebind, rewrite or republish any of them between the read and
498
808
  // the write.
499
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead }) {
809
+ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null }) {
500
810
  // The same workspace `next` resolves, so the record lands in the report
501
811
  // `next` reads now, not the one it read before the lock was free.
502
812
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -508,6 +818,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
508
818
 
509
819
  let composed = null;
510
820
  let facts = null;
821
+ let layer = null;
511
822
  const compose = (report) => {
512
823
  if (!isObject(report) || !isObject(report.stages)) throw refuseRecord(stage, [`Assembly Report at ${reportPath} has no stages object.`]);
513
824
  const context = stage === "setup" ? readJsonIfExists(contextPath) : null;
@@ -525,27 +836,36 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
525
836
  }
526
837
  facts = doctorFacts(stage, doctor, report, packet);
527
838
  if (typeof afterDoctorRead === "function") afterDoctorRead();
839
+ if (stage === "theme") layer = brandLayerFacts(doctor);
840
+ if (stage === "deploy") deployFacts(doctor, packet, input);
528
841
  const next = stage === "setup"
529
842
  ? composeSetup(report, context, { now: timestamp, recordedBy })
530
843
  : stage === "build"
531
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
532
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
844
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment })
845
+ : stage === "theme"
846
+ ? composeTheme(report, { now: timestamp, recordedBy, layer })
847
+ : stage === "deploy"
848
+ ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
849
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
533
850
  applyDerivedAssemblyReportSummary(next.report);
851
+ // The packet's one new value, deploy.preview_url, is checked by
852
+ // localPreviewUrl; the rest of the packet is as the operator left it.
534
853
  validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
535
854
  assertOutputUnchanged(stage, facts);
536
855
  composed = next;
537
856
  if (dryRun) return null;
538
857
  // Written inside the report's critical section, after every check and
539
- // before the report itself, so the two files move together.
858
+ // before the report itself, so the files move together.
540
859
  if (next.context) writeJsonAtomic(contextPath, next.context);
860
+ if (next.packet) writeJsonAtomic(packetPath, next.packet);
541
861
  return next.report;
542
862
  };
543
863
  // Already inside the target lock, which commitAssemblyReport re-enters.
544
864
  commitAssemblyReport(workspace, compose, {
545
865
  command: `record ${stage}`,
546
- staleReason: `stages.${stage === "build" ? "assembly" : stage} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
866
+ staleReason: `${stage === "theme" ? "theme" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
547
867
  ...(dryRun ? { lock: false } : {}),
548
868
  });
549
869
  const after = dryRun ? null : doctorPacket(packetPath, sidecars);
550
- return { composed, facts, reportPath, contextPath, after };
870
+ return { composed, facts, layer, reportPath, contextPath, after };
551
871
  }
@@ -123,7 +123,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
123
123
  id: "fix_load_order",
124
124
  kind: "manual",
125
125
  command: null,
126
- description: "List brand-theme.css after next-core.css in commerce-page frontmatter styles, rebuild, then record report.theme.load_order=after-next-core.",
126
+ description: `List brand-theme.css after next-core.css in commerce-page frontmatter styles, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}.`,
127
127
  },
128
128
  ],
129
129
  );
@@ -145,7 +145,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
145
145
  return result(
146
146
  "blocked",
147
147
  "theme_gate.needs_decision",
148
- `Assembly report theme status is "${reportStatus || "missing"}" and no generatable brand theme exists; record an applied brand layer or waive the gate with a reason.`,
148
+ `Assembly report theme status is "${reportStatus || "missing"}" and no generatable brand theme exists; apply a brand layer and record it with campaigns-os record theme, or waive the gate with a reason.`,
149
149
  [
150
150
  {
151
151
  id: "waive_theme",
@@ -172,7 +172,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
172
172
  id: "apply_brand_layer",
173
173
  kind: "manual",
174
174
  command: null,
175
- description: "Copy brand-theme.css into the campaign assets/css folder, list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles, rebuild, then record report.theme.status=applied, load_order=after-next-core, and commerce_pages.",
175
+ description: `Copy brand-theme.css into the campaign assets/css folder, list it after next-core.css in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
176
176
  },
177
177
  {
178
178
  id: "waive_theme",