@nextcommerce/campaigns-os 1.61.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  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/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -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
  };
@@ -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
 
@@ -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,
@@ -519,12 +555,12 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
519
555
  // Context's theme.generated.fonts_css_path (theme generate --out-dir moves
520
556
  // it); the default path is read only when the context names none. Any page
521
557
  // that breaks the rule is a refusal naming the page.
522
- function brandLayerFacts(doctor, context = null) {
558
+ function brandLayerFacts(doctor, context = null, runtimeDir = resolve(doctor.derived?.target_repo || ".", ".campaign-runtime")) {
523
559
  const derived = doctor.derived || {};
524
560
  const recordedFonts = context?.theme?.generated?.fonts_css_path;
525
561
  const generatedFonts = typeof recordedFonts === "string" && recordedFonts
526
562
  ? resolve(derived.target_repo, recordedFonts)
527
- : (context?.theme?.generated ? null : resolve(derived.target_repo, ".campaign-runtime/theme", BRAND_FONTS_FILENAME));
563
+ : (context?.theme?.generated ? null : resolve(runtimeDir, "theme", BRAND_FONTS_FILENAME));
528
564
  const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
529
565
  const insideSite = (file) => {
530
566
  const rel = relative(site.site_root, file);
@@ -616,10 +652,10 @@ function brandLayerFacts(doctor, context = null) {
616
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.`]);
617
653
  }
618
654
  const cssPath = relative(derived.target_repo, layers[0]);
619
- const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
655
+ const generated = resolve(runtimeDir, "theme/brand-theme.css");
620
656
  if (existsSync(generated)) {
621
657
  const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
622
- 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("/")}.`);
623
659
  }
624
660
  return {
625
661
  cssPath,
@@ -1148,6 +1184,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1148
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`] : []),
1149
1185
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
1150
1186
  ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
1187
+ ...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
1151
1188
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
1152
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)`] : []),
1153
1190
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
@@ -1163,7 +1200,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1163
1200
  ...(dryRun ? { would_write: writes } : { written: writes }),
1164
1201
  build_fingerprint: facts.fingerprint || null,
1165
1202
  // The manifest is kept in the report, not echoed: it lists every built file.
1166
- 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],
1167
1204
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
1168
1205
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
1169
1206
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -1175,6 +1212,14 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1175
1212
  };
1176
1213
  }
1177
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
+
1178
1223
  // Everything a record reads from the target, in order, all under the target
1179
1224
  // lock (except a dry run, which writes nothing): the packet and the Build
1180
1225
  // Context's report pointer (the workspace), the report, the Build Context
@@ -1218,12 +1263,12 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1218
1263
  }
1219
1264
  facts = doctorFacts(stage, doctor, report, packet);
1220
1265
  if (typeof afterDoctorRead === "function") afterDoctorRead();
1221
- if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
1266
+ if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath), workspace.runtimeDir);
1222
1267
  if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
1223
1268
  const next = stage === "setup"
1224
1269
  ? composeSetup(report, context, { now: timestamp, recordedBy })
1225
1270
  : stage === "build"
1226
- ? 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 })
1227
1272
  : stage === "theme"
1228
1273
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
1229
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}. `