@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -19,6 +19,20 @@ export function specMaterialHash(spec) {
19
19
  return `sha256:${createHash("sha256").update(canonicalJson(materialSpec(spec))).digest("hex")}`;
20
20
  }
21
21
 
22
+ // The spec material with every page's page_url left out (funnels[].pages[]
23
+ // and the legacy funnel_pages[] mirror). Intake and record spec record it in
24
+ // the Build Context, so record spec can tell a change of routes alone from
25
+ // one that also touched anything else the build reads (#531).
26
+ export function routeMaskedSpecMaterialHash(spec) {
27
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) return specMaterialHash(spec);
28
+ const masked = JSON.parse(JSON.stringify(spec));
29
+ const lists = [...(Array.isArray(masked.funnels) ? masked.funnels.map((funnel) => funnel?.pages) : []), masked.funnel_pages];
30
+ for (const pages of lists) {
31
+ for (const page of Array.isArray(pages) ? pages : []) if (page && typeof page === "object") delete page.page_url;
32
+ }
33
+ return specMaterialHash(masked);
34
+ }
35
+
22
36
  // The three pure comparators live in `./spec-hash.mjs`, a dependency-free
23
37
  // leaf, so that browser-targeted consumers of the public
24
38
  // `./commercial-journey` export can reach them without pulling this module's
@@ -11,6 +11,7 @@ import {
11
11
  NEXT_STAGE_CONTRACTS,
12
12
  NEXT_STAGE_OWNERS,
13
13
  stageIsBlocked,
14
+ stageIsTerminal,
14
15
  } from "./orchestration-stage-contract.mjs";
15
16
 
16
17
  const PRODUCER_STAGES = new Set(["doctor", "qa"]);
@@ -226,6 +227,20 @@ export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackage
226
227
  return { ...restored, stage: "polish", ...stamps };
227
228
  }
228
229
 
