@nextcommerce/campaigns-os 1.47.0 → 1.50.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 (53) hide show
  1. package/CHANGELOG.md +307 -0
  2. package/README.md +30 -3
  3. package/agents/claude/CLAUDE.md +6 -3
  4. package/agents/codex/AGENTS.md +6 -4
  5. package/agents/copilot/copilot-instructions.md +3 -2
  6. package/agents/cursor/campaigns-os.mdc +3 -3
  7. package/compatibility.json +1 -1
  8. package/contracts/commerce-surface-catalog.json +17 -17
  9. package/contracts/effects.v1.json +173 -0
  10. package/contracts/release-ledger.json +798 -0
  11. package/contracts/supported-surface.json +4 -4
  12. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  13. package/docs/brand-theme-bridge.md +12 -6
  14. package/docs/build-packet.md +9 -4
  15. package/docs/campaign-build-brief.md +25 -28
  16. package/docs/local-setup.md +7 -2
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/qa-and-test-orders.md +70 -9
  19. package/docs/runtime-readiness.md +1 -1
  20. package/docs/skills-revision.md +10 -10
  21. package/package.json +1 -1
  22. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  23. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  24. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  25. package/skills/campaign-readback-classification/SKILL.md +3 -3
  26. package/skills/campaign-run-evidence/SKILL.md +9 -4
  27. package/skills/contribution-intake/SKILL.md +3 -3
  28. package/skills/next-campaigns-build/SKILL.md +5 -4
  29. package/skills/next-campaigns-os/SKILL.md +3 -3
  30. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  31. package/skills/next-campaigns-polish/SKILL.md +6 -5
  32. package/skills/next-campaigns-qa/SKILL.md +3 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +13 -2
  35. package/src/cli.mjs +17 -12
  36. package/src/content-residue.mjs +18 -90
  37. package/src/doctor/checks.mjs +21 -29
  38. package/src/doctor/inspect.mjs +13 -3
  39. package/src/doctor/next-step.mjs +4 -0
  40. package/src/gate-actions.mjs +8 -0
  41. package/src/install-mode.mjs +0 -8
  42. package/src/invocation.mjs +3 -1
  43. package/src/local-preview-policy.mjs +92 -0
  44. package/src/page-kit-sdk-version.mjs +8 -1
  45. package/src/polish-node.mjs +26 -2
  46. package/src/progress-node.mjs +5 -1
  47. package/src/qa-binding-evidence.mjs +22 -1
  48. package/src/qa-browser.mjs +92 -14
  49. package/src/qa-node.mjs +43 -6
  50. package/src/readback.mjs +19 -10
  51. package/src/source-prep.mjs +1 -1
  52. package/src/stage-record.mjs +303 -22
  53. package/src/theme-gate.mjs +3 -3
package/src/readback.mjs CHANGED
@@ -1065,8 +1065,11 @@ function artifactData(views, key) {
1065
1065
  }
1066
1066
 
1067
1067
  /**
1068
- * Bucket the QA verdict's assertions by recorded status.
1068
+ * Bucket the QA verdict's assertions by recorded status: `{ fail, pass,
1069
+ * skipped, review, other }`.
1069
1070
  *
1071
+ * `review` collects the verdict's `warn` and `manual_review` statuses: evidence
1072
+ * that still needs reading, which the disposition counts as an exception.
1070
1073
  * `other` collects any status this readback does not project (it renders those
1071
1074
  * rows as written rather than reclassifying them). Non-object entries are
1072
1075
  * dropped: an assertion the readback cannot address by status is not an
@@ -1075,13 +1078,15 @@ function artifactData(views, key) {
1075
1078
  * branch.
1076
1079
  */
