@nextcommerce/campaigns-os 1.57.0 → 1.59.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 (41) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/campaign-spec/dist/checkout-flow.d.ts +7 -0
  3. package/campaign-spec/dist/checkout-flow.js +17 -1
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +26 -27
  7. package/contracts/effects.v1.json +5 -5
  8. package/contracts/release-ledger.json +432 -0
  9. package/contracts/supported-surface.json +4 -4
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/docs/build-packet.md +22 -8
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/qa-and-test-orders.md +54 -12
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/package.json +1 -1
  18. package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
  19. package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +6 -6
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/built-site-scope.mjs +91 -0
  31. package/src/cli.mjs +15 -23
  32. package/src/doctor/checks.mjs +73 -3
  33. package/src/doctor/next-step.mjs +3 -14
  34. package/src/polish-node.mjs +5 -4
  35. package/src/qa-browser.mjs +137 -2
  36. package/src/qa-cart-entry.mjs +105 -0
  37. package/src/qa-node.mjs +3 -1
  38. package/src/qa-verdict.mjs +24 -1
  39. package/src/sdk-attribute-index.mjs +8 -7
  40. package/src/source-html-intake.mjs +27 -18
  41. package/src/stage-record.mjs +200 -33
@@ -17,17 +17,20 @@
17
17
  // polish` validate the context they read but write only the report. `record
18
18
  // theme` writes `report.theme` only after reading, in each built commerce
19
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.
22
- import { existsSync, readFileSync } from "node:fs";
23
- import { dirname, join, relative, resolve } from "node:path";
20
+ // deploy` records a served deploy: the packet's deploy.preview_url and
21
+ // stages.deploy, after every built page answers on the served URL (loopback
22
+ // for a local-serve packet; https for a hosted one, whose served scripts and
23
+ // stylesheets must also match the built output byte for byte).
24
+ import { createHash } from "node:crypto";
25
+ import { existsSync, readFileSync, statSync } from "node:fs";
26
+ import { dirname, join, relative, resolve, sep } from "node:path";
24
27
  import { fileURLToPath } from "node:url";
25
28
 
26
29
  import Ajv2020 from "ajv/dist/2020.js";
27
30
 
28
31
  import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
32
  import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
30
- import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
33
+ import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
31
34
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
32
35
  import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
33
36
  import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
@@ -327,7 +330,7 @@ function currentStamps(inputs) {
327
330
  return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
328
331
  }
329
332
 
330
- function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
333
+ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
331
334
  const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
332
335
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
333
336
  const previousAssembly = stageObject(report, "assembly");
@@ -337,11 +340,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
337
340
  const writeInputs = stageWriteInputs("assembly", inputs);
338
341
  const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
339
342
  let assembly = {
340
- ...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", ...STAMP_FIELDS]),
343
+ ...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
341
344
  ...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
342
345
  stage: "assembly",
343
346
  status: "completed",
344
347
  build_fingerprint: fingerprint,
348
+ // The path + sha256 list the fingerprint hashes, so a later mismatch
349
+ // (doctor's built_output.fingerprint_stale, polish capture) can name the
350
+ // files that differ. Left out when it cannot be read back as this value.
351
+ ...(manifest ? { build_manifest: manifest } : {}),
345
352
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
346
353
  ...stamps,
347
354
  completed_at: now,
@@ -587,30 +594,143 @@ function composeTheme(report, { now, recordedBy, layer }) {
587
594
  return { report: { ...report, theme }, context: null };
588
595
  }
589
596
 
590
- // The local preview a `record deploy` URL must name: a loopback http(s)
591
- // origin serving the packet's route root ("/<slug>/", or "/" for a
592
- // root-served campaign) of a local-serve packet. Returns the URL as recorded
593
- // (origin plus route root) and the problems that refuse it.
594
- function localPreviewUrl(packet, rawUrl) {
595
- if (!isLocalServePacket(packet)) {
596
- 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.`] };
597
+ // The served preview a `record deploy` URL must name, as recorded (origin
598
+ // plus the packet's route root, "/<slug>/" or "/" for a root-served
599
+ // campaign), and the problems that refuse it. A local-serve packet names a
600
+ // loopback http(s) origin. A hosted packet names an https origin unless the
601
+ // host is loopback, where http is accepted too.
602
+ function deployPreviewUrl(packet, rawUrl) {
603
+ const hosted = !isLocalServePacket(packet);
604
+ if (hosted && !optionalString(packet?.deploy?.target)) {
605
+ return { url: null, hosted, problems: ["This packet records no deploy.target; set it (local-serve, or the hosting target) with " + `${cmd("qa")} policy set --packet <p> --deploy-target <target>.`] };
597
606
  }
598
607
  let url;
599
608
  try {
600
609
  url = new URL(String(rawUrl));
601
610
  } catch {
602
- return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug>/.`] };
611
+ return { url: null, hosted, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example ${hosted ? "https://<preview host>/<slug>/" : "http://localhost:<port>/<slug>/"}.`] };
603
612
  }
604
613
  const problems = [];
605
614
  if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
606
- 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].`);
615
+ else if (!hosted && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview (deploy.target local-serve) is served on localhost, 127.0.0.1 or [::1].`);
616
+ else if (hosted && url.protocol !== "https:" && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is plain http; a hosted deploy (deploy.target ${packet.deploy.target}) is recorded over https.`);
607
617
  const routeRoot = campaignRouteRoot(packet);
608
618
  if (!routeRoot) {
609
619
  problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
610
620
  } else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
611
621
  problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
612
622
  }
