@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,509 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
3
+ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
4
+ import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
5
+ import {
6
+ ASSEMBLY_REPORT_STAGE_KEYS,
7
+ NEXT_STAGE_CONTRACTS,
8
+ NEXT_STAGE_OWNERS,
9
+ stageIsBlocked,
10
+ stageIsTerminal,
11
+ } from "./orchestration-stage-contract.mjs";
12
+
13
+ const PRODUCER_STAGES = new Set(["doctor", "qa"]);
14
+
15
+ function nonEmptyStrings(values) {
16
+ return Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : [];
17
+ }
18
+
19
+ function terminalStatus(disposition) {
20
+ if (disposition === "blocked") return "blocked";
21
+ if (disposition === "ready_with_warnings" || disposition === "ready_with_exceptions") return "completed_with_warnings";
22
+ if (disposition === "ready") return "completed";
23
+ throw new Error(`Unsupported producer disposition "${disposition}".`);
24
+ }
25
+
26
+ // Extension fields on a producer stage that the PRODUCER owns, not the
27
+ // operator. Everything else on the stage object is someone else's data and
28
+ // passes through a producer write untouched (`waivers` is read elsewhere in
29
+ // this repo, and out-of-repo consumers read this report too), so this list must
30
+ // stay exactly as long as the evidence a producer can actually restate.
31
+ //
32
+ // Compatibility decision, deliberate and narrow: before this, every hand-authored
33
+ // extension field survived the spread, which let a stage carry a refreshed
34
+ // status/outputs/timestamp beside a previous run's `verdict_run_id` and an
35
+ // `evidence` block describing an already-fixed bug. Latest identity and latest
36
+ // status can no longer disagree. The previous pair is not deleted — it is moved,
37
+ // with its ORIGINAL status and timestamp, into a bounded `history[]` on the same
38
+ // stage. A previous stage with no `checked_at` (the pre-#308 report shape)
39
+ // produces a history entry with no `checked_at`: an absent timestamp is
40
+ // preserved as absent rather than stamped with now, because manufactured
41
+ // provenance is worse than the stale field it replaces. Both schema-legal
42
+ // `evidence` shapes archive, object and array alike; an array of operator notes
43
+ // is exactly the evidence a producer has no standing to silently drop.
44
+ const QA_OWNED_FIELDS = Object.freeze(["verdict_run_id", "evidence", "purchase_proof"]);
45
+
46
+ // Bounded so a committed handoff artifact cannot grow without limit, and deep
47
+ // enough that a couple of repair attempts do not evict the state a reviewer
48
+ // came looking for.
49
+ const PRODUCER_STAGE_HISTORY_LIMIT = 5;
50
+ // The fields a producer restates on every run even when nothing else moved.
51
+ // A re-run that reaches the same outcome differs from the previous report in
52
+ // these alone, and rewriting the file for them makes every digest taken of
53
+ // the report (a Run Record's assembly_report sha256, for one) go stale for
54
+ // no information.
55
+ const PRODUCER_STAGE_TIMESTAMP_FIELDS = Object.freeze(["checked_at", "completed_at"]);
56
+
57
+ // `$defs.stage.evidence` is `oneOf: [array, object]`, so an operator or an
58
+ // out-of-repo producer may legally have written either shape. Recognize both,
59
+ // or the array branch is deleted below with no history entry and the notes it
60
+ // carried leave the report entirely.
61
+ function evidenceValue(value) {
62
+ if (isPlainObject(value) || Array.isArray(value)) return value;
63
+ return null;
64
+ }
65
+
66
+ // An empty object or array is schema-legal but says nothing. History exists to
67
+ // preserve prior identity a reviewer might come looking for; an empty evidence
68
+ // block is not that, and archiving one produces a no-op entry that evicts a
69
+ // real one from the bounded window. Empty therefore reads as absent.
70
+ function meaningfulEvidence(value) {
71
+ const evidence = evidenceValue(value);
72
+ if (!evidence) return null;
73
+ const empty = Array.isArray(evidence) ? evidence.length === 0 : Object.keys(evidence).length === 0;
74
+ return empty ? null : evidence;
75
+ }
76
+
77
+ // Key order is not meaning. `previous` has been round-tripped through disk and
78
+ // may come back with its keys in any order, while `incoming` carries whatever
79
+ // order the producer happened to build it in; a plain JSON.stringify comparison
80
+ // would call those two unequal and archive a new history entry on every
81
+ // re-record of an unchanged verdict, which is precisely what the dedup below
82
+ // exists to prevent. Canonicalize object keys (arrays keep their order, which
83
+ // IS meaning) before comparing.
84
+ function canonicalize(value) {
85
+ if (Array.isArray(value)) return value.map(canonicalize);
86
+ if (isPlainObject(value)) {
87
+ const out = {};
88
+ for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
89
+ return out;
90
+ }
91
+ return value;
92
+ }
93
+
94
+ function sameJson(a, b) {
95
+ return JSON.stringify(canonicalize(a ?? null)) === JSON.stringify(canonicalize(b ?? null));
96
+ }
97
+
98
+ /**
99
+ * Archive the previous producer-owned identity, if there was one, without
100
+ * inventing anything it did not carry.
101
+ */
102
+ function archivePreviousIdentity(previous, incoming) {
103
+ const hadIdentity = typeof previous.verdict_run_id === "string" && previous.verdict_run_id.trim()
104
+ ? previous.verdict_run_id.trim()
105
+ : null;
106
+ const hadEvidence = meaningfulEvidence(previous.evidence);
107
+ if (!hadIdentity && !hadEvidence) return null;
108
+ // An unchanged verdict is a re-record, not a new chapter: re-running the same
109
+ // producer against the same verdict must not grow history. Both sides are
110
+ // normalized the same way, so an empty incoming evidence block compares equal
111
+ // to an empty previous one instead of looking like a change.
112
+ if (sameJson(hadIdentity, incoming.verdict_run_id ?? null) && sameJson(hadEvidence, meaningfulEvidence(incoming.evidence))) return null;
113
+ const entry = {};
114
+ if (typeof previous.status === "string" && previous.status.trim()) entry.status = previous.status;
115
+ if (typeof previous.checked_at === "string" && previous.checked_at.trim()) entry.checked_at = previous.checked_at;
116
+ if (hadIdentity) entry.verdict_run_id = hadIdentity;
117
+ if (hadEvidence) entry.evidence = JSON.parse(JSON.stringify(hadEvidence));
118
+ return entry;
119
+ }
120
+
121
+ function withoutStageTimestamps(report, stage) {
122
+ const copy = JSON.parse(JSON.stringify(report ?? null));
123
+ const stageValue = copy?.stages?.[stage];
124
+ if (isPlainObject(stageValue)) {
125
+ for (const field of PRODUCER_STAGE_TIMESTAMP_FIELDS) delete stageValue[field];
126
+ }
127
+ return copy;
128
+ }
129
+
130
+ /**
131
+ * True when `nextReport` restates exactly the outcome `previousReport` already
132
+ * carries for `stage`, differing only in that stage's timestamps. A producer
133
+ * that sees this has nothing to write: the report on disk already says what
134
+ * this run found, and leaving its bytes alone keeps every digest of it valid.
135
+ */
136
+ export function producerStageOutcomeUnchanged(previousReport, nextReport, stage) {
137
+ if (!PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
138
+ return sameJson(withoutStageTimestamps(previousReport, stage), withoutStageTimestamps(nextReport, stage));
139
+ }
140
+
141
+ /**
142
+ * Return an Assembly Report copy with the current doctor/QA producer outcome.
143
+ * The producer supplies its own timestamp and artifact paths; this helper never
144
+ * invents historical completion evidence.
145
+ *
146
+ * `identity` (currently `{ verdict_run_id }`) and `evidence` are the QA
147
+ * producer's own explanation of the run the canonical fields point at.
148
+ * `proof` is the counts-only purchase-proof summary — never order ids, refs,
149
+ * emails or URLs, because this report is committed and rides into the readback
150
+ * bundle.
151
+ */
152
+ export function recordProducerStageOutcome(report, {
153
+ stage,
154
+ disposition,
155
+ timestamp,
156
+ command,
157
+ outputs = [],
158
+ blockers = [],
159
+ warnings = [],
160
+ identity = null,
161
+ evidence = null,
162
+ proof = null,
163
+ } = {}) {
164
+ if (!PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
165
+ if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp))) {
166
+ throw new Error("Producer stage timestamp must be a parseable ISO timestamp.");
167
+ }
168
+ if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("Assembly Report must be an object.");
169
+
170
+ const updated = JSON.parse(JSON.stringify(report));
171
+ const stages = updated.stages && typeof updated.stages === "object" && !Array.isArray(updated.stages)
172
+ ? updated.stages
173
+ : {};
174
+ const previous = stages[stage] && typeof stages[stage] === "object" && !Array.isArray(stages[stage])
175
+ ? stages[stage]
176
+ : {};
177
+ const status = terminalStatus(disposition);
178
+ const next = {
179
+ ...previous,
180
+ stage,
181
+ status,
182
+ inputs: nonEmptyStrings(previous.inputs),
183
+ outputs: nonEmptyStrings(outputs),
184
+ commands: nonEmptyStrings(command ? [command] : []),
185
+ blockers: nonEmptyStrings(blockers),
186
+ warnings: nonEmptyStrings(warnings),
187
+ checked_at: timestamp,
188
+ };
189
+ if (status.startsWith("completed")) next.completed_at = timestamp;
190
+ else delete next.completed_at;
191
+
192
+ // Only QA restates a verdict. The doctor stage has no verdict identity and no
193
+ // purchase proof, so it never gains these fields even if a caller passes them.
194
+ if (stage === "qa") {
195
+ const incomingRunId = typeof identity?.verdict_run_id === "string" && identity.verdict_run_id.trim()
196
+ ? identity.verdict_run_id.trim()
197
+ : null;
198
+ const incomingEvidenceSource = evidenceValue(evidence);
199
+ const incomingEvidence = incomingEvidenceSource ? JSON.parse(JSON.stringify(incomingEvidenceSource)) : null;
200
+ const archived = archivePreviousIdentity(previous, { verdict_run_id: incomingRunId, evidence: incomingEvidence });
201
+ for (const field of QA_OWNED_FIELDS) delete next[field];
202
+ if (incomingRunId) next.verdict_run_id = incomingRunId;
203
+ if (incomingEvidence) next.evidence = incomingEvidence;
204
+ if (isPlainObject(proof)) next.purchase_proof = JSON.parse(JSON.stringify(proof));
205
+ if (archived) {
206
+ const priorHistory = Array.isArray(previous.history) ? previous.history.filter(isPlainObject) : [];
207
+ next.history = [...priorHistory, archived].slice(-PRODUCER_STAGE_HISTORY_LIMIT);
208
+ }
209
+ }
210
+ stages[stage] = next;
211
+ updated.stages = stages;
212
+ return updated;
213
+ }
214
+
215
+ // The gates that sit before the ladder, in the order the `next` picker
216
+ // consults them: a blocked prepare-build or a blocked doctor holds every
217
+ // stage behind it (`blockedStage`). Neither is a ladder step — a pending
218
+ // doctor (prepare-build with --no-doctor, or a report written before doctor
219
+ // ran) does not hold the ladder, exactly as the picker does not walk it — but
220
+ // it does hold "done": the report only reads completed once every recorded
221
+ // stage is terminal, and a gate that never recorded an outcome is named
222
+ // (`pendingStage`) once the ladder has nothing left to run.
223
+ const PRE_LADDER_GATES = Object.freeze([
224
+ Object.freeze({ reportKey: "prepare_build", blockedStage: "prepare-build", pendingStage: "prepare-build" }),
225
+ Object.freeze({ reportKey: "doctor", blockedStage: "doctor-blocked", pendingStage: "doctor" }),
226
+ ]);
227
+
228
+ function stageOf(report, key) {
229
+ const stage = report?.stages?.[key];
230
+ return isPlainObject(stage) ? stage : null;
231
+ }
232
+
233
+ function stageBlockers(stage) {
234
+ return Array.isArray(stage?.blockers) ? stage.blockers : [];
235
+ }
236
+
237
+ /**
238
+ * True when any recorded stage on `report` has status "blocked".
239
+ */
240
+ export function anyAssemblyReportStageBlocked(report) {
241
+ return ASSEMBLY_REPORT_STAGE_KEYS.some((key) => stageIsBlocked(stageOf(report, key)?.status));
242
+ }
243
+
244
+ // `ownerKey` is the NEXT_STAGE_OWNERS row to spell the owner from; it differs
245
+ // from `stage` only for a pending doctor, which the picker has no row for
246
+ // (it names doctor-blocked alone) and which the same operator skill owns.
247
+ function nextBlock(stage, action, { ownerKey = stage, ...extras } = {}) {
248
+ const owners = NEXT_STAGE_OWNERS[ownerKey];
249
+ return { stage, owner: owners.default_skill, action, ...extras };
250
+ }
251
+
252
+ /**
253
+ * The Assembly Report's top-level summary, computed from the stage ledger it
254
+ * carries and nothing else. Every write of the report restates it
255
+ * (commitAssemblyReport, and prepare-build's initial write), so the summary
256
+ * can never lag the stages: before this it was written once by prepare-build
257
+ * and a finished ladder still read `status: "prepared"`, `next.stage:
258
+ * "setup"`, `blockers: []` beside a blocked or completed QA stage.
259
+ *
260
+ * - `status`: "blocked" when any recorded stage is blocked; "completed" only
261
+ * when every recorded stage — the pre-ladder gates (prepare_build, doctor)
262
+ * included — is terminal; otherwise "prepared". A freshly prepared report
263
+ * whose doctor never ran therefore never reads completed, whatever the
264
+ * ladder says.
265
+ * - `next`: the first stage that is not terminal, in the order the `next`
266
+ * command walks — a blocked prepare-build ("prepare-build"), a blocked
267
+ * doctor ("doctor-blocked"), then the ladder in NEXT_STAGE_CONTRACTS order.
268
+ * A pending gate does not hold the ladder (the picker does not walk it) but
269
+ * is named once the ladder is exhausted ("doctor" for a doctor that never
270
+ * recorded an outcome), and only when every recorded stage is terminal does
271
+ * `next.stage` read "done". `next.stage` uses the picker's vocabulary (the
272
+ * `next <stage>` argument, so "build" not "assembly"), `next.owner` names
273
+ * the skill that owns the stage, and `next.blocked` is true when the named
274
+ * stage is the one holding the ladder. This is the ledger's own position
275
+ * only: the `next` command additionally folds in live gates (doctor
276
+ * findings, purchase-proof coverage, the polish gate) and stays the
277
+ * authority for what runs next.
278
+ * - `blockers`: the union of the `blockers[]` of every stage currently
279
+ * blocked, in stage order, exact duplicates collapsed. A stage that was
280
+ * blocked and later passed contributes nothing, so a blocker cleared by a
281
+ * re-run leaves the top level with the stage. The entries are the stage's
282
+ * own blocker values, not copies: the summary is computed for a write, and
283
+ * the report is serialized right after.
284
+ *
285
+ * Pure: reads `report`, returns a fresh summary, copies nothing else.
286
+ */
287
+ export function deriveAssemblyReportSummary(report) {
288
+ if (!isPlainObject(report)) throw new TypeError("deriveAssemblyReportSummary requires an Assembly Report object.");
289
+ const blockers = [];
290
+ const seen = new Set();
291
+ for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
292
+ const stage = stageOf(report, key);
293
+ if (!stageIsBlocked(stage?.status)) continue;
294
+ for (const blocker of stageBlockers(stage)) {
295
+ const id = JSON.stringify(canonicalize(blocker));
296
+ if (seen.has(id)) continue;
297
+ seen.add(id);
298
+ blockers.push(blocker);
299
+ }
300
+ }
301
+ const anyBlocked = anyAssemblyReportStageBlocked(report);
302
+
303
+ let next = null;
304
+ for (const gate of PRE_LADDER_GATES) {
305
+ if (!stageIsBlocked(stageOf(report, gate.reportKey)?.status)) continue;
306
+ next = nextBlock(gate.blockedStage, `Stage "${gate.reportKey}" is blocked; resolve its blockers before any stage runs.`, { blocked: true });
307
+ break;
308
+ }
309
+ if (!next) {
310
+ for (const { cliStage, reportKey } of NEXT_STAGE_CONTRACTS) {
311
+ const status = stageOf(report, reportKey)?.status;
312
+ if (stageIsBlocked(status)) {
313
+ next = nextBlock(cliStage, `Stage "${reportKey}" is blocked; unblock it, then run ${cliStage}.`, { blocked: true });
314
+ break;
315
+ }
316
+ if (!stageIsTerminal(status)) {
317
+ next = nextBlock(cliStage, `Run ${cliStage} with this packet.`);
318
+ break;
319
+ }
320
+ }
321
+ }
322
+ if (!next) {
323
+ for (const gate of PRE_LADDER_GATES) {
324
+ if (stageIsTerminal(stageOf(report, gate.reportKey)?.status)) continue;
325
+ next = nextBlock(gate.pendingStage, `Stage "${gate.reportKey}" has not recorded a terminal outcome; run it before treating the report as complete.`, { ownerKey: gate.blockedStage });
326
+ break;
327
+ }
328
+ }
329
+ if (!next) next = nextBlock("done", "Every stage is terminal; run next to confirm the closeout actions.");
330
+
331
+ const status = anyBlocked ? "blocked" : next.stage === "done" ? "completed" : "prepared";
332
+ return { status, next, blockers };
333
+ }
334
+
335
+ /**
336
+ * Restate `report`'s derived summary (`status`, `next`, `blockers`) from its
337
+ * stages, in place, and return it. The report is the caller's own object
338
+ * (the fresh one prepare-build built, or the copy a producer's
339
+ * recordProducerStageOutcome already made), so nothing is cloned here.
340
+ */
341
+ export function applyDerivedAssemblyReportSummary(report) {
342
+ return Object.assign(report, deriveAssemblyReportSummary(report));
343
+ }
344
+
345
+ // QA-owned gate evidence on the qa stage. The QA producer records, beside
346
+ // its verdict identity, the build it ran against and the outcome of gates a
347
+ // static doctor scan can only approximate (`gates.placeholder_text_residue`,
348
+ // from summarizePlaceholderTextGate). Doctor reads it back through
349
+ // qaGatePassedForCurrentBuild: a pass counts only while
350
+ // stages.assembly.build_fingerprint still equals the fingerprint QA saw, so a
351
+ // rebuild silently revokes it. Evidence is a QA-owned field, so the next QA
352
+ // record replaces it wholesale — a stale pass cannot outlive the run that
353
+ // recorded it.
354
+ export const QA_GATE_PLACEHOLDER_TEXT_RESIDUE = "placeholder_text_residue";
355
+
356
+ export function qaGateEvidence(report, gate) {
357
+ const evidence = report?.stages?.qa?.evidence;
358
+ if (!isPlainObject(evidence) || !isPlainObject(evidence.gates)) return null;
359
+ const outcome = evidence.gates[gate];
360
+ if (!isPlainObject(outcome)) return null;
361
+ return {
362
+ status: optionalString(outcome.status),
363
+ source_build_fingerprint: optionalString(evidence.source_build_fingerprint),
364
+ };
365
+ }
366
+
367
+ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint }) {
368
+ const outcome = qaGateEvidence(report, gate);
369
+ const current = optionalString(buildFingerprint);
370
+ return Boolean(outcome && outcome.status === QA_STATUS.PASS && current && outcome.source_build_fingerprint === current);
371
+ }
372
+
373
+ /**
374
+ * True when `report` is this packet's Assembly Report: the identity block
375
+ * names the packet's map id and public route slug (both absent on both sides
376
+ * also matches — a report with no identity belongs to a packet with none).
377
+ */
378
+ export function assemblyReportMatchesPacket(report, packet) {
379
+ return isPlainObject(report)
380
+ && optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id)
381
+ && optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
382
+ }
383
+
384
+ /**
385
+ * One commit of an edit to the Assembly Report a campaign workspace binds,
386
+ * with the retained doctor sidecar kept honest about it in the same step.
387
+ *
388
+ * `workspace` is `resolveCampaignWorkspace(...)`'s result (or any object with
389
+ * `packet`, `reportPath`, `doctorOutPath`, `targetRepo`). The report is read
390
+ * from `reportPath`, `mutate(report)` returns the report to write — or `null`
391
+ * to leave the file's bytes alone (a re-run that restates what is already on
392
+ * disk, so every digest taken of the file stays valid) — and the write is
393
+ * atomic (tmp + rename), so a concurrent reader never sees a torn report.
394
+ * `null` means the same for an operator edit: nothing to write, so nothing
395
+ * to stamp stale — an edit that finds its change already recorded is a
396
+ * no-op, not an error.
397
+ *
398
+ * Exactly one doctor-freshness strategy is named:
399
+ *
400
+ * - `refreshDoctor(outcome)`: the caller recomputes doctor state (or already
401
+ * has it) and the sidecar at `doctorOutPath` is rewritten wholesale from
402
+ * what it returns, stamped `generated_by: command` (#312), which also
403
+ * clears any stale stamp; return `null` to leave the sidecar as it is. It
404
+ * runs whether or not the report was written, because a producer that
405
+ * found nothing new to restate still holds current doctor state.
406
+ * - `staleReason`: the edit changes what doctor would conclude without
407
+ * recomputing it, so the retained sidecar under `targetRepo`, if any, is
408
+ * stamped stale (`stale_marked_by: command`) — only when the report was
409
+ * actually written.
410
+ *
411
+ * `command` is required either way: it is the producer's own name as the
412
+ * sidecar will carry it (`"doctor"`, `"qa run"`, `"theme generate"`), threaded
413
+ * from the caller rather than guessed from argv here.
414
+ *
415
+ * `stage`: a producer (`"doctor"` | `"qa"`) restating its outcome. The write
416
+ * is skipped when the mutated report differs from disk only in that stage's
417
+ * timestamps (`producerStageOutcomeUnchanged`), and the report must be this
418
+ * packet's (`assemblyReportMatchesPacket`) or the edit is skipped — a
419
+ * producer never restates its outcome into another campaign's report, and
420
+ * an absent report is likewise a skip rather than an error, since a producer
421
+ * without a ledger still has a sidecar to keep current. Operator edits
422
+ * (waivers, evidence merges) pass no `stage`: they require the report to
423
+ * exist and bind its identity themselves.
424
+ *
425
+ * Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
426
+ * `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
427
+ * is what is now on disk (the mutated report when written, else the one read,
428
+ * else `null`).
429
+ */
430
+ export function commitAssemblyReport(workspace, mutate, {
431
+ refreshDoctor = null,
432
+ staleReason = null,
433
+ command = null,
434
+ stage = null,
435
+ } = {}) {
436
+ const hasRefresh = typeof refreshDoctor === "function";
437
+ const hasStale = typeof staleReason === "string" && staleReason.trim();
438
+ if (hasRefresh === Boolean(hasStale)) {
439
+ throw new TypeError("commitAssemblyReport requires exactly one of refreshDoctor (a function) or staleReason (a string).");
440
+ }
441
+ if (!optionalString(command)) {
442
+ throw new TypeError("commitAssemblyReport requires command: the doctor sidecar names the command that produced it (generated_by) or stamped it stale (stale_marked_by).");
443
+ }
444
+ if (stage !== null && !PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
445
+ if (typeof mutate !== "function") throw new TypeError("commitAssemblyReport requires a mutate(report) function.");
446
+ const reportPath = optionalString(workspace?.reportPath);
447
+ const doctorOutPath = optionalString(workspace?.doctorOutPath);
448
+ const targetRepo = optionalString(workspace?.targetRepo);
449
+ if (!reportPath) throw new TypeError("commitAssemblyReport requires a workspace with reportPath.");
450
+ if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
451
+ if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
452
+
453
+ const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
454
+ const finish = () => {
455
+ if (hasRefresh) {
456
+ const doctor = refreshDoctor(outcome);
457
+ if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
458
+ } else if (outcome.written) {
459
+ markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
460
+ }
461
+ return outcome;
462
+ };
463
+
464
+ if (!existsSync(reportPath)) {
465
+ if (stage) {
466
+ outcome.skipped = "absent";
467
+ return finish();
468
+ }
469
+ throw new Error(`Assembly Report not found at ${reportPath}; run prepare-build/start first.`);
470
+ }
471
+ // A torn or hand-edited report fails by name: the raw SyntaxError names
472
+ // neither the file nor the command, and every caller's read of the report
473
+ // (waivers, the polish merge, the producer stage records) goes through
474
+ // here. Only the parse is caught; a read failure (EACCES, EISDIR) is not
475
+ // a malformed report and propagates as itself.
476
+ const raw = readFileSync(reportPath, "utf8");
477
+ let report;
478
+ try {
479
+ report = JSON.parse(raw);
480
+ } catch (error) {
481
+ throw new Error(`Assembly Report at ${reportPath} is not valid JSON: ${error.message}`);
482
+ }
483
+ outcome.report = report;
484
+ if (stage && !assemblyReportMatchesPacket(report, workspace?.packet)) {
485
+ outcome.skipped = "identity";
486
+ return finish();
487
+ }
488
+ const mutated = mutate(report);
489
+ if (mutated === null || mutated === undefined) {
490
+ outcome.skipped = "unchanged";
491
+ return finish();
492
+ }
493
+ if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object, null, or undefined.");
494
+ // The summary is restated on every write, so a report whose top level lags
495
+ // its stages (written before the summary was derived) heals on the next
496
+ // commit; after that the restatement is a no-op and the unchanged check
497
+ // below keeps the file's bytes alone. `mutated` is the mutator's own object
498
+ // (every mutator in this repo returns a copy), so the restatement is in
499
+ // place rather than a second deep clone.
500
+ const next = applyDerivedAssemblyReportSummary(mutated);
501
+ if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
502
+ outcome.skipped = "unchanged";
503
+ return finish();
504
+ }
505
+ writeJsonAtomic(reportPath, next);
506
+ outcome.written = true;
507
+ outcome.report = next;
508
+ return finish();
509
+ }