1077
1080
  export function partitionAssertions(views) {
1078
- const buckets = { fail: [], pass: [], skipped: [], other: [] };
1081
+ const buckets = { fail: [], pass: [], skipped: [], review: [], other: [] };
1079
1082
  const verdict = artifactData(views, "qa_verdict");
1080
1083
  if (verdict === null) return buckets;
1081
1084
  for (const assertion of verdict.assertions ?? []) {
1082
1085
  if (!isPlainObject(assertion)) continue;
1083
1086
  const status = assertion.status;
1084
- const bucket = status === "fail" || status === "pass" || status === "skipped" ? status : "other";
1087
+ const bucket = status === "fail" || status === "pass" || status === "skipped"
1088
+ ? status
1089
+ : status === "warn" || status === "manual_review" ? "review" : "other";
1085
1090
  buckets[bucket].push(assertion);
1086
1091
  }
1087
1092
  return buckets;
@@ -1414,15 +1419,17 @@ function renderVerdict(views, lines) {
1414
1419
  lines.push(
1415
1420
  `QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
1416
1421
  );
1417
- const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
1422
+ const { fail: failed, pass: passed, skipped, review, other } = partitionAssertions(views);
1418
1423
  let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
1424
+ if (review.length) countLine += `, ${review.length} warn or manual review`;
1419
1425
  if (other.length) countLine += `, ${other.length} unrecognized status`;
1420
1426
  lines.push(countLine);
1421
- for (const assertion of failed) {
1422
- const severity = assertion.severity;
1423
- const severityText = severity ? `, severity ${severity}` : "";
1427
+ // fail, warn and manual_review rows print what Campaigns OS recorded: the
1428
+ // row's actual value and any evidence problems.
1429
+ const renderRecordedRow = (assertion) => {
1430
+ const severityText = assertion.severity ? `, severity ${assertion.severity}` : "";
1424
1431
  lines.push(
1425
- ` fail ${recorded(assertion.id, "(no id)")} ` +
1432
+ ` ${assertion.status} ${recorded(assertion.id, "(no id)")} ` +
1426
1433
  `(family ${recorded(assertion.family, "(no family)")}${severityText})`,
1427
1434
  );
1428
1435
  const actual = assertion.actual;
@@ -1433,7 +1440,9 @@ function renderVerdict(views, lines) {
1433
1440
  lines.push(` recorded problems (${problems.length}):`);
1434
1441
  for (const problem of problems) lines.push(` - ${problem}`);
1435
1442
  }
1436
- }
1443
+ };
1444
+ for (const assertion of failed) renderRecordedRow(assertion);
1445
+ for (const assertion of review) renderRecordedRow(assertion);
1437
1446
  for (const assertion of passed) {
1438
1447
  const family = assertion.family || assertion.id || "(no family)";
1439
1448
  lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
@@ -1442,7 +1451,7 @@ function renderVerdict(views, lines) {
1442
1451
  lines.push(
1443
1452
  ` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
1444
1453
  `(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
1445
- "projects fail, pass, and skipped statuses",
1454
+ "projects fail, pass, skipped, warn and manual_review statuses",
1446
1455
  );
1447
1456
  }
1448
1457
 
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
201
201
  const policyNote = wrapperPolicy === "preserve_document_wrappers"
202
202
  ? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
203
203
  : "";
204
- return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision.${policyNote} See ${docs}.`;
204
+ return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
205
205
  }
206
206
  if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
207
207
  const listed = sample.map((page) => {
@@ -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,24 @@
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 { isLocalServePacket } from "./local-proof.mjs";
33
+ import { isLoopbackHostname } from "./remit.mjs";
34
+ import { campaignRouteRoot } from "./route-identity.mjs";
27
35
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
28
36
  import { doctorPacket } from "./doctor/inspect.mjs";
29
37
  import { validateAssemblyReport } from "./doctor/checks.mjs";
@@ -39,13 +47,15 @@ import {
39
47
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
40
48
  import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
41
49
  import { withTargetLockSync } from "./target-lock.mjs";
50
+ import { commerceScopeFromScope } from "./theme-gate.mjs";
42
51
 
43
- export const RECORD_STAGES = Object.freeze(["setup", "build", "polish"]);
52
+ export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
44
53
 
45
54
  // Every flag `record` reads, plus the two any command accepts (run id and
46
55
  // lifecycle journal). Anything else is refused before a file is read.
47
56
  const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal"]);
48
57
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
58
+ const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
49
59
 
50
60
  // The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
51
61
  // `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
@@ -93,9 +103,9 @@ function refuseRecord(stage, problems) {
93
103
  export function parseRecordArgs(args) {
94
104
  const stage = args._[1];
95
105
  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>.`);
106
+ 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>, and record deploy --base-url <served url>.`);
97
107
  }
98
- const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : [])]);
108
+ const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : [])]);
99
109
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
100
110
  if (unknown.length) {
101
111
  throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
@@ -111,6 +121,7 @@ export function parseRecordArgs(args) {
111
121
  stage,
112
122
  packetPath: resolve(requireArg(args, "packet")),
113
123
  evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
124
+ baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
114
125
  dryRun: args["dry-run"] === true,
115
126
  };
116
127
  }