613
- return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
623
+ return { url: problems.length ? null : `${url.origin}${routeRoot}`, hosted, problems };
624
+ }
625
+
626
+ function scriptSrcs(html) {
627
+ return [...html.matchAll(/<script\b[^>]*>/gi)]
628
+ .map((match) => (match[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i) || [])[1])
629
+ .filter(Boolean);
630
+ }
631
+
632
+ const sha256Of = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
633
+
634
+ // The scripts and stylesheets the built pages load from the preview's own
635
+ // origin and the build output holds: each one's served URL, its file under
636
+ // _site/ (the host serves _site/ as its origin root) and that file's sha256.
637
+ // A reference to another origin, or to a path the build output does not hold,
638
+ // is not the build's to compare.
639
+ function builtAssets(site, pageUrls) {
640
+ const assets = new Map();
641
+ site.pages.forEach((page, index) => {
642
+ const html = readFileSync(page.built_path, "utf8");
643
+ const pageUrl = new URL(pageUrls[index]);
644
+ for (const ref of [...scriptSrcs(html), ...stylesheetHrefs(html)]) {
645
+ let served;
646
+ try {
647
+ served = new URL(ref, pageUrl);
648
+ } catch {
649
+ continue;
650
+ }
651
+ if (served.origin !== pageUrl.origin) continue;
652
+ served.hash = "";
653
+ let pathname;
654
+ try {
655
+ pathname = decodeURIComponent(served.pathname);
656
+ } catch {
657
+ continue;
658
+ }
659
+ const file = resolve(site.site_root, `.${pathname}`);
660
+ if (!file.startsWith(`${resolve(site.site_root)}${sep}`) || !existsSync(file) || !statSync(file).isFile()) continue;
661
+ if (!assets.has(served.href)) {
662
+ const bytes = readFileSync(file);
663
+ assets.set(served.href, { url: served.href, file, sha256: sha256Of(bytes), size: bytes.byteLength });
664
+ }
665
+ }
666
+ });
667
+ return [...assets.values()];
668
+ }
669
+
670
+ // A file's sha256, or null when it cannot be read (removed, or replaced by a
671
+ // directory, since it was listed).
672
+ function fileSha256(file) {
673
+ try {
674
+ return sha256Of(readFileSync(file));
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+
680
+ // A served body, read no further than `limit` bytes: a response that says or
681
+ // streams more than the built file holds cannot be that file, so the read
682
+ // stops there instead of buffering whatever the host sends.
683
+ async function readCapped(response, limit) {
684
+ const declared = Number(response.headers?.get?.("content-length"));
685
+ if (Number.isFinite(declared) && declared > limit) {
686
+ await response.body?.cancel?.();
687
+ return { bytes: null, error: `Content-Length ${declared} exceeds the built file's ${limit} bytes` };
688
+ }
689
+ const reader = response.body?.getReader?.();
690
+ // No readable body is a transport failure, not an empty file, unless the
691
+ // built file is itself empty.
692
+ if (!reader) return limit === 0 ? { bytes: Buffer.alloc(0) } : { bytes: null, error: "the response had no readable body" };
693
+ const chunks = [];
694
+ let total = 0;
695
+ for (;;) {
696
+ const { done, value } = await reader.read();
697
+ if (done) break;
698
+ total += value.byteLength;
699
+ if (total > limit) {
700
+ await reader.cancel();
701
+ return { bytes: null, error: `served more than the built file's ${limit} bytes` };
702
+ }
703
+ chunks.push(Buffer.from(value));
704
+ }
705
+ return { bytes: Buffer.concat(chunks) };
706
+ }
707
+
708
+ // One served asset, compared by bytes: it must answer 200, following
709
+ // redirects only within the preview's own origin as a page request does.
710
+ async function probeAsset(asset, fetchImpl) {
711
+ let current = asset.url;
712
+ try {
713
+ for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
714
+ const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
715
+ const location = response.headers?.get?.("location");
716
+ if (response.status >= 300 && response.status < 400 && location) {
717
+ await response.body?.cancel?.();
718
+ const next = new URL(location, current);
719
+ if (next.origin !== new URL(asset.url).origin) return { ...asset, served: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
720
+ current = next.href;
721
+ continue;
722
+ }
723
+ if (response.status !== 200) {
724
+ await response.body?.cancel?.();
725
+ return { ...asset, served: null, error: `HTTP ${response.status}` };
726
+ }
727
+ const { bytes, error } = await readCapped(response, asset.size);
728
+ return bytes ? { ...asset, served: sha256Of(bytes) } : { ...asset, served: null, error };
729
+ }
730
+ return { ...asset, served: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
731
+ } catch (error) {
732
+ return { ...asset, served: null, error: error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error) };
733
+ }
614
734
  }
615
735
 
616
736
  const PROBE_TIMEOUT_MS = 5_000;
@@ -641,32 +761,56 @@ async function probePage(target, fetchImpl) {
641
761
 
642
762
  /**
643
763
  * What only the running server can say, read before the target lock (like
644
- * the polish --evidence file): the URL names this campaign's local preview,
645
- * and every built page under it answers 2xx. The lock re-checks the
646
- * packet and the built output; the probe is never trusted for either.
764
+ * the polish --evidence file): the URL names this campaign's served preview,
765
+ * and every built page under it answers (2xx on a local preview, 200 on a
766
+ * hosted one). On a hosted preview every script and stylesheet the built
767
+ * pages load from that origin must also be served byte-identical to the
768
+ * built output; HTML is not compared, since hosts inject markup. The lock
769
+ * re-checks the packet and the built output; the probe is never trusted for
770
+ * either.
647
771
  */
648
772
  export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
649
773
  const packet = readPacketFile("deploy", packetPath);
650
- const { url, problems } = localPreviewUrl(packet, baseUrl);
774
+ const { url, hosted, problems } = deployPreviewUrl(packet, baseUrl);
651
775
  if (problems.length) throw refuseRecord("deploy", problems);
652
776
  const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
653
777
  if (!site.ok) throw refuseRecord("deploy", [site.error]);
654
778
  // The built pages, not the bare route root: a funnel often has no index
655
779
  // page there, and servers answer that differently (404, a listing).
656
- const targets = [...new Set(site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url)))];
780
+ const pageUrls = site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url));
781
+ const targets = [...new Set(pageUrls)];
657
782
  // Requested together; the evidence keeps the built pages' order.