230
+ /**
231
+ * True when the campaign has been handed off (#721): its deploy or its QA was
232
+ * completed, as the live record or as a record the stage's history keeps
233
+ * (a deploy or QA made owed again by a later change still happened). From
234
+ * then on the repo, not the design source, is the authority for which pages
235
+ * the funnel has, so `spec derive` adds and removes pages from the page tree
236
+ * and `record spec` binds that change without intake.
237
+ */
238
+ export function campaignHandedOff(report) {
239
+ const completedIn = (stage) => isPlainObject(stage) && (isCompletedStatus(stage.status)
240
+ || (Array.isArray(stage.history) && stage.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
241
+ return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
242
+ }
243
+
229
244
  /**
230
245
  * True when any stage of `report` carries a non-empty history: recorded
231
246
  * operator-visible state an intake must not silently drop.
@@ -573,6 +588,30 @@ export function qaRecordedBuildFingerprint(report) {
573
588
  || optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
574
589
  }
575
590
 
591
+ // #720: the build whose completed QA a page-scoped run extends. A completed
592
+ // QA record extends its own build. A page-scoped run that did not complete
593
+ // extends the build of the full QA it was based on, which was completed when
594
+ // that run was accepted. Any other record (blocked or pending full QA, a
595
+ // skip, a required stage) extends nothing, so it owes a full QA run.
596
+ const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
597
+ export function qaExtendedBuildFingerprint(report) {
598
+ const qa = report?.stages?.qa;
599
+ if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
600
+ if (qa?.status === "blocked") return optionalString(qa?.evidence?.page_scope?.base_build_fingerprint);
601
+ return null;
602
+ }
603
+
604
+ // The build Polish last reviewed: the output a terminal Polish is bound to,
605
+ // or, once a rebuild has made it required, the build that rebuild kept for
606
+ // it. A blocked or pending Polish reviewed nothing.
607
+ export function polishReviewedBuild(polish) {
608
+ if (stageIsTerminal(String(polish?.status || ""))) return optionalString(polish.source_build_fingerprint);
609
+ // A brief or spec change since then (input_change, which only a restored
610
+ // or new Polish clears) owes the full review, whatever the build change.
611
+ if (Object.hasOwn(polish || {}, "input_change")) return null;
612
+ return polish?.status === "required" && polish.required_by === "build" ? optionalString(polish.owed_since_build_fingerprint) : null;
613
+ }
614
+
576
615
  export function qaRecordedForCurrentBuild(report, buildFingerprint) {
577
616
  const recorded = qaRecordedBuildFingerprint(report);
578
617
  const current = optionalString(buildFingerprint);
@@ -716,7 +755,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
716
755
  const doctor = refreshDoctor(outcome);
717
756
  if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
718
757
  } else if (outcome.written) {
719
- markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
758
+ markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim(), ...(doctorOutPath ? { path: doctorOutPath } : {}) });
720
759
  }
721
760
  return outcome;
722
761
  };
@@ -23,12 +23,12 @@
23
23
  // stylesheets must also match the built output byte for byte).
24
24
  import { createHash } from "node:crypto";
25
25
  import { existsSync, readFileSync, statSync } from "node:fs";
26
- import { dirname, join, relative, resolve, sep } from "node:path";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
 
29
29
  import Ajv2020 from "ajv/dist/2020.js";
30
30
 
31
- import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
31
+ import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
32
32
  import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
33
33
  import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
34
34
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
@@ -36,7 +36,9 @@ import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requ
36
36
  import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
37
37
  import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
38
38
  import { isLoopbackHostname } from "./remit.mjs";
39
- import { campaignRouteRoot } from "./route-identity.mjs";
39
+ import { campaignRouteRoot, normalizePublicRouteSlug } from "./route-identity.mjs";
40
+ import { buildChangeEntry, buildCommerceSignature, nextBuildChanges } from "./change-scope.mjs";
41
+ import { assessEvidenceDebt } from "./evidence-debt.mjs";
40
42
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
41
43
  import { doctorPacket } from "./doctor/inspect.mjs";
42
44
  import { validateAssemblyReport } from "./doctor/checks.mjs";
@@ -52,7 +54,7 @@ import {
52
54
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
53
55
  import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
54
56
  import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
55
- import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
57
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
56
58
  import { withTargetLockSync } from "./target-lock.mjs";
57
59
  import { commerceScopeFromScope } from "./theme-gate.mjs";
58
60
 
@@ -127,7 +129,7 @@ function typeName(value) {
127
129
 
128
130
  // visual_review keys only `polish capture` writes. `record polish --evidence`
129
131
  // refuses them by name and carries the captured values forward.
130
- export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
132
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
131
133
  export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
132
134
 
133
135
  export function refuseRecord(stage, problems) {
@@ -330,17 +332,39 @@ function currentStamps(inputs) {
330
332
  return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
331
333
  }
332
334
 
333
- function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
335
+ // #707/#720: what this rebuild changed against the build recorded before it,
336
+ // classified page-only or commerce-affecting, appended to the kept history so
337
+ // a stage that reviewed an older build can still read its changed pages; and
338
+ // the commerce signature of this output, for the next rebuild to compare
339
+ // against. Read from the same packet and output doctor fingerprinted.
340
+ function buildChangeFacts(report, { packet, packetPath, fingerprint, now }) {
341
+ if (!packet || !packetPath) return {};
342
+ const previousAssembly = stageObject(report, "assembly");
343
+ const targetRepo = targetRepoFor(packetPath, packet);
344
+ const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
345
+ const outputRoot = join(targetRepo, "_site", slug);
346
+ const signature = buildCommerceSignature({ scope: resolveBuiltSiteScope(outputRoot), outputRoot, targetRepo, publicRouteSlug: slug });
347
+ const from = optionalString(previousAssembly.build_fingerprint);
348
+ if (!from || from === fingerprint) return { signature, buildChanges: previousAssembly.build_changes };
349
+ const debt = assessEvidenceDebt({ packet, packetPath, report });
350
+ const entry = buildChangeEntry({ from, to: fingerprint, at: now, changes: debt.changes,
351
+ classification: { kind: debt.changes.kind, reasons: debt.changes.reasons } });
352
+ const anchors = [qaRecordedBuildFingerprint(report), qaExtendedBuildFingerprint(report), polishReviewedBuild(stageObject(report, "polish"))];
353
+ return { signature, buildChanges: nextBuildChanges(previousAssembly.build_changes, entry, anchors) };
354
+ }
355
+
356
+ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null, packet = null, packetPath = null }) {
334
357
  const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
335
358
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
336
359
  const previousAssembly = stageObject(report, "assembly");
360
+ const { signature, buildChanges } = buildChangeFacts(report, { packet, packetPath, fingerprint, now });
337
361
  const stamps = currentStamps(inputs);
338
362
  // A replaced completed record whose stamps differ from the current inputs
339
363
  // records the change now, if nothing recorded it before.
340
364
  const writeInputs = stageWriteInputs("assembly", inputs);
341
365
  const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
342
366
  let assembly = {
343
- ...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
367
+ ...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", "commerce_signature", "build_changes", ...STAMP_FIELDS]),
344
368
  ...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
345
369
  stage: "assembly",
346
370
  status: "completed",
@@ -349,6 +373,14 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
349
373
  // (doctor's built_output.fingerprint_stale, polish capture) can name the
350
374
  // files that differ. Left out when it cannot be read back as this value.
351
375
  ...(manifest ? { build_manifest: manifest } : {}),
376
+ // One hash per built page over its commerce-bearing markup, and one of
377
+ // the campaign's Page Kit config entry, so the next change can be read
378
+ // as page-only or commerce-affecting (change-scope.mjs).
379
+ ...(signature ? { commerce_signature: signature } : {}),
380
+ // The rebuilds since the builds Polish and QA last reviewed, each with
381
+ // its changed pages and classification, so the changed-page list
382
+ // survives this record (readback evidence_debt.<stage>.change).
383
+ ...(Array.isArray(buildChanges) && buildChanges.length ? { build_changes: buildChanges } : {}),
352
384
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
353
385
  ...stamps,
354
386
  completed_at: now,
@@ -393,14 +425,18 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
393
425
  if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
394
426
  const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
395
427
  && optionalString(previousPolish.source_build_fingerprint) === fingerprint;
428
+ // The build Polish reviewed stays named once its binding is cleared, so a
429
+ // page-only change since then reads as advisory (evidence-debt.mjs).
430
+ const reviewed = polishReviewedBuild(previousPolish);
396
431
  const polish = polishStillCurrent
397
432
  ? previousPolish
398
433
  : {
399
- ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", ...STAMP_FIELDS]),
434
+ ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
400
435
  stage: "polish",
401
436
  status: "required",
402
437
  required_by: "build",
403
438
  required_for: ["qa"],
439
+ ...(reviewed ? { owed_since_build_fingerprint: reviewed } : {}),
404
440
  };
405
441
  return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
406
442
  }
@@ -456,7 +492,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {
456
492
  : previous.evidence;
457
493
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
458
494
  let polish = {
459
- ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", ...STAMP_FIELDS]),
495
+ ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
460
496
  stage: "polish",
461
497
  status: input.status,
462
498
  performed_by: POLISH_PRODUCER,
@@ -512,10 +548,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
512
548
  // family components, so it must load a brand layer (brand-theme.css or
513
549
  // checkout-brand.css) after it, and that file must be in the built output. A
514
550
  // page that loads neither renders the design's own markup and needs no brand
515
- // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
551
+ // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
552
+ // theme generate wrote brand-fonts.css (the design's own faces), each styled
553
+ // page must load the campaign's copy too, or the families brand-theme.css
554
+ // names fall back to the system font. Where it was written is the Build
555
+ // Context's theme.generated.fonts_css_path (theme generate --out-dir moves
556
+ // it); the default path is read only when the context names none. Any page
516
557
  // that breaks the rule is a refusal naming the page.
517
- function brandLayerFacts(doctor) {
558
+ function brandLayerFacts(doctor, context = null, runtimeDir = resolve(doctor.derived?.target_repo || ".", ".campaign-runtime")) {
518
559
  const derived = doctor.derived || {};
560
+ const recordedFonts = context?.theme?.generated?.fonts_css_path;
561
+ const generatedFonts = typeof recordedFonts === "string" && recordedFonts
562
+ ? resolve(derived.target_repo, recordedFonts)
563
+ : (context?.theme?.generated ? null : resolve(runtimeDir, "theme", BRAND_FONTS_FILENAME));
564
+ const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
565
+ const insideSite = (file) => {
566
+ const rel = relative(site.site_root, file);
567
+ return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
568
+ };
519
569
  const commerce = commerceScopeFromScope(derived.scope);
520
570
  if (!commerce.all.length) {
521
571
  throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
@@ -566,6 +616,30 @@ function brandLayerFacts(doctor) {
566
616
  problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
567
617
  continue;
568
618
  }
619
+ // brand-fonts.css holds only @font-face rules, so unlike the brand layer
620
+ // its position relative to next-core.css does not matter.
621
+ if (fontsRequired) {
622
+ const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
623
+ const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
624
+ const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
625
+ ? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
626
+ : null;
627
+ const generatedRel = relative(derived.target_repo, generatedFonts);
628
+ const problem = !fontsHref
629
+ ? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
630
+ : !fontsFile || !insideSite(fontsFile)
631
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
632
+ : !existsSync(fontsFile)
633
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
634
+ : readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
635
+ ? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
636
+ : null;
637
+ if (problem) {
638
+ problems.push(problem);
639
+ continue;
640
+ }
641
+ evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
642
+ }
569
643
  layers.push(file);
570
644
  styled.push(label);
571
645
  evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
@@ -578,10 +652,10 @@ function brandLayerFacts(doctor) {
578
652
  : `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.`]);
579
653
  }
580
654
  const cssPath = relative(derived.target_repo, layers[0]);
581
- const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
655
+ const generated = resolve(runtimeDir, "theme/brand-theme.css");
582
656
  if (existsSync(generated)) {
583
657
  const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
584
- evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .campaign-runtime/theme/brand-theme.css.`);
658
+ evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated ${relative(derived.target_repo, generated).split(sep).join("/")}.`);
585
659
  }
586
660
  return {
587
661
  cssPath,
@@ -865,7 +939,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
865
939
  // over the report this record would write. A blocked or skipped Polish is
866
940
  // not a pass the gate could grant; doctor keeps QA blocked on it.
867
941
  const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
868
- const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
942
+ const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
869
943
  if (gate.status === "blocked") {
870
944
  problems.push(`${gate.code}: ${gate.reason}`);
871
945
  for (const problem of gate.problems || []) problems.push(problem);
@@ -1110,6 +1184,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1110
1184
  ...(stage === "build" && Array.isArray(composed.report.stages.assembly.build_manifest) ? [`stages.assembly.build_manifest lists ${composed.report.stages.assembly.build_manifest.length} file(s), so a later fingerprint mismatch names the paths that differ`] : []),
1111
1185
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
1112
1186
  ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
1187
+ ...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
1113
1188
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
1114
1189
  ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
1115
1190
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
@@ -1125,7 +1200,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1125
1200
  ...(dryRun ? { would_write: writes } : { written: writes }),
1126
1201
  build_fingerprint: facts.fingerprint || null,
1127
1202
  // The manifest is kept in the report, not echoed: it lists every built file.
1128
- record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
1203
+ record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
1129
1204
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
1130
1205
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
1131
1206
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -1137,6 +1212,14 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1137
1212
  };
1138
1213
  }
1139
1214
 
1215
+ // One line for the rebuild this record kept, when it replaced another build.
1216
+ function buildChangeReadyLines(assembly) {
1217
+ const latest = Array.isArray(assembly.build_changes) ? assembly.build_changes.at(-1) : null;
1218
+ if (!latest || latest.build_fingerprint !== assembly.build_fingerprint) return [];
1219
+ const pages = latest.changed_pages.map((page) => page.page_id).join(", ") || "none";
1220
+ return [`stages.assembly.build_changes: this rebuild is ${latest.kind.replace("_", "-")} (changed pages: ${pages}${latest.unmapped_file_count ? `; ${latest.unmapped_file_count} file(s) outside any page` : ""})`];
1221
+ }
1222
+
1140
1223
  // Everything a record reads from the target, in order, all under the target
1141
1224
  // lock (except a dry run, which writes nothing): the packet and the Build
1142
1225
  // Context's report pointer (the workspace), the report, the Build Context
@@ -1180,12 +1263,12 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1180
1263
  }
1181
1264
  facts = doctorFacts(stage, doctor, report, packet);
1182
1265
  if (typeof afterDoctorRead === "function") afterDoctorRead();
1183
- if (stage === "theme") layer = brandLayerFacts(doctor);
1266
+ if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath), workspace.runtimeDir);
1184
1267
  if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
1185
1268
  const next = stage === "setup"
1186
1269
  ? composeSetup(report, context, { now: timestamp, recordedBy })
1187
1270
  : stage === "build"
1188
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
1271
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason, packet, packetPath })
1189
1272
  : stage === "theme"
1190
1273
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
1191
1274
  : stage === "deploy"
@@ -22,9 +22,19 @@ export function targetLockPath(targetRepo) {
22
22
  // holds it, only a pid, so the holder is described generically. A lock with
23
23
  // no owner record is called out on its own: it is never taken over, and the
24
24
  // operator needs to know it will not clear by waiting. So is anything at the
25
- // lock path that is not a lock at all, named by what it is (#514).
25
+ // lock path that is not a lock at all, named by what it is, and a lock held
26
+ // by a live process of another campaigns-os release, which is refused at once
27
+ // (#514).
26
28
  function unavailable(targetRepo, lockPath, command) {
27
29
  return (error) => {
30
+ if (error?.code === "ELOCKFORMAT") {
31
+ return Object.assign(new Error(
32
+ `${command}: the target lock at ${lockPath} is held by process ${error.holderPid}, which runs a different campaigns-os release. `
33
+ + "Two releases taking this lock at the same moment can both get it, so this run stops instead of waiting. "
34
+ + `Let that run finish, then retry with one campaigns-os release working on ${targetRepo} at a time.`,
35
+ { cause: error },
36
+ ), { code: error.code, holderPid: error.holderPid, lockFormat: error.lockFormat });
37
+ }
28
38
  if (error?.code === "ENOTLOCK") {
29
39
  return Object.assign(new Error(
30
40
  `${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
166
166
  id: "theme_generate",
167
167
  kind: "command",
168
168
  command: `campaigns-os theme generate --packet ${packetArg}`,
169
- description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
169
+ description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
170
170
  },
171
171
  {
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 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.`,
175
+ description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, 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",