@@ -294,6 +305,223 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
294
305
  return { report: nextReport, context: null };
295
306
  }
296
307
 
308
+ // The stylesheet every family's commerce pages load first; the brand layer
309
+ // must come after it so its --brand--* values win.
310
+ const CORE_STYLESHEET = "next-core.css";
311
+
312
+ function stylesheetHrefs(html) {
313
+ return [...html.matchAll(/<link\b[^>]*>/gi)]
314
+ .map((match) => match[0])
315
+ .filter((tag) => /\brel\s*=\s*["']?[^"'>]*\bstylesheet\b/i.test(tag))
316
+ .map((tag) => (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1])
317
+ .filter(Boolean);
318
+ }
319
+
320
+ const hrefName = (href) => href.split(/[?#]/)[0].split("/").pop();
321
+ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
322
+
323
+ // What `record theme` stands on, read from each built commerce page's
324
+ // stylesheet links in document order. A page that loads next-core.css renders
325
+ // family components, so it must load a brand layer (brand-theme.css or
326
+ // checkout-brand.css) after it, and that file must be in the built output. A
327
+ // page that loads neither renders the design's own markup and needs no brand
328
+ // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
329
+ // that breaks the rule is a refusal naming the page.
330
+ function brandLayerFacts(doctor) {
331
+ const derived = doctor.derived || {};
332
+ const commerce = commerceScopeFromScope(derived.scope);
333
+ if (!commerce.all.length) {
334
+ throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
335
+ }
336
+ const site = resolveBuiltSiteScope(derived.target_repo, { slug: derived.public_route_slug });
337
+ if (!site.ok) throw refuseRecord("theme", [site.error]);
338
+ const byRoute = new Map(site.pages.map((page) => [routeKey(page.route), page]));
339
+ const byId = new Map(site.pages.map((page) => [page.page_id, page]));
340
+ const problems = [];
341
+ const evidence = [];
342
+ const layers = [];
343
+ const styled = [];
344
+ const unstyled = [];
345
+ for (const page of commerce.built) {
346
+ const label = page.page_id || page.route || page.type;
347
+ const built = byRoute.get(routeKey(page.route)) || byId.get(page.page_id);
348
+ if (!built) {
349
+ problems.push(`${label}: no built page in ${relative(derived.target_repo, site.campaign_dir) || "."}; run the page-kit build, then ${cmd("record")} build.`);
350
+ continue;
351
+ }
352
+ const builtRel = relative(derived.target_repo, built.built_path);
353
+ const hrefs = stylesheetHrefs(readFileSync(built.built_path, "utf8"));
354
+ const core = hrefs.findIndex((href) => hrefName(href) === CORE_STYLESHEET);
355
+ const brand = hrefs.findIndex((href, index) => index > core && BRAND_LAYER_FILENAMES.has(hrefName(href)));
356
+ if (core < 0) {
357
+ if (hrefs.some((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)))) {
358
+ 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.`);
359
+ } else {
360
+ unstyled.push(label);
361
+ evidence.push(`${label}: ${builtRel} loads neither ${CORE_STYLESHEET} nor a brand layer (the design's own markup).`);
362
+ }
363
+ continue;
364
+ }
365
+ if (brand < 0) {
366
+ const early = hrefs.find((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)));
367
+ problems.push(early
368
+ ? `${label}: ${builtRel} links ${early} before ${CORE_STYLESHEET}; list it after ${CORE_STYLESHEET} in the page's frontmatter styles and rebuild.`
369
+ : `${label}: ${builtRel} links no brand layer (${[...BRAND_LAYER_FILENAMES].join(" or ")}) after ${CORE_STYLESHEET}.`);
370
+ continue;
371
+ }
372
+ const href = hrefs[brand].split(/[?#]/)[0];
373
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(href)) {
374
+ 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.`);
375
+ continue;
376
+ }
377
+ const file = href.startsWith("/") ? join(site.site_root, href) : resolve(dirname(built.built_path), href);
378
+ if (!existsSync(file)) {
379
+ problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
380
+ continue;
381
+ }
382
+ layers.push(file);
383
+ styled.push(label);
384
+ evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
385
+ }
386
+ if (problems.length) throw refuseRecord("theme", problems);
387
+ if (!styled.length) {
388
+ const outOfScope = commerce.out_of_scope.map((page) => page.page_id || page.route || page.type);
389
+ throw refuseRecord("theme", [!commerce.built.length
390
+ ? `No commerce page is built in this scope (declared but not built: ${outOfScope.join(", ")}); build them, run ${cmd("record")} build, then record theme.`
391
+ : `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.`]);
392
+ }
393
+ const cssPath = relative(derived.target_repo, layers[0]);
394
+ const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
395
+ if (existsSync(generated)) {
396
+ const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
397
+ evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .campaign-runtime/theme/brand-theme.css.`);
398
+ }
399
+ return {
400
+ cssPath,
401
+ commercePages: styled,
402
+ outOfScope: commerce.out_of_scope.map((page) => page.page_id || page.route || page.type),
403
+ evidence,
404
+ };
405
+ }
406
+
407
+ // An applied brand layer replaces any earlier waiver: the gate reads a waiver
408
+ // first, and the two answer the same question opposite ways. Other fields
409
+ // stay: `warnings` come from theme inspect, and `repair_loop_defect` is what
410
+ // `record polish` recorded about the repair loop, history the gate never reads.
411
+ function composeTheme(report, { now, recordedBy, layer }) {
412
+ const theme = {
413
+ ...(isObject(report.theme) ? report.theme : {}),
414
+ status: "applied",
415
+ css_path: layer.cssPath,
416
+ load_order: "after-next-core",
417
+ commerce_pages: layer.commercePages,
418
+ evidence: layer.evidence,
419
+ waiver: null,
420
+ recorded_by: recordedBy,
421
+ recorded_at: now,
422
+ };
423
+ return { report: { ...report, theme }, context: null };
424
+ }
425
+
426
+ // The local preview a `record deploy` URL must name: a loopback http(s)
427
+ // origin serving the packet's route root ("/<slug>/", or "/" for a
428
+ // root-served campaign) of a local-serve packet. Returns the URL as recorded
429
+ // (origin plus route root) and the problems that refuse it.
430
+ function localPreviewUrl(packet, rawUrl) {
431
+ if (!isLocalServePacket(packet)) {
432
+ 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.`] };
433
+ }
434
+ let url;
435
+ try {
436
+ url = new URL(String(rawUrl));
437
+ } catch {
438
+ return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug>/.`] };
439
+ }
440
+ const problems = [];
441
+ if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
442
+ 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].`);
443
+ const routeRoot = campaignRouteRoot(packet);
444
+ if (!routeRoot) {
445
+ problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
446
+ } else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
447
+ problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
448
+ }
449
+ return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
450
+ }
451
+
452
+ const PROBE_TIMEOUT_MS = 5_000;
453
+ const PROBE_MAX_REDIRECTS = 3;
454
+
455
+ // One page request. Redirects are followed only within the preview's own
456
+ // origin, so a probe never leaves the machine; a redirect elsewhere is
457
+ // reported, not followed.
458
+ async function probePage(target, fetchImpl) {
459
+ let current = target;
460
+ try {
461
+ for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
462
+ const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
463
+ await response.body?.cancel();
464
+ const location = response.headers?.get?.("location");
465
+ if (!(response.status >= 300 && response.status < 400) || !location) return { url: target, status: response.status };
466
+ const next = new URL(location, current);
467
+ if (next.origin !== new URL(target).origin) return { url: target, status: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
468
+ current = next.href;
469
+ }
470
+ return { url: target, status: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
471
+ } catch (error) {
472
+ const reason = error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error);
473
+ // A local static server usually speaks plain http; say so when https fails.
474
+ return { url: target, status: null, error: new URL(target).protocol === "https:" ? `${reason} (over https; a local static server usually serves http)` : reason };
475
+ }
476
+ }
477
+
478
+ /**
479
+ * What only the running server can say, read before the target lock (like
480
+ * the polish --evidence file): the URL names this campaign's local preview,
481
+ * and every built page under it answers 2xx. The lock re-checks the
482
+ * packet and the built output; the probe is never trusted for either.
483
+ */
484
+ export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
485
+ const packet = readPacketFile("deploy", packetPath);
486
+ const { url, problems } = localPreviewUrl(packet, baseUrl);
487
+ if (problems.length) throw refuseRecord("deploy", problems);
488
+ const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
489
+ if (!site.ok) throw refuseRecord("deploy", [site.error]);
490
+ // The built pages, not the bare route root: a funnel often has no index
491
+ // page there, and servers answer that differently (404, a listing).
492
+ const targets = [...new Set(site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url)))];
493
+ // Requested together; the evidence keeps the built pages' order.
494
+ const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
495
+ const failed = routes.filter((route) => !(route.status >= 200 && route.status < 300));
496
+ if (failed.length) {
497
+ throw refuseRecord("deploy", [
498
+ ...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
499
+ `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
500
+ ]);
501
+ }
502
+ return { url, routes };
503
+ }
504
+
505
+ // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
506
+ // completed with the URL in outputs (what next reads) and the probe as evidence.
507
+ function composeDeploy(report, packet, { now, recordedBy, probe }) {
508
+ const deploy = {
509
+ ...stageObject(report, "deploy"),
510
+ stage: "deploy",
511
+ status: "completed",
512
+ outputs: [probe.url],
513
+ evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
514
+ completed_at: now,
515
+ recorded_by: recordedBy,
516
+ blockers: [],
517
+ };
518
+ return {
519
+ report: { ...report, stages: { ...report.stages, deploy } },
520
+ context: null,
521
+ packet: { ...packet, deploy: { ...packet.deploy, preview_url: probe.url } },
522
+ };
523
+ }
524
+
297
525
  // Every check a written record must pass, over exactly what would be written.