658
783
  const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
659
- const failed = routes.filter((route) => !(route.status >= 200 && route.status < 300));
784
+ const answered = (status) => (hosted ? status === 200 : status >= 200 && status < 300);
785
+ const failed = routes.filter((route) => !answered(route.status));
660
786
  if (failed.length) {
661
787
  throw refuseRecord("deploy", [
662
788
  ...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
663
- `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
789
+ hosted
790
+ ? `Deploy the current _site/ build so every page answers HTTP 200 at ${url}, then run ${cmd("record")} deploy again.`
791
+ : `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
792
+ ]);
793
+ }
794
+ // The build the probe ran against, so a record build before the lock
795
+ // refuses instead of recording a probe of other output.
796
+ const probedBuild = computeBuildFingerprint(site.campaign_dir);
797
+ const buildFingerprint = probedBuild.ok ? probedBuild.fingerprint : null;
798
+ if (!hosted) return { url, routes, buildFingerprint };
799
+ const assets = await Promise.all(builtAssets(site, pageUrls).map((asset) => probeAsset(asset, fetchImpl)));
800
+ const targetRepo = site.target_repo;
801
+ const differing = assets.filter((asset) => asset.served !== asset.sha256);
802
+ if (differing.length) {
803
+ throw refuseRecord("deploy", [
804
+ ...differing.map((asset) => (asset.served
805
+ ? `${asset.url}: served bytes (${asset.served}) differ from ${relative(targetRepo, asset.file)} (${asset.sha256}).`
806
+ : `${asset.url}: ${asset.error}; ${relative(targetRepo, asset.file)} is in the build output.`)),
807
+ `The preview at ${url} is not serving the recorded build. Deploy the current _site/ build, then run ${cmd("record")} deploy again.`,
664
808
  ]);
665
809
  }
666
- return { url, routes };
810
+ return { url, routes, buildFingerprint, assets: assets.map(({ url: assetUrl, file, sha256 }) => ({ url: assetUrl, file, sha256 })) };
667
811
  }
668
812
 
669
- // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
813
+ // A recorded preview (local or hosted): the packet's deploy.preview_url, and stages.deploy
670
814
  // completed with the URL in outputs (what next reads), the probe as evidence,
671
815
  // and the build it probed as source_build_fingerprint (doctorFacts has checked
672
816
  // it equals stages.assembly.build_fingerprint), so a later record build of
@@ -678,7 +822,10 @@ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint })
678
822
  status: "completed",
679
823
  source_build_fingerprint: fingerprint,
680
824
  outputs: [probe.url],
681
- evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
825
+ evidence: [
826
+ ...probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
827
+ ...(probe.assets || []).map((asset) => `${asset.url} served ${asset.sha256}, as built`),
828
+ ],
682
829
  completed_at: now,
683
830
  recorded_by: recordedBy,
684
831
  blockers: [],
@@ -808,6 +955,15 @@ function doctorFacts(stage, doctor, report, packet) {
808
955
  };
809
956
  }
810
957
 
958
+ // The manifest of the output doctor fingerprinted, read with doctor's own
959
+ // function over doctor's own root; null when the output on disk is no longer
960
+ // that value (assertOutputUnchanged then refuses the record).
961
+ function outputManifest(facts) {
962
+ if (!facts.fingerprint || !facts.fingerprintRoot) return null;
963
+ const current = computeBuildFingerprint(facts.fingerprintRoot);
964
+ return current.ok && current.fingerprint === facts.fingerprint ? buildManifestEntries(current) : null;
965
+ }
966
+
811
967
  // The last check before the write: the output doctor fingerprinted is still
812
968
  // the output on disk. The target lock keeps campaigns-os writers out, but a
813
969
  // page-kit build does not take it, so the fingerprint is recomputed here with
@@ -824,12 +980,21 @@ function assertOutputUnchanged(stage, facts) {
824
980
  // Under the lock, the probe is checked against the packet as it is now (it
825
981
  // could have been retargeted while the probe ran), and the theme gate, which
826
982
  // blocks deploy, must not be blocked.
827
- function deployFacts(doctor, packet, probe) {
828
- const { url, problems } = localPreviewUrl(packet, probe.url);
983
+ function deployFacts(doctor, packet, probe, fingerprint) {
984
+ const { url, problems } = deployPreviewUrl(packet, probe.url);
829
985
  if (problems.length) throw refuseRecord("deploy", problems);
830
986
  if (url !== probe.url) {
831
987
  throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
832
988
  }
989
+ // The served assets were compared with the files as they were before the
990
+ // lock; a build since then is a refusal, not a record of other output.
991
+ const rebuilt = (probe.assets || []).filter((asset) => fileSha256(asset.file) !== asset.sha256);
992
+ if (rebuilt.length) {
993
+ throw refuseRecord("deploy", [`The built output changed while the preview was probed (${rebuilt.map((asset) => asset.url).join(", ")}); run ${cmd("record")} build, then record deploy again.`]);
994
+ }
995
+ if (probe.buildFingerprint !== undefined && probe.buildFingerprint !== fingerprint) {
996
+ throw refuseRecord("deploy", [`The built output changed while the preview was probed (probed ${probe.buildFingerprint || "no output"}, now ${fingerprint}); run ${cmd("record")} deploy again.`]);
997
+ }
833
998
  const gate = doctor.derived?.theme_gate;
834
999
  if (gate?.status === "blocked") {
835
1000
  throw refuseRecord("deploy", [
@@ -926,6 +1091,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
926
1091
  ] : [
927
1092
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
928
1093
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
1094
+ ...(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`] : []),
929
1095
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
930
1096
  ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
931
1097
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
@@ -942,7 +1108,8 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
942
1108
  ...(composed.context ? { context_path: contextPath } : {}),
943
1109
  ...(dryRun ? { would_write: writes } : { written: writes }),
944
1110
  build_fingerprint: facts.fingerprint || null,
945
- record: stage === "theme" ? composed.report.theme : composed.report.stages[stageKey],
1111
+ // The manifest is kept in the report, not echoed: it lists every built file.
1112
+ record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
946
1113
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
947
1114
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
948
1115
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -998,11 +1165,11 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
998
1165
  facts = doctorFacts(stage, doctor, report, packet);
999
1166
  if (typeof afterDoctorRead === "function") afterDoctorRead();
1000
1167
  if (stage === "theme") layer = brandLayerFacts(doctor);
1001
- if (stage === "deploy") deployFacts(doctor, packet, input);
1168
+ if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
1002
1169
  const next = stage === "setup"
1003
1170
  ? composeSetup(report, context, { now: timestamp, recordedBy })
1004
1171
  : stage === "build"
1005
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
1172
+ ? 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 })
1006
1173
  : stage === "theme"
1007
1174
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
1008
1175
  : stage === "deploy"
@@ -1010,7 +1177,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1010
1177
  : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
1011
1178
  applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
1012
1179
  // The packet's one new value, deploy.preview_url, is checked by
1013
- // localPreviewUrl; the rest of the packet is as the operator left it.
1180
+ // deployPreviewUrl; the rest of the packet is as the operator left it.
1014
1181
  validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
1015
1182
  assertOutputUnchanged(stage, facts);
1016
1183
  composed = next;