298
526
  function validateRecord(stage, { report, context, packet, fingerprint }) {
299
527
  const problems = [
@@ -369,7 +597,8 @@ function doctorFacts(stage, doctor, report, packet) {
369
597
  const derived = doctor.derived || {};
370
598
  const binding = bindingProblems(doctor, report, packet);
371
599
  if (binding.length) throw refuseRecord(stage, binding);
372
- const ladder = ladderProblems(stage, doctor, report);
600
+ // The brand layer is applied to built output, so theme is checked as polish is.
601
+ const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
373
602
  if (ladder.length) throw refuseRecord(stage, ladder);
374
603
  if (stage === "setup") {
375
604
  const outputDir = optionalString(derived.target_output_dir);
@@ -386,13 +615,15 @@ function doctorFacts(stage, doctor, report, packet) {
386
615
  if (stage === "build" && derived.scaffold_required === true) {
387
616
  throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
388
617
  }
389
- if (stage === "polish") {
618
+ if (stage === "polish" || stage === "theme" || stage === "deploy") {
390
619
  const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
391
620
  if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
392
621
  throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
393
622
  }
394
623
  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.`]);
624
+ throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
625
+ ? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
626
+ : `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
627
  }
397
628
  }
398
629
  return {
@@ -416,6 +647,37 @@ function assertOutputUnchanged(stage, facts) {
416
647
  }
417
648
  }
418
649
 
650
+ // Under the lock, the probe is checked against the packet as it is now (it
651
+ // could have been retargeted while the probe ran), and the theme gate, which
652
+ // blocks deploy, must not be blocked.
653
+ function deployFacts(doctor, packet, probe) {
654
+ const { url, problems } = localPreviewUrl(packet, probe.url);
655
+ if (problems.length) throw refuseRecord("deploy", problems);
656
+ if (url !== probe.url) {
657
+ throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
658
+ }
659
+ const gate = doctor.derived?.theme_gate;
660
+ if (gate?.status === "blocked") {
661
+ throw refuseRecord("deploy", [
662
+ `${gate.code}: ${gate.reason}`,
663
+ ...(gate.required_actions || []).map((action) => `required action: ${action.command || action.description}`),
664
+ ]);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * The `record` command as the CLI runs it: `record deploy` first probes the
670
+ * served preview (asynchronously, before the target lock), then every kind
671
+ * records through recordStageCommand.
672
+ */
673
+ export async function recordCommand(args, options = {}) {
674
+ const { stage, packetPath, baseUrl } = parseRecordArgs(args);
675
+ const probe = stage === "deploy" && existsSync(packetPath)
676
+ ? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
677
+ : null;
678
+ return recordStageCommand(args, { ...options, probe });
679
+ }
680
+
419
681
  function readPacketFile(stage, packetPath) {
420
682
  try {
421
683
  return JSON.parse(readFileSync(packetPath, "utf8"));
@@ -434,11 +696,13 @@ function readPacketFile(stage, packetPath) {
434
696
  * target lock, after doctor has read the target and before anything is
435
697
  * composed or written.
436
698
  */
437
- export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null } = {}) {
699
+ export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
438
700
  const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
439
701
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
702
+ if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
440
703
  // Operator input, not target state: no campaigns-os writer produces it.
441
- const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : null;
704
+ // For deploy, the probe of the running server (probeLocalPreview).
705
+ const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : null;
442
706
  const sidecars = {
443
707
  contextPath: args.context ? resolve(args.context) : undefined,
444
708
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -457,11 +721,18 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
457
721
  stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
458
722
  });
459
723
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
460
- const { composed, facts, reportPath, contextPath, after } = recorded;
724
+ const { composed, facts, layer, reportPath, contextPath, after } = recorded;
461
725
 
462
726
  const stageKey = stage === "build" ? "assembly" : stage;
463
- const writes = [...(composed.context ? [contextPath] : []), reportPath];
464
- const ready = [
727
+ const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
728
+ const ready = stage === "deploy" ? [
729
+ `stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
730
+ ...composed.report.stages.deploy.evidence,
731
+ ] : stage === "theme" ? [
732
+ `theme.status = applied, load_order = after-next-core (${layer.cssPath})`,
733
+ ...layer.evidence,
734
+ ...(layer.outOfScope.length ? [`Not built in this scope, so not checked: ${layer.outOfScope.join(", ")}; run record theme again after building them.`] : []),
735
+ ] : [
465
736
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
466
737
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
467
738
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
@@ -477,7 +748,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
477
748
  ...(composed.context ? { context_path: contextPath } : {}),
478
749
  ...(dryRun ? { would_write: writes } : { written: writes }),
479
750
  build_fingerprint: facts.fingerprint || null,
480
- record: composed.report.stages[stageKey],
751
+ record: stage === "theme" ? composed.report.theme : composed.report.stages[stageKey],
481
752
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
482
753
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
483
754
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -508,6 +779,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
508
779
 
509
780
  let composed = null;
510
781
  let facts = null;
782
+ let layer = null;
511
783
  const compose = (report) => {
512
784
  if (!isObject(report) || !isObject(report.stages)) throw refuseRecord(stage, [`Assembly Report at ${reportPath} has no stages object.`]);
513
785
  const context = stage === "setup" ? readJsonIfExists(contextPath) : null;
@@ -525,27 +797,36 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
525
797
  }
526
798
  facts = doctorFacts(stage, doctor, report, packet);
527
799
  if (typeof afterDoctorRead === "function") afterDoctorRead();
800
+ if (stage === "theme") layer = brandLayerFacts(doctor);
801
+ if (stage === "deploy") deployFacts(doctor, packet, input);
528
802
  const next = stage === "setup"
529
803
  ? composeSetup(report, context, { now: timestamp, recordedBy })
530
804
  : stage === "build"
531
805
  ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
532
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
806
+ : stage === "theme"
807
+ ? composeTheme(report, { now: timestamp, recordedBy, layer })
808
+ : stage === "deploy"
809
+ ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
810
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
533
811
  applyDerivedAssemblyReportSummary(next.report);
812
+ // The packet's one new value, deploy.preview_url, is checked by
813
+ // localPreviewUrl; the rest of the packet is as the operator left it.
534
814
  validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
535
815
  assertOutputUnchanged(stage, facts);
536
816
  composed = next;
537
817
  if (dryRun) return null;
538
818
  // Written inside the report's critical section, after every check and
539
- // before the report itself, so the two files move together.
819
+ // before the report itself, so the files move together.
540
820
  if (next.context) writeJsonAtomic(contextPath, next.context);
821
+ if (next.packet) writeJsonAtomic(packetPath, next.packet);
541
822
  return next.report;
542
823
  };
543
824
  // Already inside the target lock, which commitAssemblyReport re-enters.
544
825
  commitAssemblyReport(workspace, compose, {
545
826
  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.`,
827
+ 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
828
  ...(dryRun ? { lock: false } : {}),
548
829
  });
549
830
  const after = dryRun ? null : doctorPacket(packetPath, sidecars);
550
- return { composed, facts, reportPath, contextPath, after };
831
+ return { composed, facts, layer, reportPath, contextPath, after };
551
832
  }
@@ -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",