@1aboveio/skills 0.10.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 (397) hide show
  1. package/LICENSE +3 -0
  2. package/README.md +90 -0
  3. package/bin/1aboveio-skills.mjs +18 -0
  4. package/package.json +28 -0
  5. package/runtime/skills/distribution/generated/recipes.json +1189 -0
  6. package/runtime/skills/distribution/scripts/bundles.mjs +280 -0
  7. package/runtime/skills/engineering/engineering-runtime/scripts/main-module.mjs +80 -0
  8. package/skills/backend/airflow-dag-develop/LICENSE +3 -0
  9. package/skills/backend/airflow-dag-develop/SKILL.md +111 -0
  10. package/skills/backend/app-debug/LICENSE +3 -0
  11. package/skills/backend/app-debug/SKILL.md +109 -0
  12. package/skills/backend/app-debug/references/common-errors.md +128 -0
  13. package/skills/backend/python-backend/LICENSE +3 -0
  14. package/skills/backend/python-backend/SKILL.md +326 -0
  15. package/skills/cicd-pipeline/cloud-build/LICENSE +3 -0
  16. package/skills/cicd-pipeline/cloud-build/SKILL.md +707 -0
  17. package/skills/cicd-pipeline/cloud-debug/LICENSE +3 -0
  18. package/skills/cicd-pipeline/cloud-debug/SKILL.md +316 -0
  19. package/skills/cicd-pipeline/cloud-debug/references/build-failures.md +238 -0
  20. package/skills/cicd-pipeline/cloud-debug/references/deploy-failures.md +376 -0
  21. package/skills/cicd-pipeline/cloud-debug/references/pipeline-failures.md +378 -0
  22. package/skills/cicd-pipeline/cloud-deploy/LICENSE +3 -0
  23. package/skills/cicd-pipeline/cloud-deploy/SKILL.md +229 -0
  24. package/skills/cicd-pipeline/cloud-deploy/references/config-templates.md +257 -0
  25. package/skills/cicd-pipeline/docker/LICENSE +3 -0
  26. package/skills/cicd-pipeline/docker/SKILL.md +126 -0
  27. package/skills/cicd-pipeline/google-cloud/LICENSE +3 -0
  28. package/skills/cicd-pipeline/google-cloud/SKILL.md +118 -0
  29. package/skills/cicd-pipeline/google-cloud/references/gcs.md +469 -0
  30. package/skills/cicd-pipeline/google-cloud/references/iam.md +451 -0
  31. package/skills/cicd-pipeline/google-cloud/references/project.md +349 -0
  32. package/skills/cicd-pipeline/google-cloud/references/secrets.md +336 -0
  33. package/skills/cicd-pipeline/google-cloud/references/vpc.md +312 -0
  34. package/skills/cicd-pipeline/google-cloud/scripts/create-sa.sh +36 -0
  35. package/skills/cicd-pipeline/google-cloud/scripts/gcp-config.sh +31 -0
  36. package/skills/cicd-pipeline/google-cloud/scripts/grant-iap.sh +41 -0
  37. package/skills/cicd-pipeline/google-cloud/scripts/setup-secrets.sh +48 -0
  38. package/skills/cicd-pipeline/mergify/LICENSE +3 -0
  39. package/skills/cicd-pipeline/mergify/SKILL.md +138 -0
  40. package/skills/cicd-pipeline/mergify/assets/templates/mergify.yml +237 -0
  41. package/skills/cicd-pipeline/mergify/assets/templates/ruleset.json +46 -0
  42. package/skills/cicd-pipeline/mergify/references/branch-protection.md +277 -0
  43. package/skills/cicd-pipeline/mergify/references/configuration.md +183 -0
  44. package/skills/cicd-pipeline/mergify/references/diagnosis.md +73 -0
  45. package/skills/cicd-pipeline/mergify/references/traps.md +78 -0
  46. package/skills/cicd-pipeline/mergify/references/watch-contract.md +218 -0
  47. package/skills/cicd-pipeline/mergify/scripts/audit-core.mjs +131 -0
  48. package/skills/cicd-pipeline/mergify/scripts/audit.mjs +4 -0
  49. package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery-core.mjs +663 -0
  50. package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery.mjs +4 -0
  51. package/skills/cicd-pipeline/podman/LICENSE +3 -0
  52. package/skills/cicd-pipeline/podman/SKILL.md +70 -0
  53. package/skills/cicd-pipeline/podman/agents/openai.yaml +4 -0
  54. package/skills/cicd-pipeline/podman/assets/templates/podman-compose-socket-directory.yml +6 -0
  55. package/skills/cicd-pipeline/podman/assets/templates/podman-service-override.conf +3 -0
  56. package/skills/cicd-pipeline/podman/references/compose-compatibility.md +70 -0
  57. package/skills/cicd-pipeline/podman/references/networking-and-ports.md +74 -0
  58. package/skills/cicd-pipeline/podman/references/rootless-services-and-sockets.md +156 -0
  59. package/skills/cicd-pipeline/podman/references/troubleshooting.md +98 -0
  60. package/skills/engineering/e2e-test/LICENSE +3 -0
  61. package/skills/engineering/e2e-test/SKILL.md +156 -0
  62. package/skills/engineering/e2e-test/assets/ci-gates.cloudbuild.yaml +272 -0
  63. package/skills/engineering/e2e-test/assets/ci-gates.github.yml +451 -0
  64. package/skills/engineering/e2e-test/assets/e2e-workflow.yml +282 -0
  65. package/skills/engineering/e2e-test/references/authoring/auth-flows.md +159 -0
  66. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +71 -0
  67. package/skills/engineering/e2e-test/references/authoring/playwright-patterns.md +219 -0
  68. package/skills/engineering/e2e-test/references/authoring/test-skipping.md +44 -0
  69. package/skills/engineering/e2e-test/references/ci-integration.md +121 -0
  70. package/skills/engineering/e2e-test/references/ci-playwright-container.md +280 -0
  71. package/skills/engineering/e2e-test/references/debugging.md +36 -0
  72. package/skills/engineering/e2e-test/references/presentation-sweep.md +131 -0
  73. package/skills/engineering/e2e-test/references/reviewing.md +39 -0
  74. package/skills/engineering/e2e-test/references/route-discovery.md +50 -0
  75. package/skills/engineering/e2e-test/references/route-manifest.md +44 -0
  76. package/skills/engineering/e2e-test/scripts/detect-routes-fastapi.py +290 -0
  77. package/skills/engineering/e2e-test/scripts/detect-routes-nextjs.mjs +200 -0
  78. package/skills/engineering/e2e-test/scripts/post-visual-evidence.mjs +158 -0
  79. package/skills/engineering/e2e-test/scripts/presentation-checks.mjs +171 -0
  80. package/skills/engineering/e2e-test/scripts/presentation-perceivability.mjs +179 -0
  81. package/skills/engineering/e2e-test/scripts/presentation-reachability.mjs +154 -0
  82. package/skills/engineering/e2e-test/scripts/presentation-render-health.mjs +141 -0
  83. package/skills/engineering/e2e-test/scripts/presentation-sweep.mjs +148 -0
  84. package/skills/engineering/e2e-test/scripts/presentation-temporal.mjs +127 -0
  85. package/skills/engineering/e2e-test/scripts/presentation-visual.mjs +84 -0
  86. package/skills/engineering/e2e-test/scripts/project-route-manifest.mjs +75 -0
  87. package/skills/engineering/e2e-test/scripts/validate-manifest.mjs +106 -0
  88. package/skills/engineering/engineering-runtime/LICENSE +3 -0
  89. package/skills/engineering/engineering-runtime/SKILL.md +10 -0
  90. package/skills/engineering/engineering-runtime/agents/openai.yaml +6 -0
  91. package/skills/engineering/engineering-runtime/coherence/workflow.json +553 -0
  92. package/skills/engineering/engineering-runtime/scripts/exact-head-artifact.mjs +131 -0
  93. package/skills/engineering/engineering-runtime/scripts/head-check-set.mjs +398 -0
  94. package/skills/engineering/engineering-runtime/scripts/main-module.mjs +80 -0
  95. package/skills/engineering/engineering-runtime/scripts/mergify-yaml.mjs +11 -0
  96. package/skills/engineering/engineering-runtime/scripts/package-lock.json +43 -0
  97. package/skills/engineering/engineering-runtime/scripts/package.json +10 -0
  98. package/skills/engineering/engineering-runtime/scripts/required-check-plan.mjs +223 -0
  99. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +576 -0
  100. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +166 -0
  101. package/skills/engineering/ensure-coverage/LICENSE +3 -0
  102. package/skills/engineering/ensure-coverage/SKILL.md +136 -0
  103. package/skills/engineering/ensure-coverage/evals/evals.json +125 -0
  104. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +91 -0
  105. package/skills/engineering/ensure-coverage/references/breadth/inventory-contract.md +83 -0
  106. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +44 -0
  107. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +16 -0
  108. package/skills/engineering/ensure-coverage/references/depth/characterization.md +68 -0
  109. package/skills/engineering/ensure-coverage/references/depth/coverage.config.example.json +25 -0
  110. package/skills/engineering/ensure-coverage/references/depth/grading.md +35 -0
  111. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +87 -0
  112. package/skills/engineering/ensure-coverage/references/depth/test-smells.md +23 -0
  113. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +164 -0
  114. package/skills/engineering/ensure-coverage/references/enforcement/hooks.md +85 -0
  115. package/skills/engineering/ensure-coverage/references/examples/coverage-ledger.md +109 -0
  116. package/skills/engineering/ensure-coverage/references/examples/refund-flow.md +33 -0
  117. package/skills/engineering/ensure-coverage/references/presentation/axis.md +78 -0
  118. package/skills/engineering/ensure-coverage/references/presentation/runner-contract.md +74 -0
  119. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +33 -0
  120. package/skills/engineering/ensure-coverage/references/process/output-template.md +139 -0
  121. package/skills/engineering/ensure-coverage/references/process/review-contract-template.md +119 -0
  122. package/skills/engineering/ensure-coverage/references/process/scope-class.md +178 -0
  123. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +55 -0
  124. package/skills/engineering/ensure-coverage/schemas/coverage-config.schema.json +45 -0
  125. package/skills/engineering/ensure-coverage/schemas/coverage-file.schema.json +93 -0
  126. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.mjs +178 -0
  127. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.mjs +101 -0
  128. package/skills/engineering/ensure-coverage/scripts/ci-audit.mjs +358 -0
  129. package/skills/engineering/ensure-coverage/scripts/coverage-checklist.mjs +494 -0
  130. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.mjs +663 -0
  131. package/skills/engineering/ensure-coverage/scripts/design-parity.mjs +591 -0
  132. package/skills/engineering/ensure-coverage/scripts/evidence-block.mjs +367 -0
  133. package/skills/engineering/ensure-coverage/scripts/lint-tests.mjs +269 -0
  134. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.mjs +176 -0
  135. package/skills/engineering/ensure-coverage/scripts/package-lock.json +76 -0
  136. package/skills/engineering/ensure-coverage/scripts/package.json +19 -0
  137. package/skills/engineering/ensure-coverage/scripts/scope-class.mjs +554 -0
  138. package/skills/engineering/harness-runtime/LICENSE +3 -0
  139. package/skills/engineering/harness-runtime/SKILL.md +18 -0
  140. package/skills/engineering/harness-runtime/agents/openai.yaml +6 -0
  141. package/skills/engineering/harness-runtime/bin/discover-models.mjs +4 -0
  142. package/skills/engineering/harness-runtime/bin/model-catalog.mjs +4 -0
  143. package/skills/engineering/harness-runtime/contracts.md +15 -0
  144. package/skills/engineering/harness-runtime/discover-models.mjs +392 -0
  145. package/skills/engineering/harness-runtime/fixtures/native-question-schemas.json +33 -0
  146. package/skills/engineering/harness-runtime/fixtures/question-responses.json +54 -0
  147. package/skills/engineering/harness-runtime/index.mjs +767 -0
  148. package/skills/engineering/harness-runtime/model-catalog.mjs +787 -0
  149. package/skills/engineering/harness-runtime/native-question-contracts.md +37 -0
  150. package/skills/engineering/harness-runtime/references/model-catalog-seed.json +159 -0
  151. package/skills/engineering/harness-runtime/references/model-catalog.md +57 -0
  152. package/skills/engineering/implement-and-pr/LICENSE +3 -0
  153. package/skills/engineering/implement-and-pr/SKILL.md +176 -0
  154. package/skills/engineering/implement-and-pr/references/ci-iteration.md +10 -0
  155. package/skills/engineering/implement-and-pr/references/closeout.md +27 -0
  156. package/skills/engineering/implement-and-pr/references/contract-complete-fix-rounds.md +34 -0
  157. package/skills/engineering/implement-and-pr/references/evidence-rules.md +39 -0
  158. package/skills/engineering/implement-and-pr/references/incremental-plan.md +16 -0
  159. package/skills/engineering/implement-and-pr/references/self-review.md +23 -0
  160. package/skills/engineering/implement-and-pr/references/tdd-mode.md +18 -0
  161. package/skills/engineering/resolve-issues/LICENSE +3 -0
  162. package/skills/engineering/resolve-issues/SKILL.md +167 -0
  163. package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +448 -0
  164. package/skills/engineering/resolve-issues/references/breaker.md +82 -0
  165. package/skills/engineering/resolve-issues/references/deliverables.md +27 -0
  166. package/skills/engineering/resolve-issues/references/delivery.md +108 -0
  167. package/skills/engineering/resolve-issues/references/evidence-lane.md +21 -0
  168. package/skills/engineering/resolve-issues/references/exact-head-ci.md +287 -0
  169. package/skills/engineering/resolve-issues/references/fan-out.md +33 -0
  170. package/skills/engineering/resolve-issues/references/finalization.md +68 -0
  171. package/skills/engineering/resolve-issues/references/guarantees.md +10 -0
  172. package/skills/engineering/resolve-issues/references/high-risk.md +29 -0
  173. package/skills/engineering/resolve-issues/references/incidents/848/README.md +156 -0
  174. package/skills/engineering/resolve-issues/references/intake.md +86 -0
  175. package/skills/engineering/resolve-issues/references/integration-gate.md +53 -0
  176. package/skills/engineering/resolve-issues/references/interference.md +87 -0
  177. package/skills/engineering/resolve-issues/references/loop.md +134 -0
  178. package/skills/engineering/resolve-issues/references/model-catalog.md +9 -0
  179. package/skills/engineering/resolve-issues/references/postmortem.md +27 -0
  180. package/skills/engineering/resolve-issues/references/pre-flight-model-slots.md +41 -0
  181. package/skills/engineering/resolve-issues/references/pre-flight-recording-and-checkout.md +48 -0
  182. package/skills/engineering/resolve-issues/references/pre-flight.md +41 -0
  183. package/skills/engineering/resolve-issues/references/regression-checklist.md +26 -0
  184. package/skills/engineering/resolve-issues/references/run-state.md +288 -0
  185. package/skills/engineering/resolve-issues/references/sandboxed-testing.md +48 -0
  186. package/skills/engineering/resolve-issues/references/spawn-contract.md +96 -0
  187. package/skills/engineering/resolve-issues/references/terminal-evidence-journal.md +40 -0
  188. package/skills/engineering/resolve-issues/references/why.md +653 -0
  189. package/skills/engineering/resolve-issues/schemas/fix-round.schema.json +49 -0
  190. package/skills/engineering/resolve-issues/scripts/combine-and-verify.mjs +721 -0
  191. package/skills/engineering/resolve-issues/scripts/component-candidate.mjs +962 -0
  192. package/skills/engineering/resolve-issues/scripts/contract-revision.mjs +220 -0
  193. package/skills/engineering/resolve-issues/scripts/detect-delivery-mode.mjs +420 -0
  194. package/skills/engineering/resolve-issues/scripts/detect-target-branch.mjs +256 -0
  195. package/skills/engineering/resolve-issues/scripts/detect-workspace-mode.mjs +168 -0
  196. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +9 -0
  197. package/skills/engineering/resolve-issues/scripts/doctrine.mjs +62 -0
  198. package/skills/engineering/resolve-issues/scripts/evidence-lifecycle-contract.mjs +191 -0
  199. package/skills/engineering/resolve-issues/scripts/exact-head-ci.mjs +413 -0
  200. package/skills/engineering/resolve-issues/scripts/exact-head-github-provider.mjs +332 -0
  201. package/skills/engineering/resolve-issues/scripts/finalize.mjs +488 -0
  202. package/skills/engineering/resolve-issues/scripts/fix-rounds.mjs +3307 -0
  203. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-circular-1001.json +16 -0
  204. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-valid-sequencing.json +51 -0
  205. package/skills/engineering/resolve-issues/scripts/fixtures/fmm-express-830-component-candidate.json +17 -0
  206. package/skills/engineering/resolve-issues/scripts/fixtures/head-check-set-1081.json +166 -0
  207. package/skills/engineering/resolve-issues/scripts/gate-value-series.mjs +92 -0
  208. package/skills/engineering/resolve-issues/scripts/guide-index.mjs +73 -0
  209. package/skills/engineering/resolve-issues/scripts/head-check-set.mjs +159 -0
  210. package/skills/engineering/resolve-issues/scripts/interference.mjs +427 -0
  211. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +9 -0
  212. package/skills/engineering/resolve-issues/scripts/next-operations.mjs +419 -0
  213. package/skills/engineering/resolve-issues/scripts/postmortem.mjs +909 -0
  214. package/skills/engineering/resolve-issues/scripts/preflight-questions.mjs +322 -0
  215. package/skills/engineering/resolve-issues/scripts/reconcile-contained-unit-prs.mjs +415 -0
  216. package/skills/engineering/resolve-issues/scripts/release-state-contract.mjs +697 -0
  217. package/skills/engineering/resolve-issues/scripts/report.mjs +494 -0
  218. package/skills/engineering/resolve-issues/scripts/required-check-plan.mjs +172 -0
  219. package/skills/engineering/resolve-issues/scripts/round-metadata.mjs +79 -0
  220. package/skills/engineering/resolve-issues/scripts/run-state-review6-cases.mjs +334 -0
  221. package/skills/engineering/resolve-issues/scripts/run-state.mjs +4784 -0
  222. package/skills/engineering/resolve-issues/scripts/sandbox-selftest.mjs +395 -0
  223. package/skills/engineering/resolve-issues/scripts/spawn-contract.mjs +290 -0
  224. package/skills/engineering/resolve-issues/scripts/terminal-dispositions.mjs +170 -0
  225. package/skills/engineering/resolve-issues/scripts/terminal-evidence-journal.mjs +293 -0
  226. package/skills/engineering/resolve-issues/scripts/unit-kind.mjs +197 -0
  227. package/skills/engineering/resolve-issues/scripts/unit-lifecycle.mjs +127 -0
  228. package/skills/engineering/resolve-issues/scripts/watch-delivery.mjs +893 -0
  229. package/skills/engineering/resolve-issues/scripts/workspaces.mjs +829 -0
  230. package/skills/engineering/resolve-issues/workflows/independent-review.workflow.js +290 -0
  231. package/skills/engineering/resolve-issues/workflows/prior-art-scan.workflow.js +80 -0
  232. package/skills/engineering/resolve-issues/workflows/workflow-smoke.mjs +102 -0
  233. package/skills/engineering/resolve-release/LICENSE +3 -0
  234. package/skills/engineering/resolve-release/SKILL.md +112 -0
  235. package/skills/engineering/resolve-release/references/assembly.md +137 -0
  236. package/skills/engineering/resolve-release/references/auto-when-green.md +56 -0
  237. package/skills/engineering/resolve-release/references/candidate.md +167 -0
  238. package/skills/engineering/resolve-release/references/exposure.md +178 -0
  239. package/skills/engineering/resolve-release/references/handoff.md +24 -0
  240. package/skills/engineering/resolve-release/references/postmortem.md +230 -0
  241. package/skills/engineering/resolve-release/references/preflight.md +207 -0
  242. package/skills/engineering/resolve-release/references/principles.md +94 -0
  243. package/skills/engineering/resolve-release/references/regression-checklist.md +36 -0
  244. package/skills/engineering/resolve-release/references/related-skills.md +12 -0
  245. package/skills/engineering/resolve-release/references/routing.md +149 -0
  246. package/skills/engineering/resolve-release/references/verified-sha-github-flow.md +285 -0
  247. package/skills/engineering/resolve-release/references/versioning.md +202 -0
  248. package/skills/engineering/resolve-release/references/why.md +53 -0
  249. package/skills/engineering/resolve-release/scripts/adapter-completion-artifact.mjs +389 -0
  250. package/skills/engineering/resolve-release/scripts/build-changes.mjs +209 -0
  251. package/skills/engineering/resolve-release/scripts/candidate-hygiene.mjs +407 -0
  252. package/skills/engineering/resolve-release/scripts/candidate-identity.mjs +904 -0
  253. package/skills/engineering/resolve-release/scripts/candidate-traffic.mjs +81 -0
  254. package/skills/engineering/resolve-release/scripts/checked-adapter-loader.mjs +612 -0
  255. package/skills/engineering/resolve-release/scripts/close-attempt.mjs +135 -0
  256. package/skills/engineering/resolve-release/scripts/closeout-release.mjs +161 -0
  257. package/skills/engineering/resolve-release/scripts/doctrine.mjs +106 -0
  258. package/skills/engineering/resolve-release/scripts/durable-processing.mjs +522 -0
  259. package/skills/engineering/resolve-release/scripts/ensure-target-green.mjs +659 -0
  260. package/skills/engineering/resolve-release/scripts/evidence-bundle.mjs +1014 -0
  261. package/skills/engineering/resolve-release/scripts/finalize-release.mjs +526 -0
  262. package/skills/engineering/resolve-release/scripts/fixtures/durable-processing-adapter.mjs +169 -0
  263. package/skills/engineering/resolve-release/scripts/green-gate.mjs +599 -0
  264. package/skills/engineering/resolve-release/scripts/isolated-adapter-evaluator.mjs +752 -0
  265. package/skills/engineering/resolve-release/scripts/metadata-pr-status.mjs +56 -0
  266. package/skills/engineering/resolve-release/scripts/metadata-sync.mjs +1538 -0
  267. package/skills/engineering/resolve-release/scripts/postmortem.mjs +381 -0
  268. package/skills/engineering/resolve-release/scripts/preflight-probes.mjs +498 -0
  269. package/skills/engineering/resolve-release/scripts/production-endpoints.mjs +326 -0
  270. package/skills/engineering/resolve-release/scripts/rc-circuit-breaker.mjs +272 -0
  271. package/skills/engineering/resolve-release/scripts/report.mjs +417 -0
  272. package/skills/engineering/resolve-release/scripts/reprobe-credentials.mjs +114 -0
  273. package/skills/engineering/resolve-release/scripts/revalidate-candidate.mjs +238 -0
  274. package/skills/engineering/resolve-release/scripts/review-packet.mjs +503 -0
  275. package/skills/engineering/resolve-release/scripts/rollback-floor.mjs +263 -0
  276. package/skills/engineering/resolve-release/scripts/version-assert.mjs +339 -0
  277. package/skills/engineering/resolve-release/scripts/version-postmortem.mjs +485 -0
  278. package/skills/engineering/resolve-release/scripts/version.mjs +1199 -0
  279. package/skills/engineering/resolve-release/scripts/watch-candidate-delivery.mjs +449 -0
  280. package/skills/engineering/resolve-release/vendor/ACORN-LICENSE +21 -0
  281. package/skills/engineering/resolve-release/vendor/README.md +60 -0
  282. package/skills/engineering/resolve-release/vendor/acorn.mjs +6233 -0
  283. package/skills/engineering/resolve-release/vendor/quickjs/core/LICENSE +21 -0
  284. package/skills/engineering/resolve-release/vendor/quickjs/core/README.md +341 -0
  285. package/skills/engineering/resolve-release/vendor/quickjs/core/README.template.md +70 -0
  286. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs +2 -0
  287. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs.map +1 -0
  288. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs +7 -0
  289. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
  290. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.mts +2033 -0
  291. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.ts +2033 -0
  292. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js +7 -0
  293. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js.map +1 -0
  294. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs +2 -0
  295. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs.map +1 -0
  296. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs +2 -0
  297. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs.map +1 -0
  298. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs +2 -0
  299. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
  300. package/skills/engineering/resolve-release/vendor/quickjs/core/package.json +49 -0
  301. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/LICENSE +21 -0
  302. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/README.md +5 -0
  303. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.mts +549 -0
  304. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.ts +549 -0
  305. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js +2 -0
  306. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js.map +1 -0
  307. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs +2 -0
  308. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs.map +1 -0
  309. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/package.json +36 -0
  310. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/LICENSE +47 -0
  311. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/README.md +82 -0
  312. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.d.ts +11 -0
  313. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.mjs +22 -0
  314. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cjs +21 -0
  315. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
  316. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  317. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.d.ts +11 -0
  318. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.mjs +25 -0
  319. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.wasm +0 -0
  320. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.mts +85 -0
  321. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.ts +85 -0
  322. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js +2 -0
  323. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js.map +1 -0
  324. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs +2 -0
  325. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs.map +1 -0
  326. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.mts +20 -0
  327. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.ts +20 -0
  328. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js +2 -0
  329. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js.map +1 -0
  330. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs +2 -0
  331. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs.map +1 -0
  332. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/package.json +61 -0
  333. package/skills/engineering/review-pr/LICENSE +3 -0
  334. package/skills/engineering/review-pr/SKILL.md +123 -0
  335. package/skills/engineering/review-pr/references/adversarial-reviewer-prompt.md +42 -0
  336. package/skills/engineering/review-pr/references/code-correctness.md +5 -0
  337. package/skills/engineering/review-pr/references/contract-freshness.md +9 -0
  338. package/skills/engineering/review-pr/references/coordination.md +18 -0
  339. package/skills/engineering/review-pr/references/domain-hazards.md +123 -0
  340. package/skills/engineering/review-pr/references/finding-themes.md +7 -0
  341. package/skills/engineering/review-pr/references/github-posting.md +98 -0
  342. package/skills/engineering/review-pr/references/golden-path-smoke.md +5 -0
  343. package/skills/engineering/review-pr/references/incremental-output.md +16 -0
  344. package/skills/engineering/review-pr/references/inputs-and-discovery.md +31 -0
  345. package/skills/engineering/review-pr/references/output-format.md +99 -0
  346. package/skills/engineering/review-pr/references/over-mock-screen.md +7 -0
  347. package/skills/engineering/review-pr/references/promotion-prs.md +16 -0
  348. package/skills/engineering/review-pr/references/re-review.md +18 -0
  349. package/skills/engineering/review-pr/references/review-method.md +199 -0
  350. package/skills/engineering/review-pr/references/review-mode.md +30 -0
  351. package/skills/engineering/review-pr/references/review-posture.md +53 -0
  352. package/skills/engineering/review-pr/references/round1-depth.md +62 -0
  353. package/skills/engineering/review-pr/references/scripts.md +17 -0
  354. package/skills/engineering/review-pr/references/workflow.md +16 -0
  355. package/skills/engineering/review-pr/schemas/findings.schema.json +282 -0
  356. package/skills/engineering/review-pr/scripts/finding-contract.mjs +285 -0
  357. package/skills/engineering/review-pr/scripts/post-review.mjs +405 -0
  358. package/skills/engineering/review-pr/scripts/pr-context.mjs +207 -0
  359. package/skills/engineering/review-pr/scripts/scan-diff.mjs +365 -0
  360. package/skills/engineering/review-pr/scripts/theme-contract.mjs +57 -0
  361. package/skills/engineering/smoke/LICENSE +3 -0
  362. package/skills/engineering/smoke/SKILL.md +131 -0
  363. package/skills/engineering/smoke/assets/smoke.manifest.example.json +53 -0
  364. package/skills/engineering/smoke/references/manifest.md +192 -0
  365. package/skills/engineering/smoke/scripts/smoke.mjs +713 -0
  366. package/skills/fullstack/better-auth/LICENSE +3 -0
  367. package/skills/fullstack/better-auth/SKILL.md +601 -0
  368. package/skills/fullstack/better-auth/references/feishu-api.md +270 -0
  369. package/skills/fullstack/monorepo/LICENSE +3 -0
  370. package/skills/fullstack/monorepo/SKILL.md +465 -0
  371. package/skills/fullstack/nextjs-fullstack/LICENSE +3 -0
  372. package/skills/fullstack/nextjs-fullstack/SKILL.md +210 -0
  373. package/skills/fullstack/nextjs-fullstack/conventions.md +318 -0
  374. package/skills/fullstack/nextjs-fullstack/frontend-conventions.md +61 -0
  375. package/skills/fullstack/nextjs-fullstack/nextjs16.md +287 -0
  376. package/skills/fullstack/nextjs-fullstack/server-actions.md +409 -0
  377. package/skills/fullstack/prisma-setup/LICENSE +3 -0
  378. package/skills/fullstack/prisma-setup/SKILL.md +180 -0
  379. package/skills/fullstack/prisma-setup/nextjs.md +258 -0
  380. package/skills/fullstack/prisma-setup/turborepo.md +301 -0
  381. package/skills/fullstack/shadcn/LICENSE +3 -0
  382. package/skills/fullstack/shadcn/SKILL.md +119 -0
  383. package/skills/fullstack/shadcn/assets/shadcn-small.png +0 -0
  384. package/skills/fullstack/shadcn/assets/shadcn.png +0 -0
  385. package/skills/fullstack/shadcn/cli.md +411 -0
  386. package/skills/fullstack/shadcn/customization.md +224 -0
  387. package/skills/fullstack/shadcn/evals/evals.json +90 -0
  388. package/skills/fullstack/shadcn/mcp.md +101 -0
  389. package/skills/fullstack/shadcn/rules/base-vs-radix.md +323 -0
  390. package/skills/fullstack/shadcn/rules/component-selection.md +67 -0
  391. package/skills/fullstack/shadcn/rules/composition.md +195 -0
  392. package/skills/fullstack/shadcn/rules/data-table.md +201 -0
  393. package/skills/fullstack/shadcn/rules/forms.md +255 -0
  394. package/skills/fullstack/shadcn/rules/icons.md +103 -0
  395. package/skills/fullstack/shadcn/rules/styling.md +167 -0
  396. package/skills/fullstack/zod-v4/LICENSE +3 -0
  397. package/skills/fullstack/zod-v4/SKILL.md +287 -0
@@ -0,0 +1,3307 @@
1
+ // Contract-complete fix/review state transitions.
2
+ //
3
+ // The functions in this module are pure. Every transition clones the manifest,
4
+ // validates its old state, builds a candidate, validates the complete candidate,
5
+ // and only then returns it. The run-state CLI is therefore unable to persist a
6
+ // state that this module would reject on read-back.
7
+
8
+ // Deliberate cross-skill import: this skill ships as a sibling of `review-pr`
9
+ // in the symlink-farm distribution model, never standalone. Testing this file
10
+ // from a bare copy (e.g. for mutation testing) needs `review-pr` symlinked
11
+ // beside the copy — see references/sandboxed-testing.md.
12
+ import {
13
+ stateSpaceCells,
14
+ validateTheme,
15
+ CONTRACT_COMPLETE_KINDS,
16
+ } from '../../review-pr/scripts/theme-contract.mjs'
17
+ import {
18
+ REVIEW_VERDICTS,
19
+ validateFinding,
20
+ validateReviewDocument,
21
+ migrateLegacyFinding,
22
+ findingWarnings,
23
+ } from '../../review-pr/scripts/finding-contract.mjs'
24
+ import {
25
+ CHANGED_SURFACES,
26
+ LEGACY_CHANGED_SURFACE,
27
+ assertChangedSurfacesMatch,
28
+ normalizeChangedSurfaces,
29
+ } from './round-metadata.mjs'
30
+ import { terminalDisposition } from './terminal-dispositions.mjs'
31
+ import { completionBlockers, effectiveUnitKind } from './unit-lifecycle.mjs'
32
+
33
+ export { stateSpaceCells, validateTheme, CONTRACT_COMPLETE_KINDS }
34
+
35
+ const clone = (value) => structuredClone(value)
36
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
37
+ const nonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0
38
+ const LOCATOR_RE = /(?:^|\/)\S+:\d+(?:$|\s)|^\S+:\d+$|`[^`]+`|https?:\/\/|#\d+/i
39
+ const isLocator = (value) => nonEmptyString(value) && LOCATOR_RE.test(value)
40
+ const ROUND1_DEPTH_OBLIGATIONS = ['liveEndpointProbing', 'sharedConsumerImpact']
41
+ // The round circuit-breaker (SKILL.md "Round circuit-breaker"). The founding
42
+ // incident is epic-848's unit #848 — nine review rounds without converging, a
43
+ // round-6 PASS reversed by later rounds; it is why the threshold was set to 4
44
+ // then lowered to 3 (distilled record:
45
+ // ../references/incidents/848/README.md). Prose alone did not hold: issue #936
46
+ // then ran seven review rounds over ~6.4 hours with no breaker state ever
47
+ // written, because nothing refused the round-4 transition. It is the ONLY gate
48
+ // that blocks a fix/review transition: a required human decision stops the loop.
49
+ export const BREAKER_ROUND_LIMIT = 3
50
+ export const BREAKER_DECISIONS = ['design', 'split', 'descope', 'continue']
51
+ // `'produce-passes'` is retained here as a READ-ONLY legacy value. A produce-pass
52
+ // circuit-breaker (issue #382) once fired independently of the review-round threshold and
53
+ // stamped `breaker.firedOn: 'produce-passes'` on historical manifests. It was removed: it
54
+ // counted producer/fix spawns AT SPAWN TIME (`record-produce-pass` "in the same turn you
55
+ // spawn it"), so a spawn that died with zero code — no completed review — still advanced
56
+ // the count and could false-fire "the contract keeps changing" before any review ran.
57
+ // That contradicted spawn-contract.md's own rule that a partial counts exactly as a
58
+ // missing one for every gate. Its #376 capability (fire before any review runs) is a
59
+ // deliberately accepted loss. The surviving breaker counts completed review rounds only,
60
+ // so a dead spawn can never inflate it. The value stays accepted so legacy manifests still
61
+ // validate on read-back; the code NEVER writes it — every new firing is `'review-rounds'`.
62
+ export const BREAKER_FIRED_ON = ['review-rounds', 'produce-passes']
63
+
64
+ // ---- the breaker DIAGNOSIS, mechanized (2026-07-22) ----
65
+ //
66
+ // The breaker always handed the human a diagnosis, and SKILL.md stated the routing rule
67
+ // in prose: "mostly NEW themes each round -> the loop is discovering an unwritten design,
68
+ // recommend the design pass; the SAME theme recurring -> the unit is stuck or mis-sized,
69
+ // recommend split or descope". An audit of 127 run manifests (522 units, 18 that fired,
70
+ // 27 firings) found the recurring-theme branch had NEVER once been taken:
71
+ //
72
+ // #523 theme `deploy-prod-auto-promote` in 5 of 5 rounds -> continue, continue
73
+ // #400 theme `help-usage-accuracy` in 3 of 4 rounds -> continue
74
+ // #405 theme `condition-c-prose-evidence` in 2 of 5 rounds -> continue, continue
75
+ //
76
+ // 19 of 27 decisions were `continue`; `descope` was chosen zero times in 522 units and
77
+ // `split` never on a real unit. A rule an agent applies from memory mid-gate is a rule
78
+ // that degrades to its cheapest branch — the same failure class as the `/grill-with-docs`
79
+ // paraphrase and the relative-skillsRoot lookalike. The data to decide it was already in
80
+ // the manifest the whole time (`units[].rounds[].themeIds`), so the diagnosis is a
81
+ // computation, not a judgment, and it belongs in code.
82
+ //
83
+ // The ceiling. With the diagnosis mechanized the decision can also be APPLIED
84
+ // autonomously (see autoBreakerDecision), which removes the human stop that used to bound
85
+ // the loop — so something else must. This is that bound: no auto-decision above it, ever.
86
+ // Observed round counts across the audited units were 1:23 2:14 3:10 4:5 5:3 8:1,
87
+ // so 8 is the observed maximum and a unit reaching it has already spent more rounds than
88
+ // any that ever converged. Past the ceiling the breaker is a hard stop-and-ask again,
89
+ // whatever the diagnosis says — the epic-848 grind (nine rounds, ~6.5 unattended hours)
90
+ // is the failure this number exists to make unreachable.
91
+ export const BREAKER_TOTAL_ROUND_CEILING = 8
92
+ // How many of the recent rounds one theme must dominate before the unit reads as stuck on
93
+ // it rather than working through it. Two consecutive is explicitly NOT enough — SKILL.md
94
+ // keeps that as an input to the diagnosis, not a rung, because the loop cannot tell an
95
+ // implementer circling a blind spot from a reviewer over-blocking. Three is the first
96
+ // count that outruns that ambiguity, and it is what #400/#523 both exhibited.
97
+ export const THEME_RECURRENCE_LIMIT = 3
98
+ // Which decisions the loop may take on its own. The split is by REVERSIBILITY, not by
99
+ // convenience:
100
+ // `continue` — spends one more bounded round. Cheap, reversible, ceiling-capped.
101
+ // `design` — routes to `/grill-with-docs`, which is ITSELF a human interview, and
102
+ // record-intake-reentry already refuses it unless the contract SHA moved.
103
+ // Automating the decision does not remove the human; it stops asking
104
+ // permission to ask them.
105
+ // `descope` is deliberately absent, at every level: it decides that LESS SHIPS than the
106
+ // issue asked for, and a loop that can quietly decide to build less is the one failure
107
+ // that leaves no trace in the handoff — every other autonomous decision shows up as
108
+ // rounds spent, which is visible.
109
+ // `split` is absent from THIS set for a different reason, not the same one: it is
110
+ // auto-decidable, but only where the human pre-authorized it. See
111
+ // AUTONOMOUS_ONLY_AUTO_DECISIONS below for why the reversibility argument reaches a
112
+ // re-slice and not a descope.
113
+ export const AUTO_BREAKER_DECISIONS = ['continue', 'design']
114
+
115
+ // Auto-decidable only when pre-flight recorded `autonomy: autonomous`. `split` sits here
116
+ // rather than beside continue/design because a supervised run should keep being asked.
117
+ //
118
+ // It moved out of the always-human set on evidence, and the evidence it replaced was
119
+ // stale. breaker.md long claimed `split` was "never chosen on a real unit" from an early
120
+ // audit; measured across 246 live manifests / 602 units the real distribution is
121
+ // **continue 17 · design 6 · split 5 · descope 0** — split is the second most common
122
+ // explicit decision, and epic-848 carries three MORE splits recorded only as blocker prose
123
+ // ("approved implementation split is #936…") that never reached the breaker at all, so the
124
+ // true rate is higher still.
125
+ //
126
+ // Why it is safe in a way `descope` is not: a split re-slices, it does not ship less. It
127
+ // also fires exactly where the loop hurts most — a unit that survived three failing review
128
+ // rounds plus a class-complete mandate carries the largest diff and the longest finding
129
+ // list, so every further round costs the producer and the reviewer their scarcest
130
+ // resource. Splitting is the one remedy that shrinks the per-round load instead of adding
131
+ // to it, which matters more the tighter the implementer's context is.
132
+ //
133
+ // `descope` stays human at every level: zero uses in 602 units, and it is the one decision
134
+ // that genuinely delivers less than was asked. A split that quietly drops a slice IS a
135
+ // descope wearing another label — which is why an auto-split owes a `deferrals[]` entry
136
+ // with a tracking issue for anything not carried forward, and `deferrals` already refuses
137
+ // an untracked one.
138
+ export const AUTONOMOUS_ONLY_AUTO_DECISIONS = ['split']
139
+
140
+ // The record carries its own authorization. Threading the manifest's autonomy level into
141
+ // per-unit breaker validation would have meant every caller passing it; stamping the level
142
+ // that authorized the decision onto the decision makes the guard self-contained AND makes
143
+ // the handoff able to say why the loop was allowed to take it.
144
+ export function autoDecisionAllowed(decision, autonomyAtDecision) {
145
+ if (AUTO_BREAKER_DECISIONS.includes(decision)) return true
146
+ return AUTONOMOUS_ONLY_AUTO_DECISIONS.includes(decision) && autonomyAtDecision === 'autonomous'
147
+ }
148
+ export const BREAKER_AUTONOMY = ['auto', 'ask']
149
+ export const BREAKER_SIGNALS = ['recurring-theme', 'churning-themes', 'converging', 'insufficient-data']
150
+
151
+ // Themes recorded per round, oldest first, with empty rounds preserved so "this round
152
+ // named no theme" stays distinguishable from "this round did not happen".
153
+ function roundThemes(unit) {
154
+ const rounds = Array.isArray(unit?.rounds) ? unit.rounds : []
155
+ return rounds.map((r) => (Array.isArray(r?.themeIds) ? r.themeIds.filter(nonEmptyString) : []))
156
+ }
157
+
158
+ // The theme that dominates the round log, with the count of rounds naming it. Counts
159
+ // ROUNDS not mentions — a round listing eight themes is one round of evidence about each,
160
+ // and counting mentions would let a single scattergun review outvote a genuine streak.
161
+ function dominantTheme(unit, themesByRound) {
162
+ const rounds = Array.isArray(unit?.rounds) ? unit.rounds : []
163
+ const counts = new Map()
164
+ themesByRound.forEach((themes, i) => {
165
+ const rn = roundNumber(rounds[i], i)
166
+ for (const t of new Set(themes)) {
167
+ if (rn >= themeWindowStart(unit, t)) counts.set(t, (counts.get(t) || 0) + 1)
168
+ }
169
+ })
170
+ let top = null
171
+ for (const [theme, count] of counts) {
172
+ // Ties break on the theme id so the diagnosis is deterministic across runs — an
173
+ // unstable recommendation is one nobody can reproduce from the manifest.
174
+ if (!top || count > top.count || (count === top.count && theme < top.theme)) top = { theme, count }
175
+ }
176
+ return top
177
+ }
178
+
179
+ // Every class-complete mandate whose authorized round has actually COMPLETED, current and
180
+ // archived alike.
181
+ //
182
+ // "Spent" is the load-bearing word, and both ways of getting it wrong break the remedy in
183
+ // opposite directions. Reading a mandate as spent the moment it is *recorded* flips the
184
+ // diagnosis to `split` before the class-complete round ever runs, so every recurring theme
185
+ // escalates straight to a human — worse than the prose version this replaces. Reading an
186
+ // archived mandate as spent *by construction* is the same error wearing a different hat:
187
+ // a mandate can be archived without its round ever happening (the unit's next producer
188
+ // spawn trips the produce-pass breaker first, and recording that decision archives the
189
+ // still-unspent mandate), after which the theme reads as "already remedied" and escalates
190
+ // early. So neither position in the log decides it — only the round count does.
191
+ function spentMandates(unit) {
192
+ const all = [...(Array.isArray(unit?.breakerLog) ? unit.breakerLog : []), ...(isObject(unit?.breaker) ? [unit.breaker] : [])]
193
+ const completed = completedReviewRounds(unit)
194
+ return all.filter((b) => isObject(b)
195
+ && nonEmptyString(b.classCompleteFor)
196
+ && Number.isInteger(b.authorizedThroughRound)
197
+ && completed >= b.authorizedThroughRound)
198
+ }
199
+
200
+ function mandateSpentFor(unit, theme) {
201
+ return spentMandates(unit).some((b) => b.classCompleteFor === theme)
202
+ }
203
+
204
+ const roundNumber = (entry, index) => (Number.isInteger(entry?.round) ? entry.round : index + 1)
205
+
206
+ const countRounds = (themesByRound, theme) => themesByRound.filter((t) => t.includes(theme)).length
207
+
208
+ // Was `theme` raised in round `from` or any round after it? This is how a mandate is
209
+ // judged: the mandated round is the first evidence of whether the class-complete fix held,
210
+ // so it is included, and every later round counts too (a theme that goes quiet for a round
211
+ // and comes back was not closed either).
212
+ function themeRaisedFrom(unit, themesByRound, theme, from) {
213
+ if (!nonEmptyString(theme) || !Number.isInteger(from)) return false
214
+ const rounds = Array.isArray(unit?.rounds) ? unit.rounds : []
215
+ return themesByRound.some((themes, i) => roundNumber(rounds[i], i) >= from && themes.includes(theme))
216
+ }
217
+
218
+ // The round from which a theme's recurrence is counted: just after its OWN class-complete
219
+ // remedy, or round 1 if it never had one.
220
+ //
221
+ // Per-theme, not per-unit, and that distinction is the whole fix. Counting every theme
222
+ // over the whole log lets a theme's history outlive the fix that closed it; counting every
223
+ // theme from one shared post-mandate cursor does the opposite damage, suppressing the
224
+ // history of themes that were never remedied at all. Both failures showed up under
225
+ // adversarial audit and neither was caught by the tests, whose fixtures happened to have
226
+ // no stale dominant theme:
227
+ // - A mandate that WORKED still escalates: theme X in rounds 1-3, mandated, round 4 comes
228
+ // back with only theme Y. Whole-log counting still shows X at 3-of-4 with its mandate
229
+ // spent, so the loop tells the human "X survived a class-complete round" — false, X was
230
+ // not raised in the mandated round at all, and the human is sent to split a unit whose
231
+ // remedy just worked.
232
+ // - One-shot-per-theme fails: X remedied at round 2, Y recurring in rounds 1-3 and never
233
+ // remedied. A shared cursor at round 2 hides Y's first round and Y never gets the
234
+ // class-complete round it is owed.
235
+ // Asking each theme "how often since we last tried to fix YOU" answers both.
236
+ function themeWindowStart(unit, theme) {
237
+ const own = spentMandates(unit).filter((b) => b.classCompleteFor === theme)
238
+ return own.length ? Math.max(...own.map((b) => b.authorizedThroughRound)) : 1
239
+ }
240
+
241
+ // The diagnosis: a pure read of the round log. Returns the signal, the recommended
242
+ // decision, the evidence that produced it, and the VERBATIM text to hand the human (or
243
+ // to record) — verbatim because the one thing the prose version reliably lost was the
244
+ // literal `/grill-with-docs` redirect, and a string in code cannot be paraphrased.
245
+ export function diagnoseBreaker(unit) {
246
+ const themesByRound = roundThemes(unit)
247
+ const rounds = themesByRound.length
248
+ const withThemes = themesByRound.filter((t) => t.length > 0).length
249
+ const completed = completedReviewRounds(unit)
250
+ const atCeiling = completed >= BREAKER_TOTAL_ROUND_CEILING
251
+
252
+ const base = { rounds, roundsWithThemes: withThemes, completedRounds: completed, atCeiling }
253
+ const missingMetadata = []
254
+ for (const [index, round] of (Array.isArray(unit?.rounds) ? unit.rounds : []).entries()) {
255
+ if (!Array.isArray(round?.themeIds)) missingMetadata.push(`rounds[${index}].themeIds`)
256
+ if (classifyRoundMetadata(round).changedSurfaces.includes(LEGACY_CHANGED_SURFACE)) {
257
+ missingMetadata.push(`rounds[${index}].changedSurfaces`)
258
+ }
259
+ }
260
+ if (missingMetadata.length) {
261
+ return {
262
+ ...base,
263
+ signal: 'insufficient-data',
264
+ recommendation: null,
265
+ dominantTheme: null,
266
+ dominantThemeRounds: 0,
267
+ missingMetadata,
268
+ text: `The breaker refuses auto-continue because required theme/change metadata is absent (${missingMetadata.join(', ')}). Record the missing round metadata or hand back the unit; reconstruct only from the review/diff evidence.`,
269
+ }
270
+ }
271
+
272
+ // The log has to be dense enough to carry a verdict. Two ways it is not, and both route
273
+ // to the human rather than to a guess, because an unfounded auto-decision is worse than
274
+ // an interrupt:
275
+ // - too few themed rounds to see a pattern at all;
276
+ // - a log that covers less than half the rounds the unit actually completed, which is
277
+ // the shape that let a 2-of-7 sample produce a confident `design`. The round count
278
+ // comes from max(reviewHistory, reviewRounds, rounds) — so a unit can genuinely have
279
+ // run seven rounds while logging two, and the diagnosis must not mistake the two it
280
+ // can see for the seven that happened.
281
+ const sparse = withThemes < 2 || withThemes * 2 < completed
282
+ if (sparse) {
283
+ return {
284
+ ...base,
285
+ signal: 'insufficient-data',
286
+ recommendation: null,
287
+ dominantTheme: null,
288
+ dominantThemeRounds: 0,
289
+ text: `The round log carries themes for ${withThemes} round(s) against ${completed} completed review round(s) — too sparse to say whether this unit is circling one invariant or discovering an unwritten design. Record \`themeIds\` on every round (run-state.mjs record-round) — until then this decision is the human's.`,
290
+ }
291
+ }
292
+
293
+ // Did a mandated class-complete fix FAIL to close its theme? That is the escalation
294
+ // trigger, and the test is whether the theme was raised again in the mandated round or
295
+ // any round after it — not whether it recurs another THEME_RECURRENCE_LIMIT times. The
296
+ // mandate's whole claim is "this round closes the class"; one reappearance falsifies it,
297
+ // and waiting for three more would spend the rounds the remedy was supposed to save.
298
+ // Equally, a theme absent from the mandated round onward was CLOSED by the remedy, and
299
+ // saying it "survived" on the strength of its pre-mandate history is a false report that
300
+ // sends the human to split a unit whose fix just worked.
301
+ const survived = spentMandates(unit).find((b) => themeRaisedFrom(unit, themesByRound, b.classCompleteFor, b.authorizedThroughRound))
302
+ if (survived) {
303
+ const theme = survived.classCompleteFor
304
+ return {
305
+ ...base,
306
+ signal: 'recurring-theme',
307
+ recommendation: 'split',
308
+ dominantTheme: theme,
309
+ dominantThemeRounds: countRounds(themesByRound, theme),
310
+ // The autonomy level is deliberately NOT threaded in (this function takes the unit
311
+ // and nothing else), so the sentence has to be true at both levels: a split is the
312
+ // loop's under `autonomous` and yours under `supervised`, a descope is yours always.
313
+ text: `Theme \`${theme}\` was raised again at or after its mandated class-complete round (round ${survived.authorizedThroughRound}) — the fix that was supposed to close the whole invariant class did not. This is the mis-sized shape, not a fixable one: split the unit at the seam \`${theme}\` names, or descope it. Under \`autonomy: autonomous\` the split is the loop's to take — it re-slices rather than shipping less, and anything it drops owes a tracked \`deferrals[]\` entry; under \`supervised\` it is yours to decide. A descope is yours at every level.`,
314
+ }
315
+ }
316
+
317
+ const top = dominantTheme(unit, themesByRound)
318
+ if (top && top.count >= THEME_RECURRENCE_LIMIT && !mandateSpentFor(unit, top.theme)) {
319
+ // The doctrine's own remedy for a recurring theme is a class-complete fix at full
320
+ // scope (SKILL.md: "close the invariant class, not the counterexample"). #523 spent
321
+ // five rounds on one theme and every one of them was an INSTANCE fix — the remedy was
322
+ // never applied. So apply it once, autonomously, before spending a human on scope.
323
+ return {
324
+ ...base,
325
+ signal: 'recurring-theme',
326
+ recommendation: 'continue',
327
+ dominantTheme: top.theme,
328
+ dominantThemeRounds: top.count,
329
+ classCompleteFor: top.theme,
330
+ text: `Theme \`${top.theme}\` has been raised in ${top.count} of the ${rounds} rounds under review — the previous fixes closed the counterexample, not the invariant class. One more round, MANDATED class-complete on that theme: force full scope (withhold lastReviewedSha) and require the fix to name the invariant class it closes and the adjacent members it checked. If \`${top.theme}\` is raised again in that round, the unit is mis-sized and the next firing decides split or descope — the split is the loop's under \`autonomy: autonomous\` and yours under \`supervised\`; a descope is yours at every level.`,
331
+ }
332
+ }
333
+
334
+ // Mostly-new themes each round: the reviewer keeps finding DIFFERENT contracts to
335
+ // violate, which is what discovering an unwritten design looks like from outside.
336
+ // Churn is measured over the window too, and needs at least THEME_RECURRENCE_LIMIT themed
337
+ // rounds before it can fire. Below that a single scattergun review — one round naming
338
+ // twenty themes beside one naming a single theme — clears the ratio on its own, and
339
+ // "twenty findings in one round" is not the same evidence as "a different contract every
340
+ // round", which is what the design redirect is actually diagnosing.
341
+ const distinct = new Set(themesByRound.flat()).size
342
+ const churn = withThemes ? distinct / withThemes : 0
343
+ if (churn >= 2 && withThemes >= THEME_RECURRENCE_LIMIT) {
344
+ return {
345
+ ...base,
346
+ signal: 'churning-themes',
347
+ recommendation: 'design',
348
+ dominantTheme: top ? top.theme : null,
349
+ dominantThemeRounds: top ? top.count : 0,
350
+ text: `${distinct} distinct themes across ${withThemes} rounds with no theme repeating ${THEME_RECURRENCE_LIMIT}x — the loop is discovering an unwritten design one invariant per round. Run \`/grill-with-docs\` on ${unit?.id ?? 'this unit'}; the resulting ADR re-enters intake as the design input.`,
351
+ }
352
+ }
353
+
354
+ return {
355
+ ...base,
356
+ signal: 'converging',
357
+ recommendation: 'continue',
358
+ dominantTheme: top ? top.theme : null,
359
+ dominantThemeRounds: top ? top.count : 0,
360
+ text: `${distinct} distinct themes across ${withThemes} rounds, none recurring ${THEME_RECURRENCE_LIMIT}x — the unit is working through findings rather than circling one. One more round.`,
361
+ }
362
+ }
363
+
364
+ // The decision the loop may take on its own, or null when this firing owes a human.
365
+ // Three independent ways to land on the human, each failing CLOSED:
366
+ // - autonomy is 'ask' (the human opted out of autonomous decisions entirely)
367
+ // - the diagnosis reached no recommendation, or reached one this run's autonomy level
368
+ // does not authorize (`autoDecisionAllowed`: a `split` needs `autonomous`, a `descope`
369
+ // is the human's at every level)
370
+ // - the unit is at BREAKER_TOTAL_ROUND_CEILING (the grind bound; no diagnosis overrides it)
371
+ export function autoBreakerDecision(unit, { autonomy = 'auto', autonomyLevel = 'supervised' } = {}) {
372
+ if (autonomy !== 'auto') return null
373
+ const d = diagnoseBreaker(unit)
374
+ if (d.atCeiling) return null
375
+ if (!d.recommendation || !autoDecisionAllowed(d.recommendation, autonomyLevel)) return null
376
+ return {
377
+ decision: d.recommendation,
378
+ diagnosis: d.text,
379
+ signal: d.signal,
380
+ autoApplied: true,
381
+ autonomyAtDecision: autonomyLevel,
382
+ ...(d.classCompleteFor ? { classCompleteFor: d.classCompleteFor } : {}),
383
+ }
384
+ }
385
+
386
+ // Surfaced by `validate`, never thrown: a decision that contradicts the computed
387
+ // diagnosis is a judgment call the human is allowed to make (they can see things the
388
+ // round log cannot), but it must not pass silently — this warning is exactly what would
389
+ // have flagged #523, #400 and #405 while they were still running.
390
+ export function breakerDiagnosisWarnings(unit) {
391
+ const breaker = isObject(unit?.breaker) ? unit.breaker : null
392
+ if (!breaker || !nonEmptyString(breaker.decision)) return []
393
+ const d = diagnoseBreaker(unit)
394
+ const out = []
395
+ if (d.recommendation && breaker.decision !== d.recommendation) {
396
+ out.push(`breaker.decision is "${breaker.decision}" but the round log diagnoses "${d.signal}" and recommends "${d.recommendation}" — ${d.text}`)
397
+ }
398
+ if (d.signal === 'recurring-theme' && breaker.decision === 'continue' && !breaker.classCompleteFor) {
399
+ out.push(`breaker: theme \`${d.dominantTheme}\` recurs in ${d.dominantThemeRounds} of ${d.rounds} rounds and this "continue" mandates no class-complete fix — that is the #523 shape (five rounds, five instance fixes on one invariant); set classCompleteFor to force the class-complete round`)
400
+ }
401
+ return out
402
+ }
403
+
404
+ // ---- delivery: the merge-ready EXIT gate (epic-986) ----
405
+ //
406
+ // `merge-ready` used to be terminal with no transition out of it: every other move in this
407
+ // skill is gated in state and refuses an invalid transition, but merge-ready -> merged had
408
+ // no gate, no counter and nothing refusable, so a unit could sit there forever and the
409
+ // manifest was never wrong. epic-986 recorded mergeShippable: true, targetBranch: main,
410
+ // four units merge-ready with lastVerdict PASS and their PR refs — and mergedSha: null on
411
+ // all four. Authorization present, bar met, PR known, no delivery performed, twelve hours.
412
+ //
413
+ // TWO MODES, and picking the wrong one fails SILENTLY IN BOTH DIRECTIONS — `gh pr merge`
414
+ // on a queue repo bypasses or errors, an enqueue on a direct repo does nothing at all.
415
+ // That silence is the incident. So the mode is settled at pre-flight, recorded on the
416
+ // manifest, and ENFORCED here: an action that does not belong to the run's mode is
417
+ // refused rather than quietly performed against the wrong merge path.
418
+ // direct — the legacy path: `gh pr merge`, synchronous, one action.
419
+ // queue — enqueue, then WATCH to a terminal queue outcome.
420
+ export const DELIVERY_MODES = ['direct', 'queue']
421
+ export const DELIVERY_ACTIONS = { direct: ['merge'], queue: ['enqueue', 'dequeue'] }
422
+ // `requested` (epic-867 / #946): command posted; no ack and not in queue yet.
423
+ // Distinct from `waiting` (ack OR queue membership observed). Without this split,
424
+ // orchestrators either lied about ack on first post or could not record the truth.
425
+ export const DELIVERY_ATTEMPT_OUTCOMES = ['merged', 'dequeued', 'waiting', 'rejected', 'requested']
426
+
427
+ // ---- the two flows, as DATA ----
428
+ //
429
+ // `deliveryMode` picks the STATE MACHINE (synchronous merge vs enqueue-and-watch).
430
+ // `queueProvider` picks the MECHANICS (how you enqueue, where state is read, whether a
431
+ // nudge or an acknowledgement even exists). Collapsing the second into the first is a
432
+ // real bug and it was already in the prose: delivery.md offered "`@mergifyio queue`, or
433
+ // `gh pr merge --auto`" as if they were two spellings of one action. They are two
434
+ // providers with different state sources, different dequeue vocabularies, and only one
435
+ // of which has a re-evaluation command at all.
436
+ //
437
+ // This table is the mechanization: every validator below reads capabilities from here,
438
+ // so adding a provider is a row rather than a sweep through branching prose.
439
+ export const QUEUE_PROVIDERS = ['mergify', 'github']
440
+
441
+ export const DELIVERY_FLOWS = {
442
+ direct: {
443
+ key: 'direct',
444
+ mode: 'direct',
445
+ provider: null,
446
+ verified: true,
447
+ actions: ['merge'],
448
+ // Synchronous: `gh pr merge` returns the result. There is no queue to watch, and an
449
+ // observation recorded here means the run believes it is in a queue when it is not —
450
+ // the wrong-mode failure detect-delivery-mode exists to prevent, one layer downstream.
451
+ watchable: false,
452
+ supportsAck: false,
453
+ supportsNudge: false,
454
+ attemptFields: ['at', 'action', 'headSha', 'outcome', 'reason', 'detail'],
455
+ ops: {
456
+ merge: 'gh pr merge <pr> --merge|--squash --delete-branch',
457
+ readState: null,
458
+ nudge: null,
459
+ dequeue: null,
460
+ },
461
+ },
462
+ 'queue:mergify': {
463
+ key: 'queue:mergify',
464
+ mode: 'queue',
465
+ provider: 'mergify',
466
+ verified: true, // exercised live against this skills repo, PR #529, 2026-07-23
467
+ actions: ['enqueue', 'dequeue'],
468
+ watchable: true,
469
+ // The command is a PR comment, so "did anything read it?" is a real question with a
470
+ // real answer: Mergify reacts 👍 and posts a status comment carrying a machine-readable
471
+ // payload. No ack means the request never landed — a different state from "the queue is
472
+ // considering it", and indistinguishable without this.
473
+ supportsAck: true,
474
+ supportsNudge: true,
475
+ // `pr` is accepted as an optional alias/detail for multi-PR components (#946).
476
+ attemptFields: ['at', 'action', 'headSha', 'outcome', 'reason', 'rawReason', 'detail', 'queueState', 'ack', 'unmetConditions', 'pr'],
477
+ ops: {
478
+ merge: null,
479
+ enqueue: 'gh pr comment <pr> --body "@mergifyio queue"',
480
+ // `mergify queue show` reports NOTHING during pre-admission ("PR #N is not in the
481
+ // merge queue" while the PR is genuinely accepted and waiting on conditions), so the
482
+ // pre-admission state is read from Mergify's own status comment instead — it carries
483
+ // `-*- Mergify Payload -*-` JSON with `state` plus the rendered condition tree.
484
+ readState: 'mergify queue show <pr> --json',
485
+ readPreAdmissionState: 'gh pr view <pr> --json comments --jq \'.comments[] | select(.author.login=="mergify") | .body\'',
486
+ nudge: 'gh pr comment <pr> --body "@mergifyio refresh"',
487
+ dequeue: 'gh pr comment <pr> --body "@mergifyio dequeue"',
488
+ },
489
+ },
490
+ 'queue:github': {
491
+ key: 'queue:github',
492
+ mode: 'queue',
493
+ provider: 'github',
494
+ // NOT verified against a live GitHub-native merge queue. The capability flags are the
495
+ // load-bearing part and they are conservative (no ack, no nudge — GitHub's enqueue is
496
+ // an API call that succeeds or fails, and it has no re-evaluation command); the `ops`
497
+ // strings are best-effort and are printed with a warning by `run-state delivery-flow`.
498
+ verified: false,
499
+ actions: ['enqueue', 'dequeue'],
500
+ watchable: true,
501
+ supportsAck: false,
502
+ supportsNudge: false,
503
+ attemptFields: ['at', 'action', 'headSha', 'outcome', 'reason', 'rawReason', 'detail', 'queueState', 'unmetConditions', 'pr'],
504
+ ops: {
505
+ merge: null,
506
+ enqueue: 'gh pr merge <pr> --auto --squash',
507
+ readState: 'gh api graphql -f query=\'{repository(owner:"<owner>",name:"<repo>"){pullRequest(number:<pr>){mergeQueueEntry{state position}}}}\'',
508
+ readPreAdmissionState: null,
509
+ nudge: null,
510
+ dequeue: 'gh pr merge <pr> --disable-auto',
511
+ },
512
+ },
513
+ }
514
+
515
+ export function deliveryFlowKey(mode, provider) {
516
+ return mode === 'queue' ? `queue:${provider ?? ''}` : 'direct'
517
+ }
518
+
519
+ // Resolve the flow for a manifest. Returns null when the flow cannot be determined —
520
+ // a `queue` run with no `queueProvider` is UNKNOWN mechanics, not a default provider,
521
+ // and every provider-specific gate below refuses rather than guessing which one.
522
+ export function deliveryFlow(manifest) {
523
+ return DELIVERY_FLOWS[deliveryFlowKey(deliveryMode(manifest), deliveryProvider(manifest))] ?? null
524
+ }
525
+
526
+ export function deliveryProvider(manifest) {
527
+ const p = manifest?.queueProvider
528
+ return p === undefined || p === null ? null : p
529
+ }
530
+ // Why a dequeue reason is REQUIRED and read from the queue tool rather than inferred: the
531
+ // reasons route to opposite places. A merge queue tests *PR merged with base* — a tree
532
+ // that never existed at review time and that per-PR CI structurally cannot have covered —
533
+ // so `checks-failed` is new information about code that is about to land and re-enters the
534
+ // loop as a round. The rest are hand-backs. There is deliberately no `batch-bystander`:
535
+ // Mergify's own `bisecting` state resolves a red batch and the innocent PRs carry on, so
536
+ // while the queue bisects the unit is in-flight and the skill simply keeps watching.
537
+ export const DELIVERY_DEQUEUE_REASONS = ['checks-failed', 'conflict', 'conditions-unmet', 'frozen', 'manual', 'pull-request-updated']
538
+ export const DELIVERY_OUTCOMES = ['merged', 'handed-back']
539
+
540
+ // The canonical reasons above are the ROUTING classes. The queue's own vocabulary is
541
+ // wider than they are — Mergify's `queue-dequeue-reason` type alone carries ~40 values —
542
+ // so demanding one of ours "read from the queue tool, never inferred" was self-
543
+ // contradictory: the tool says `pull-request-updated` and the taxonomy accepted neither
544
+ // that string nor any honest translation of it. So the raw string is kept verbatim in
545
+ // `rawReason` and mapped here, and anything unmapped routes to a HAND-BACK: a reason this
546
+ // table has not seen is not evidence about the code, and guessing is what the raw field
547
+ // exists to stop.
548
+ export const DELIVERY_DEQUEUE_ROUTES = {
549
+ // The queue tested "PR merged with base" and it failed — a tree per-PR CI structurally
550
+ // never covered. New information about code about to land ⇒ a round of the loop.
551
+ 'checks-failed': { canonical: 'checks-failed', route: 'fresh-verdict' },
552
+ // The loop's own push ejected it. HEAD moved, so the last verdict is stale by
553
+ // guarantee 3 and the unit owes a fresh one before it can be enqueued again.
554
+ 'pull-request-updated': { canonical: 'pull-request-updated', route: 'fresh-verdict' },
555
+ 'draft-pull-request-changed': { canonical: 'pull-request-updated', route: 'fresh-verdict' },
556
+ // A rebase is an evidence refresh, not a new review.
557
+ conflict: { canonical: 'conflict', route: 'evidence-lane' },
558
+ 'conflict-with-base-branch': { canonical: 'conflict', route: 'evidence-lane' },
559
+ // Everything else the queue is telling you about ITSELF or about a human's decision.
560
+ 'conditions-unmet': { canonical: 'conditions-unmet', route: 'hand-back' },
561
+ frozen: { canonical: 'frozen', route: 'hand-back' },
562
+ manual: { canonical: 'manual', route: 'hand-back' },
563
+ 'pr-manually-dequeued': { canonical: 'manual', route: 'hand-back' },
564
+ // A timeout is not evidence the code is broken, and re-enqueueing the same HEAD is
565
+ // refused anyway — so the honest exit is a hand-back quoting the queue.
566
+ 'checks-timeout': { canonical: 'conditions-unmet', route: 'hand-back' },
567
+ }
568
+
569
+ export function dequeueRouting(rawReason) {
570
+ const known = Object.prototype.hasOwnProperty.call(DELIVERY_DEQUEUE_ROUTES, rawReason)
571
+ if (known) return { ...DELIVERY_DEQUEUE_ROUTES[rawReason], rawReason, known: true }
572
+ return {
573
+ canonical: 'manual',
574
+ route: 'hand-back',
575
+ rawReason,
576
+ known: false,
577
+ note: `"${rawReason}" is not in the routing table — routed to hand-back, which is the fail-closed default; add a row if the queue emits it routinely`,
578
+ }
579
+ }
580
+
581
+ // The bound on WATCHING is the one delivery needs, and it is a stall bound rather than
582
+ // a total-elapsed one. A unit can sit in `waiting_for_previous_batches` for a long time
583
+ // while making steady progress, and a flat timeout hands back healthy units; what
584
+ // distinguishes wedged from slow-but-moving is whether the observed (state, position)
585
+ // pair ever changes. epic-986 sat twelve hours on a queue state that never moved once.
586
+ //
587
+ // This number and the two watcher scripts' stall bounds (`watch-delivery.mjs`'s
588
+ // `DEFAULT_BOUND_MINUTES`, the mergify skill's `DEFAULT_STALL_MINUTES`) are ONE doctrine with
589
+ // two implementations: this one bounds an observation recorded into the manifest, they bound
590
+ // the process doing the observing. They must agree, and for a while they did not — this sat at
591
+ // 30 while the watchers moved to 15 and then 10, which made the documented number one nobody
592
+ // ever experienced: the script returns a verdict at its own bound, the orchestrator stops
593
+ // observing, and a 30-minute manifest bound simply never fires on the scripted path. A bound
594
+ // that cannot be reached is not a bound, so the agreement is pinned by test rather than left to
595
+ // whoever edits one of the three next.
596
+ export const DELIVERY_WATCH_BOUND_MINUTES = 10
597
+ // Two queue states are SELF-EXPLAINING blocks: the queue is already telling you what is
598
+ // wrong and no amount of waiting changes it. Waiting out the bound to rediscover
599
+ // "the queue is frozen" is pure latency, so these hand back on first observation.
600
+ // `waiting_schedule` matters most: it can legitimately mean "Monday", and holding a
601
+ // session open until then is never the right answer.
602
+ export const DELIVERY_BLOCKING_QUEUE_STATES = ['frozen', 'waiting_schedule']
603
+
604
+ // PRE-ADMISSION — the phase the model did not name, and where PR #529 spent its first
605
+ // minutes on 2026-07-23: the enqueue command is acknowledged, every condition is green,
606
+ // and the PR is still not in the queue. `mergify queue show` answers "PR #N is not in the
607
+ // merge queue" and `queue status` answers "Queue is empty", both of which read exactly
608
+ // like "your enqueue never happened". They are different states and the loop must route
609
+ // them differently, so they are named:
610
+ // waiting-for-queue-conditions the queue accepted the request and is holding the PR
611
+ // not-enqueued nothing is holding it — the request never landed
612
+ export const DELIVERY_PREADMISSION_STATES = ['waiting-for-queue-conditions', 'not-enqueued']
613
+ export const DELIVERY_KEEP_WATCHING_STATES = [
614
+ 'waiting-for-queue-conditions',
615
+ 'waiting_for_batch', 'preparing', 'running', 'bisecting',
616
+ 'waiting_for_previous_batches', 'waiting_for_merge',
617
+ ]
618
+
619
+ // The nudge: ONE re-evaluation request, and only where the provider has one.
620
+ //
621
+ // PR #529 sat pre-admission with `Mergify Merge Protections` already SUCCESS; a single
622
+ // `@mergifyio refresh` was followed by admission at position 0 within one poll. That is
623
+ // ONE observation and correlation, not proof of causation — which is exactly why this is
624
+ // bounded to a single nudge rather than a retry loop: if the refresh does nothing, the
625
+ // stall bound still fires and the unit still hands back, so the cost of being wrong is
626
+ // one comment. Under the un-nudged rules that PR would have burned the full stall bound
627
+ // and handed back a healthy unit.
628
+ //
629
+ // The nudge fires at 5 minutes against a 10-minute bound, so the window between "nudge is
630
+ // due" and "hand back" is 5 minutes — and because a spent nudge restarts the stall clock, a
631
+ // nudged unit still gets a full bound afterwards. Shortening the bound therefore costs the
632
+ // nudge none of its room; it only shortens how long an UN-nudged wedge stays unnoticed.
633
+ //
634
+ // This is NOT a retry of the merge path (the rule at recordDeliveryAttempt): it attempts
635
+ // nothing, touches no HEAD, and asks the queue to re-read state it already has.
636
+ export const DELIVERY_NUDGE_AFTER_MINUTES = 5
637
+ export const DELIVERY_MAX_NUDGES = 1
638
+ const WORKFLOW_REVIEW_REQUIRED = [
639
+ 'unitId', 'verdict', 'findings', 'overMockFindings', 'previousFindingsResolution',
640
+ 'reviewModel', 'independent', 'contractValidates', 'round1Depth',
641
+ 'headSha', 'reviewSubject', 'authorizationBoundary',
642
+ ]
643
+
644
+ function cellKey(cell) {
645
+ return `${cell.state}\u0000${cell.taskKind}`
646
+ }
647
+
648
+ function proofKey(proof) {
649
+ return `${proof.reviewRound}\u0000${proof.themeId}`
650
+ }
651
+
652
+ function persistedTheme(theme) {
653
+ return {
654
+ themeId: theme.id,
655
+ kind: theme.kind,
656
+ contract: theme.contract,
657
+ ...(theme.stateSpace ? { stateSpace: clone(theme.stateSpace) } : {}),
658
+ }
659
+ }
660
+
661
+ function themeFromPersisted(theme) {
662
+ if (!isObject(theme)) throw new Error('persisted theme must be an object')
663
+ const unknown = Object.keys(theme).filter((key) => !['themeId', 'kind', 'contract', 'stateSpace'].includes(key))
664
+ if (unknown.length) throw new Error(`persisted theme has unknown property: ${unknown[0]}`)
665
+ const out = {
666
+ id: theme.themeId,
667
+ kind: theme.kind,
668
+ contract: theme.contract,
669
+ ...(theme.stateSpace ? { stateSpace: clone(theme.stateSpace) } : {}),
670
+ }
671
+ if (!nonEmptyString(theme.themeId)) throw new Error('persisted theme requires themeId')
672
+ const errors = validateTheme(out)
673
+ if (errors.length) throw new Error(errors.join('; '))
674
+ return out
675
+ }
676
+
677
+ function themeFingerprint(theme) {
678
+ return JSON.stringify({
679
+ id: theme.id,
680
+ kind: theme.kind,
681
+ contract: theme.contract,
682
+ stateSpace: theme.stateSpace || null,
683
+ })
684
+ }
685
+
686
+ function themeFromFinding(finding) {
687
+ const findingErrors = validateFinding(finding)
688
+ if (findingErrors.length) throw new Error(findingErrors.join('; '))
689
+ return clone(finding.theme)
690
+ }
691
+
692
+ function themesInRound(round) {
693
+ if (Array.isArray(round?.themes) && round?.findings === undefined) return round.themes.map(themeFromPersisted)
694
+ if (Array.isArray(round?.findings) && round?.themes === undefined) return round.findings.map(themeFromFinding)
695
+ throw new Error('review round must contain exactly one themes (current) or findings (legacy) array')
696
+ }
697
+
698
+ function uniqueThemes(themes) {
699
+ const byId = new Map()
700
+ for (const theme of themes) {
701
+ const previous = byId.get(theme.id)
702
+ if (previous && themeFingerprint(previous) !== themeFingerprint(theme)) {
703
+ throw new Error(`theme identity ${theme.id} has conflicting contract/state-space definitions in one review round`)
704
+ }
705
+ byId.set(theme.id, theme)
706
+ }
707
+ return [...byId.values()]
708
+ }
709
+
710
+ function unitOrThrow(manifest, unitId) {
711
+ const units = Array.isArray(manifest?.units) ? manifest.units : []
712
+ const index = units.findIndex((unit) => unit?.id === unitId)
713
+ if (index === -1) throw new Error(`unknown unit: ${unitId}`)
714
+ return { units, index }
715
+ }
716
+
717
+ // The breaker fires on completed review rounds, not on fix rounds: a unit that has
718
+ // absorbed BREAKER_ROUND_LIMIT independent verdicts without exiting is the signal.
719
+ // `continue` authorizes exactly one further round, after which the breaker re-fires;
720
+ // design/split/descope route the unit out of the loop entirely, so they keep blocking
721
+ // until the unit re-enters intake (which clears the decision).
722
+ // Round count must come from BOTH shapes. `reviewHistory` is the opt-in deep-audit
723
+ // layer (run-state.md) — the lean loop records only the `reviewRounds` integer, so a
724
+ // breaker that reads history alone silently never fires on the majority tier, which
725
+ // is the same "gate looks present, does nothing" failure it was built to end.
726
+ export function completedReviewRounds(unit) {
727
+ const history = Array.isArray(unit?.reviewHistory) ? unit.reviewHistory.length : 0
728
+ const counter = Number.isInteger(unit?.reviewRounds) && unit.reviewRounds > 0 ? unit.reviewRounds : 0
729
+ // The round log counts too. It is the lean lane's own record, so a unit that logs
730
+ // rounds but never bumps reviewRounds must still hit the breaker budget — the same
731
+ // reason the counter was folded in beside reviewHistory.
732
+ const log = Array.isArray(unit?.rounds) ? unit.rounds.length : 0
733
+ return Math.max(history, counter, log)
734
+ }
735
+
736
+ // ---- the round log: the LEAN record every unit writes ----
737
+ //
738
+ // Distinct from reviewHistory, which is the deep lane's opt-in audit layer and was
739
+ // written by 17 of 394 observed units — none of them the ones that churned. So the
740
+ // two questions the loop most needs answered ("is the same invariant coming back?"
741
+ // and "did this round change any code?") were unanswerable from state, and had to be
742
+ // reconstructed from PR archaeology.
743
+ //
744
+ // That reconstruction does not work. A rebase rewrites commit identity and dates, so
745
+ // old boolean records cannot be split honestly into runtime, tests, harness, evidence,
746
+ // and docs after the fact. New writes classify the actual Git tree delta and persist a
747
+ // closed surface set. Legacy booleans stay readable but project to explicit `unknown`.
748
+ export const ROUND_SCOPES = ['full', 'delta']
749
+ export { CHANGED_SURFACES }
750
+ export const REVIEW_SUBJECTS = ['code', 'evidence']
751
+ // #941 / epic-876: `external-review` is the vera932 shape — independent PASS, then a
752
+ // non-self CHANGES_REQUESTED (or equivalent) that names a real AC gap. It is NOT a
753
+ // note-chase: the production tree moves, and postmortem must not demote it as a
754
+ // probable rebase. Boundary is still `head` because the fix lands as a new head.
755
+ export const REOPEN_REASONS = ['ci-red', 'head-rewritten', 'contract-revised', 'integration-finding', 'external-review']
756
+ export const AUTHORIZATION_BOUNDARY_KINDS = ['head', 'ci', 'contract', 'integration']
757
+ const REOPEN_BOUNDARY_KIND = {
758
+ 'ci-red': 'ci',
759
+ 'head-rewritten': 'head',
760
+ 'contract-revised': 'contract',
761
+ 'integration-finding': 'integration',
762
+ 'external-review': 'head',
763
+ }
764
+
765
+ // ---- what an evidence round was actually about ----
766
+ export const EVIDENCE_CLASSES = [
767
+ 'stale-locator',
768
+ 'unrun-gate-row',
769
+ 'stale-stamp',
770
+ 'contract-drift',
771
+ 'checklist-row',
772
+ 'other',
773
+ ]
774
+
775
+ export const SCREENED_EVIDENCE_CLASSES = ['stale-locator', 'unrun-gate-row', 'stale-stamp']
776
+
777
+ // `productionMoved` is read-only compatibility. New writes carry the independent
778
+ // changed-surface and review-subject axes; an old manifest is projected explicitly to
779
+ // unknown rather than guessed into either axis.
780
+ export const ROUND_FIELDS = [
781
+ 'round', 'verdict', 'headSha', 'changedSurfaces', 'reviewSubject', 'themeIds',
782
+ 'scope', 'at', 'evidenceClasses', 'reopenReason', 'authorizationBoundary',
783
+ 'productionMoved',
784
+ ]
785
+
786
+ const hasTypedRoundMetadata = (entry) => isObject(entry) && [
787
+ 'changedSurfaces', 'reviewSubject', 'reopenReason', 'authorizationBoundary',
788
+ ].some((field) => Object.prototype.hasOwnProperty.call(entry, field))
789
+
790
+ export function classifyRoundMetadata(entry) {
791
+ if (!hasTypedRoundMetadata(entry)) {
792
+ return {
793
+ changedSurfaces: [LEGACY_CHANGED_SURFACE],
794
+ reviewSubject: 'unknown',
795
+ reopenReason: 'unknown',
796
+ authorizationBoundary: 'unknown',
797
+ legacy: true,
798
+ }
799
+ }
800
+ return {
801
+ changedSurfaces: Array.isArray(entry.changedSurfaces) ? [...entry.changedSurfaces] : [],
802
+ reviewSubject: entry.reviewSubject,
803
+ reopenReason: entry.reopenReason ?? null,
804
+ authorizationBoundary: entry.authorizationBoundary,
805
+ legacy: false,
806
+ }
807
+ }
808
+
809
+ function validateEvidenceClasses(entry, at, errors, { legacy }) {
810
+ if (entry.evidenceClasses !== undefined) {
811
+ if (!Array.isArray(entry.evidenceClasses) || !entry.evidenceClasses.length) {
812
+ errors.push(`${at}.evidenceClasses must be a non-empty array when present`)
813
+ } else {
814
+ const bad = entry.evidenceClasses.filter((c) => !EVIDENCE_CLASSES.includes(c))
815
+ if (bad.length) errors.push(`${at}.evidenceClasses[] must each be one of ${EVIDENCE_CLASSES.join('|')} (got ${JSON.stringify(bad[0])})`)
816
+ }
817
+ }
818
+ if (legacy) {
819
+ if (entry.evidenceClasses !== undefined && entry.productionMoved === true) {
820
+ errors.push(`${at} sets evidenceClasses on a legacy round with productionMoved: true — an evidence round is by definition one where production stood still`)
821
+ }
822
+ return
823
+ }
824
+ if (entry.reviewSubject === 'evidence' && entry.evidenceClasses === undefined) {
825
+ errors.push(`${at}: reviewSubject "evidence" requires evidenceClasses`)
826
+ }
827
+ if (entry.reviewSubject !== 'evidence' && entry.evidenceClasses !== undefined) {
828
+ errors.push(`${at}.evidenceClasses are legal only when reviewSubject is "evidence"`)
829
+ }
830
+ }
831
+
832
+ export function validateRoundLog(unit) {
833
+ const errors = []
834
+ if (unit?.rounds === undefined) return errors
835
+ if (!Array.isArray(unit.rounds)) return ['rounds must be an array']
836
+ let previous = 0
837
+ let prior = null
838
+ let awaitingReopen = false
839
+ let passBoundaryKnown = false
840
+ for (const [i, entry] of unit.rounds.entries()) {
841
+ const at = `rounds[${i}]`
842
+ if (!isObject(entry)) { errors.push(`${at} must be an object`); continue }
843
+ const unknown = Object.keys(entry).filter((k) => !ROUND_FIELDS.includes(k))
844
+ if (unknown.length) errors.push(`${at} has unknown property: ${unknown[0]}`)
845
+ if (!Number.isInteger(entry.round) || entry.round !== previous + 1) {
846
+ errors.push(`${at}.round must advance exactly from ${previous} to ${previous + 1}`)
847
+ } else previous = entry.round
848
+ if (!REVIEW_VERDICTS.includes(entry.verdict)) errors.push(`${at}.verdict must be one of ${REVIEW_VERDICTS.join('|')}`)
849
+ if (!nonEmptyString(entry.headSha)) errors.push(`${at}.headSha is required (the SHA the verdict was rendered against)`)
850
+ if (entry.themeIds !== undefined && (!Array.isArray(entry.themeIds) || !entry.themeIds.every(nonEmptyString))) {
851
+ errors.push(`${at}.themeIds must be an array of non-empty strings when present`)
852
+ }
853
+ if (entry.scope !== undefined && !ROUND_SCOPES.includes(entry.scope)) {
854
+ errors.push(`${at}.scope must be one of ${ROUND_SCOPES.join('|')}`)
855
+ }
856
+ if (entry.at !== undefined && !nonEmptyString(entry.at)) errors.push(`${at}.at must be a non-empty ISO string when present`)
857
+
858
+ const legacy = !hasTypedRoundMetadata(entry)
859
+ if (legacy) {
860
+ if (typeof entry.productionMoved !== 'boolean') {
861
+ errors.push(`${at}.productionMoved must be a boolean on a legacy round; its typed classifications read as unknown`)
862
+ }
863
+ validateEvidenceClasses(entry, at, errors, { legacy: true })
864
+ if (awaitingReopen && passBoundaryKnown) {
865
+ errors.push(`${at}: a legacy round cannot reopen a typed PASS; record typed reopenReason and a changed authorizationBoundary`)
866
+ awaitingReopen = false
867
+ passBoundaryKnown = false
868
+ }
869
+ if (entry.verdict === 'PASS') {
870
+ awaitingReopen = true
871
+ passBoundaryKnown = false
872
+ }
873
+ prior = entry
874
+ continue
875
+ }
876
+
877
+ if (entry.productionMoved !== undefined) {
878
+ errors.push(`${at}.productionMoved is legacy-only; new rounds use changedSurfaces`)
879
+ }
880
+ try { normalizeChangedSurfaces(entry.changedSurfaces) } catch (error) { errors.push(`${at}.${error.message}`) }
881
+ if (!REVIEW_SUBJECTS.includes(entry.reviewSubject)) {
882
+ errors.push(`${at}.reviewSubject must be one of ${REVIEW_SUBJECTS.join('|')}`)
883
+ }
884
+ if (!Array.isArray(entry.themeIds) || !entry.themeIds.every(nonEmptyString)) {
885
+ errors.push(`${at}.themeIds is required and must be an array of non-empty strings`)
886
+ }
887
+ if (!isObject(entry.authorizationBoundary)) {
888
+ errors.push(`${at}.authorizationBoundary is required and must be an object`)
889
+ } else {
890
+ const boundaryUnknown = Object.keys(entry.authorizationBoundary).filter((key) => !['kind', 'value'].includes(key))
891
+ if (boundaryUnknown.length) errors.push(`${at}.authorizationBoundary has unknown property: ${boundaryUnknown[0]}`)
892
+ if (!AUTHORIZATION_BOUNDARY_KINDS.includes(entry.authorizationBoundary.kind)) {
893
+ errors.push(`${at}.authorizationBoundary.kind must be one of ${AUTHORIZATION_BOUNDARY_KINDS.join('|')}`)
894
+ }
895
+ if (!nonEmptyString(entry.authorizationBoundary.value)) {
896
+ errors.push(`${at}.authorizationBoundary.value must be a non-empty string`)
897
+ }
898
+ }
899
+ validateEvidenceClasses(entry, at, errors, { legacy: false })
900
+
901
+ if (awaitingReopen) {
902
+ if (!isObject(prior?.authorizationBoundary)) {
903
+ errors.push(`${at}: prior PASS authorizationBoundary is unknown; reconstruct that boundary before reopening`)
904
+ }
905
+ if (!REOPEN_REASONS.includes(entry.reopenReason)) {
906
+ errors.push(`${at}.reopenReason must be one of ${REOPEN_REASONS.join('|')} after a PASS`)
907
+ } else if (isObject(entry.authorizationBoundary) && entry.authorizationBoundary.kind !== REOPEN_BOUNDARY_KIND[entry.reopenReason]) {
908
+ errors.push(`${at}.reopenReason ${entry.reopenReason} requires authorizationBoundary.kind ${REOPEN_BOUNDARY_KIND[entry.reopenReason]}`)
909
+ }
910
+ if (isObject(prior?.authorizationBoundary) && isObject(entry.authorizationBoundary)
911
+ && prior.authorizationBoundary.kind === entry.authorizationBoundary.kind
912
+ && prior.authorizationBoundary.value === entry.authorizationBoundary.value) {
913
+ errors.push(`${at}.authorizationBoundary must change after a PASS`)
914
+ }
915
+ awaitingReopen = false
916
+ passBoundaryKnown = false
917
+ } else if (entry.reopenReason !== undefined) {
918
+ errors.push(`${at}.reopenReason is legal only after a prior PASS`)
919
+ }
920
+ if (entry.verdict === 'PASS') {
921
+ awaitingReopen = true
922
+ passBoundaryKnown = true
923
+ }
924
+ prior = entry
925
+ }
926
+ return errors
927
+ }
928
+
929
+ export function sanitizeRoundEntry(entry) {
930
+ if (!isObject(entry)) return { entry, dropped: [] }
931
+ const out = {}
932
+ const dropped = []
933
+ for (const [k, v] of Object.entries(entry)) {
934
+ if (!ROUND_FIELDS.includes(k)) {
935
+ dropped.push(`${k}: not a round-log field — dropped`)
936
+ continue
937
+ }
938
+ out[k] = v
939
+ }
940
+ if (out.scope !== undefined && !ROUND_SCOPES.includes(out.scope)) {
941
+ dropped.push(`scope: ${JSON.stringify(out.scope)} is not one of ${ROUND_SCOPES.join('|')} — dropped; use reviewSubject: "evidence" with evidenceClasses for an evidence review`)
942
+ delete out.scope
943
+ }
944
+ return { entry: out, dropped }
945
+ }
946
+
947
+ export function recordRound(manifest, unitId, entry, { at = null, onDrop = null, derivedChangedSurfaces } = {}) {
948
+ if (!isObject(entry)) throw new Error('round entry must be an object')
949
+ const typedWrite = hasTypedRoundMetadata(entry) || derivedChangedSurfaces !== undefined
950
+ let candidateEntry = entry
951
+ if (typedWrite) {
952
+ if (derivedChangedSurfaces === undefined) {
953
+ throw new Error('record-round requires diff-derived changedSurfaces; callers may override only when validated against that diff')
954
+ }
955
+ const changedSurfaces = entry.changedSurfaces === undefined
956
+ ? normalizeChangedSurfaces(derivedChangedSurfaces)
957
+ : assertChangedSurfacesMatch(entry.changedSurfaces, derivedChangedSurfaces)
958
+ candidateEntry = { ...entry, changedSurfaces }
959
+ }
960
+ const { entry: clean, dropped } = sanitizeRoundEntry(candidateEntry)
961
+ if (dropped.length && typeof onDrop === 'function') onDrop(dropped)
962
+ const out = clone(manifest)
963
+ const { units, index } = unitOrThrow(out, unitId)
964
+ const unit = units[index]
965
+ unit.rounds = Array.isArray(unit.rounds) ? unit.rounds : []
966
+ const expected = (unit.rounds.at(-1)?.round || 0) + 1
967
+ const candidate = { ...clean, round: clean.round === undefined ? expected : clean.round, ...(at ? { at } : {}) }
968
+ const errors = validateRoundLog({ rounds: [...unit.rounds, candidate] })
969
+ if (errors.length) throw new Error(errors[0])
970
+ unit.rounds.push(candidate)
971
+ return out
972
+ }
973
+
974
+ // A round whose HEAD matches the one before it reviewed code that did not move. That is
975
+ // sometimes exactly right (a flaky gate held the previous verdict; a forced full-scope
976
+ // re-review after a contract revision landed) and sometimes a round the loop bought for
977
+ // nothing — the "no code delta" incident in SKILL.md step 3. Which one it is depends on
978
+ // facts the log does not hold, so this reports rather than refuses: it is the signal the
979
+ // loop-tuning pass reads, not a gate. The repeated SHA says the reviewed HEAD stood still;
980
+ // `reviewSubject` separately says whether the reviewer assessed code or evidence.
981
+ // Compared by common prefix: the same commit recorded short in one round and full in
982
+ // the next is the same HEAD, and a warning nobody sees is worth nothing. One definition,
983
+ // because `evidenceAudit` below asks the same question and two spellings would drift.
984
+ function sameHead(a, b) {
985
+ if (!nonEmptyString(a) || !nonEmptyString(b)) return false
986
+ return a.startsWith(b) || b.startsWith(a)
987
+ }
988
+
989
+ // A PASS is terminal (SKILL.md step 3). A round logged AFTER one is therefore either a
990
+ // carve-out — the diff moved: CI came back red on that head, the head was rewritten, a
991
+ // contract revision landed — or the failure this exists to name: an orchestrator spending
992
+ // rounds on findings the reviewer declined to block on, which has no stopping rule.
993
+ //
994
+ // The log DOES prove whether the head moved (#760): `headSha` is recorded on every round,
995
+ // so a round whose head differs from the one right before it proves the diff moved. What it
996
+ // does NOT prove is WHY — a genuine rebase and the failure this finding exists to name both
997
+ // move the head the same way, because closing a finding the reviewer itself classed
998
+ // non-blocking means a new commit too (see why.md's epic-563 measurement: one of #572's two
999
+ // extra cycles was a rebase, the other was exactly that, and both moved the SHA). So a moved
1000
+ // head is not an automatic carve-out — it is reported, demoted below the rounds the log truly
1001
+ // cannot explain, and labelled a probable rebase for a human to confirm from the round's own
1002
+ // notes. A round on an UNMOVED head is the opposite case: the log rules OUT "it was a
1003
+ // rebase", which leaves only a held verdict, a contract revision with no diff of its own, or
1004
+ // the failure this finding exists to name — and those three still look identical without a
1005
+ // human reading the round's own notes. So this classifies rather than refuses — every round
1006
+ // after the pass is returned, each tagged with whether its head moved relative to the round
1007
+ // before it — and leaves the caller to decide what to do with each class. `findRoundsAfterPass`
1008
+ // in postmortem.mjs is that caller: it ranks the rounds the log cannot explain any other way
1009
+ // first, and still reports the moved-head ones, demoted, rather than deleting them from the
1010
+ // signal. Named on the round that FOLLOWS the pass, because that is the one whose existence
1011
+ // has to be justified.
1012
+ export function roundsAfterPass(unit) {
1013
+ const rounds = Array.isArray(unit?.rounds) ? unit.rounds : []
1014
+ // PASS only, matching the recorded enum. `APPROVE` is the GitHub review EVENT the verdict
1015
+ // is posted as; the round log refuses it as a verdict, so accepting it here would be a
1016
+ // branch that can never run and a reader's false impression that both spellings occur.
1017
+ const firstPass = rounds.findIndex((r) => r?.verdict === 'PASS')
1018
+ if (firstPass < 0) return []
1019
+ return rounds.slice(firstPass + 1).map((entry, k) => {
1020
+ const index = firstPass + 1 + k
1021
+ const prior = rounds[index - 1]
1022
+ const headKnown = nonEmptyString(entry?.headSha) && nonEmptyString(prior?.headSha)
1023
+ // true: the log proves the head moved — probably a rebase, but the log cannot prove
1024
+ // that's WHY (a fix round closing non-blocking notes moves the head too). Reported,
1025
+ // demoted, and labelled a probable rebase rather than dropped.
1026
+ // false: the log proves the head did NOT move (the residue — ranked first, undemoted).
1027
+ // null: headSha is missing on one side, so the log cannot tell — falls back to
1028
+ // reporting it (AC5) rather than silently suppressing it.
1029
+ const headMoved = headKnown ? !sameHead(entry.headSha, prior.headSha) : null
1030
+ // `priorAt` is carried for a consumer's benefit, not this module's: it is the one piece
1031
+ // a per-round cost figure over the REPORTED rounds needs and the manifest alone does
1032
+ // not hand a caller without re-deriving this same index walk.
1033
+ return { ...entry, index, passedAt: rounds[firstPass], priorAt: prior?.at ?? null, headMoved }
1034
+ })
1035
+ }
1036
+
1037
+ // One warning per ROUND, never one per rule that matched it (#768).
1038
+ //
1039
+ // Two of the rules below overlap exactly: a round on a proven-unmoved head after a PASS
1040
+ // satisfies the same-HEAD rule and the rounds-after-pass rule at once, and used to emit a
1041
+ // line for each. Nothing counted warnings, so it cost nothing — but the count is the obvious
1042
+ // thing for a future consumer to read, and a rule-shaped count answers "how many rules fired"
1043
+ // when every caller means "how many rounds are questionable". That is a miscount waiting for
1044
+ // its first reader, so the emit side is the place to settle it: `warnings.length` is now the
1045
+ // number of rounds with something to answer for.
1046
+ //
1047
+ // Deduped by MERGING, not by dropping a rule. The two rules genuinely say different things —
1048
+ // one names the repeated HEAD and the SHA, the other names the terminal PASS and the
1049
+ // carve-outs owed — and a reader losing either would be worse off than reading both twice.
1050
+ // The overlap is exact and provable rather than heuristic: `headMoved === false` is computed
1051
+ // as `!sameHead(entry.headSha, prior.headSha)` over the same adjacent pair the same-HEAD rule
1052
+ // compares, so those indices are precisely the ones both rules claim. `headMoved === null`
1053
+ // cannot overlap — it means a headSha was missing, and `sameHead` already answers false for
1054
+ // that — so the missing-SHA branch keeps its own wording untouched.
1055
+ export function roundLogWarnings(unit) {
1056
+ const rounds = Array.isArray(unit?.rounds) ? unit.rounds : []
1057
+ const after = roundsAfterPass(unit)
1058
+ // The exact indices the same-HEAD rule would restate: proven-unmoved rounds after the pass.
1059
+ // Their merged line is emitted below, in the rounds-after-pass group.
1060
+ const mergedIntoAfterPass = new Set(after.filter((entry) => entry.headMoved === false).map((entry) => entry.index))
1061
+ const sameHeadWarnings = rounds.flatMap((entry, i) => {
1062
+ const prior = rounds[i - 1]
1063
+ if (!prior || !sameHead(entry?.headSha, prior.headSha)) return []
1064
+ if (mergedIntoAfterPass.has(i)) return []
1065
+ return [`rounds[${i}]: round ${entry.round} re-reviewed ${entry.headSha}, the same HEAD as round ${prior.round} — confirm it was worth buying (a held verdict or a landed contract revision) rather than a round spent on unmoved code`]
1066
+ })
1067
+ // Head-aware here too (#760's class fix, not just the post-mortem's instance of it) — but
1068
+ // head-aware means DEMOTED, not dropped: the log proves WHETHER a round's head moved, never
1069
+ // WHY, since a genuine rebase and a fix round that closes findings the reviewer itself
1070
+ // called non-blocking both produce a new commit (why.md's epic-563 measurement: #572 had
1071
+ // exactly one of each). A moved head is therefore still named here — ranked after the
1072
+ // rounds the log cannot explain any other way, and worded as a probable rebase rather than
1073
+ // an unjustified round.
1074
+ const unmovedOrUnknown = after.filter((entry) => entry.headMoved !== true).map((entry) => {
1075
+ // The proven-unmoved branch carries the same-HEAD rule's content too (#768): its SHA, the
1076
+ // round it repeats, and "a held verdict" as a carve-out — the one item that rule named and
1077
+ // this one did not. Merged rather than emitted twice, so the line count stays one-per-round.
1078
+ const owed = entry.headMoved === null
1079
+ ? 'one of the three carve-outs (red CI on that head, a rewritten head, a landed contract revision) — headSha is missing on one side, so the log cannot tell which'
1080
+ : 'one of the carve-outs still reachable from an unmoved head (red CI on that head, a held verdict, or a landed contract revision with no diff of its own) — the log proves this head was NOT rewritten'
1081
+ const repeated = entry.headMoved === false
1082
+ ? ` (it re-reviewed ${entry.headSha}, the same HEAD as the round before it, so no commit landed in between)`
1083
+ : ''
1084
+ return `rounds[${entry.index}]: round ${entry.round} was bought AFTER round ${entry.passedAt.round} already returned ${entry.passedAt.verdict}${repeated} — a PASS is terminal, so this round owes ${owed}. None of them is "the notes inside the PASS were worth closing": those are deferrals[] with tracking issues, not rounds`
1085
+ })
1086
+ const movedHead = after.filter((entry) => entry.headMoved === true).map((entry) => (
1087
+ `rounds[${entry.index}]: round ${entry.round} was bought AFTER round ${entry.passedAt.round} already returned ${entry.passedAt.verdict} — a PASS is terminal, and this round's head moved from the one before it, so it is probably a rebase (legitimate: the tree moved, the delta is genuinely unreviewed) — but the log cannot rule out a fix round that closed findings the reviewer itself called non-blocking, since that also produces a new commit. Confirm which in the round's own notes`
1088
+ ))
1089
+ return [...sameHeadWarnings, ...unmovedOrUnknown, ...movedHead]
1090
+ }
1091
+
1092
+ // ---- the evidence audit: which screen is missing, or which one was skipped ----
1093
+ //
1094
+ // This is the consumer that makes `evidenceClasses` worth writing. `delivery` was
1095
+ // fully mechanized, documented, and written 0 of 522 times because nothing read
1096
+ // it; the distinguishing property of a field that gets written has never been
1097
+ // mechanization, it is whether a later gate reads the value. This is that gate.
1098
+ //
1099
+ // It answers one question the handoff could not: of the rounds this run spent on
1100
+ // claims rather than code, which class recurred — and is the repair to run an
1101
+ // existing screen or to build a missing one.
1102
+ //
1103
+ // One correction the count owes the reader, added 2026-08-04. `productionMoved: false`
1104
+ // is recorded by hand, and the loop was observed reading it as "did THIS record change
1105
+ // code" rather than "has production moved since the last round". In
1106
+ // `epic-25-proposal-generator` that produced a round per loop *action* — the fix at head
1107
+ // H marked `true`, then its review at the same head H marked `false` — and 7 of 13
1108
+ // rounds landed in the evidence bucket with no class on any of them. `validate` already
1109
+ // flags every one of those (`roundLogWarnings`: same HEAD as the round before), but that
1110
+ // warning never reaches the place the evidence *number* is read, so the headline said
1111
+ // this run spent 54% of its rounds on claims when what it did was double-record.
1112
+ //
1113
+ // So the audit reports the overlap rather than guessing. It does NOT exclude these
1114
+ // rounds: a terminal review at an unmoved head is a real round with `productionMoved:
1115
+ // false` (identity #561's round 8 is one), and silently dropping them would trade an
1116
+ // overstatement for an undercount. Naming the overlap lets a reader take the headline
1117
+ // at the right strength, and gives a recurring double-record its own repair — which is
1118
+ // neither "run the screen" nor "build the screen" but "fix the recording".
1119
+ export function evidenceAudit(units) {
1120
+ const list = Array.isArray(units) ? units : [units].filter(Boolean)
1121
+ const counts = new Map()
1122
+ let evidenceRounds = 0
1123
+ let totalRounds = 0
1124
+ let unclassified = 0
1125
+ let unknownClassifications = 0
1126
+ let sameHeadAsPrevious = 0
1127
+ // epic-867 / #948: clean first-pass PASS (single PASS, no prior NEEDS_CHANGES on the
1128
+ // unit) is not an evidence-lane round. Counting it as one trained orchestrators to stuff
1129
+ // fake evidenceClasses or ignore the warn. A true evidence round is still either a
1130
+ // typed reviewSubject:"evidence" or a legacy productionMoved:false that is not that
1131
+ // first-pass happy path.
1132
+ for (const unit of list) {
1133
+ const rounds = (Array.isArray(unit?.rounds) ? unit.rounds : []).filter(isObject)
1134
+ const hasPriorNeedsChanges = rounds.some((r) => r?.verdict === 'NEEDS_CHANGES')
1135
+ for (const [i, entry] of rounds.entries()) {
1136
+ totalRounds += 1
1137
+ const metadata = classifyRoundMetadata(entry)
1138
+ const cleanFirstPass = !hasPriorNeedsChanges
1139
+ && entry.verdict === 'PASS'
1140
+ && rounds.filter((r) => r?.verdict === 'PASS').length === 1
1141
+ const evidenceRound = metadata.legacy
1142
+ ? (entry.productionMoved === false && !cleanFirstPass)
1143
+ : metadata.reviewSubject === 'evidence'
1144
+ if (!evidenceRound) continue
1145
+ evidenceRounds += 1
1146
+ if (metadata.legacy) unknownClassifications += 1
1147
+ // No commit landed between this round and the one before it, so nothing was
1148
+ // fixed in between — not even the evidence-only commit a real lane round makes.
1149
+ if (i > 0 && sameHead(entry.headSha, rounds[i - 1].headSha)) sameHeadAsPrevious += 1
1150
+ // Deduped per round because the field is counted in ROUNDS, and the finding
1151
+ // threshold is "this recurred". A class listed twice in one round would cross
1152
+ // that threshold on its own — inventing a recurrence out of a single round,
1153
+ // which is precisely the wrong signal to hand someone deciding whether to
1154
+ // build a new gate. Deduped at READ rather than refused at write: this is a
1155
+ // compatibility read. Typed writes reject duplicates and incomplete metadata;
1156
+ // legacy records remain readable so the audit can report what is unknown.
1157
+ const classes = [...new Set(Array.isArray(entry.evidenceClasses) ? entry.evidenceClasses : [])]
1158
+ if (!classes.length) { unclassified += 1; continue }
1159
+ for (const c of classes) {
1160
+ const seen = counts.get(c) || { evidenceClass: c, rounds: 0, units: new Set(), screened: SCREENED_EVIDENCE_CLASSES.includes(c) }
1161
+ seen.rounds += 1
1162
+ if (unit?.id) seen.units.add(unit.id)
1163
+ counts.set(c, seen)
1164
+ }
1165
+ }
1166
+ }
1167
+ const classes = [...counts.values()]
1168
+ .map((c) => ({ evidenceClass: c.evidenceClass, rounds: c.rounds, units: [...c.units].sort(), screened: c.screened }))
1169
+ .sort((a, b) => b.rounds - a.rounds || a.evidenceClass.localeCompare(b.evidenceClass))
1170
+ const findings = []
1171
+ for (const c of classes) {
1172
+ if (c.rounds < 2) continue
1173
+ findings.push(c.screened
1174
+ ? { evidenceClass: c.evidenceClass, rounds: c.rounds, repair: 'run-the-screen', detail: `${c.evidenceClass} cost ${c.rounds} rounds, and a screen already decides it — the screen was skipped or run without the flag that makes it resolve (coverage-checklist validate needs --diff; evidence-block validate is owed on the terminal round). Fix the call, do not add a gate.` }
1175
+ : { evidenceClass: c.evidenceClass, rounds: c.rounds, repair: 'build-the-screen', detail: `${c.evidenceClass} cost ${c.rounds} rounds and nothing mechanized decides it — this is the reviewer catching what no screen can. A candidate for a new check, with these rounds as its named incident.` })
1176
+ }
1177
+ // Recurrence threshold matched to the class findings above: two is the point at
1178
+ // which "the loop does this" beats "this happened once". Reported as a finding
1179
+ // rather than folded into the count, because the repair is a recording habit and
1180
+ // no screen — existing or new — would ever catch it.
1181
+ if (sameHeadAsPrevious >= 2) {
1182
+ findings.push({
1183
+ evidenceClass: null,
1184
+ rounds: sameHeadAsPrevious,
1185
+ repair: 'fix-the-recording',
1186
+ detail: `${sameHeadAsPrevious} of ${evidenceRounds} evidence round(s) sat at the same HEAD as the round before, so no commit landed between them — not even the evidence-only commit a real lane round makes. Read the headline as ${evidenceRounds - sameHeadAsPrevious} evidence round(s) plus ${sameHeadAsPrevious} double-record(s). The usual cause is recording one round per loop ACTION (the fix, then its review, at one head) instead of one per review verdict. run-state.mjs validate names each one.`,
1187
+ })
1188
+ }
1189
+ // Not a finding, but the number that says whether the audit itself is trustworthy:
1190
+ // an unclassified evidence round is one nobody can learn anything from.
1191
+ return { totalRounds, evidenceRounds, unclassified, unknownClassifications, sameHeadAsPrevious, classes, findings }
1192
+ }
1193
+
1194
+ // The review-round breaker, unchanged. Its return shape is load-bearing (callers and
1195
+ // tests deepEqual it), so firedOn is NOT added here — it belongs on the persisted
1196
+ // breaker object, written by record-breaker-decision.
1197
+ function pendingReviewBreaker(unit) {
1198
+ const completedRounds = completedReviewRounds(unit)
1199
+ const breaker = isObject(unit?.breaker) ? unit.breaker : null
1200
+ if (!breaker) {
1201
+ return completedRounds >= BREAKER_ROUND_LIMIT ? { completedRounds, reason: 'no-breaker-decision' } : null
1202
+ }
1203
+ if (breaker.decision === 'continue') {
1204
+ return completedRounds >= breaker.authorizedThroughRound
1205
+ ? { completedRounds, reason: 'continue-authorization-exhausted', authorizedThroughRound: breaker.authorizedThroughRound }
1206
+ : null
1207
+ }
1208
+ // design/split/descope keep blocking every transition until the unit actually
1209
+ // re-enters intake — regardless of which threshold fired, since one breaker object and
1210
+ // one decision cover both. firedOn on the object records which shape the human faced.
1211
+ if (!breaker.reenteredAt) {
1212
+ return { completedRounds, decision: breaker.decision, reason: `breaker-decision-${breaker.decision}` }
1213
+ }
1214
+ // Re-entry grants a fresh budget measured from where the breaker fired — the
1215
+ // history is evidence and is never rewritten, so the count has to be relative.
1216
+ return completedRounds - breaker.firedAtRound >= BREAKER_ROUND_LIMIT
1217
+ ? { completedRounds, reason: 'no-breaker-decision' }
1218
+ : null
1219
+ }
1220
+
1221
+ // The breaker fires on completed review rounds only. (A produce-pass threshold once fired
1222
+ // here too, counting producer/fix spawns at spawn time; it was removed because a spawn
1223
+ // that died with zero code still advanced it — see BREAKER_FIRED_ON.)
1224
+ export function pendingBreaker(unit) {
1225
+ return pendingReviewBreaker(unit)
1226
+ }
1227
+
1228
+ function throwForPendingBreaker(pending, unit, transition) {
1229
+ if (pending.reason === 'no-breaker-decision') {
1230
+ throw new Error(`${transition} blocked: ${pending.completedRounds} review rounds completed on ${unit.id} — the round circuit-breaker requires a recorded decision before round ${pending.completedRounds + 1} — run diagnose-breaker and, when it exits 0, record-breaker-decision --auto; exit 1 means this firing owes the human an answer`)
1231
+ }
1232
+ if (pending.reason === 'continue-authorization-exhausted') {
1233
+ throw new Error(`${transition} blocked: "continue" authorized rounds through ${pending.authorizedThroughRound} and ${pending.completedRounds} are complete — the breaker re-fires; record a fresh decision`)
1234
+ }
1235
+ throw new Error(`${transition} blocked: the breaker decision "${pending.decision}" routes ${unit.id} out of the loop; no fix or review transition may run until the unit re-enters intake`)
1236
+ }
1237
+
1238
+ // Gates a fix transition (record-fix-round — the transition that spends another pass).
1239
+ // The review-round breaker blocks it: a firing refuses every fix/review transition until
1240
+ // a decision is recorded.
1241
+ function assertNoPendingBreaker(unit, transition) {
1242
+ const pending = pendingBreaker(unit)
1243
+ if (pending) throwForPendingBreaker(pending, unit, transition)
1244
+ }
1245
+
1246
+ // Gates the review-verdict recorder (record-review-round). The review-round breaker gates
1247
+ // it: once BREAKER_ROUND_LIMIT completed rounds have no PASS, no further review round
1248
+ // records until a decision is taken. (record-round, the lean review recorder, stays
1249
+ // ungated — the budget binds it via read-back validation.)
1250
+ function assertNoPendingReviewBreaker(unit, transition) {
1251
+ const pending = pendingReviewBreaker(unit)
1252
+ if (pending) throwForPendingBreaker(pending, unit, transition)
1253
+ }
1254
+
1255
+ // How many rounds the unit is currently allowed to have completed. Reaching the
1256
+ // budget is a legal state — it is exactly where the breaker fires and the human is
1257
+ // asked. Recording a round BEYOND it is the violation.
1258
+ export function breakerAuthorizedRounds(unit) {
1259
+ const breaker = isObject(unit?.breaker) ? unit.breaker : null
1260
+ if (!breaker) return BREAKER_ROUND_LIMIT
1261
+ if (breaker.decision === 'continue') return breaker.authorizedThroughRound
1262
+ if (!breaker.reenteredAt) return breaker.firedAtRound
1263
+ return breaker.firedAtRound + BREAKER_ROUND_LIMIT
1264
+ }
1265
+
1266
+ // A lean-lane unit never calls record-fix-round/record-review-round — it advances
1267
+ // through `upsert-unit`, so the transition gates have no code path to sit on for the
1268
+ // majority tier and the budget is enforced on the state itself: an in-progress unit
1269
+ // past the budget with no decision is invalid.
1270
+ //
1271
+ // Statuses that genuinely owe no decision: the breaker's job is to stop further
1272
+ // rounds, and a unit that already stopped does not owe one to record that it stopped.
1273
+ // `merge-ready` is deliberately NOT among them. It is an *exit*, not a stop, and
1274
+ // exempting every non-in-progress status let an over-budget unit reach that exit
1275
+ // without the breaker ever being answered — the one transition the budget most needs
1276
+ // to bind. What this buys is precise and worth stating honestly: it does not make the
1277
+ // rounds un-bankable (a decision recorded late still sets `firedAtRound` to the rounds
1278
+ // already completed), it forces the diagnosis to be written down before a unit can
1279
+ // claim success. `queued` is likewise checked: a unit sent back through intake gets a
1280
+ // fresh budget from `reenteredAt`, so a legitimate re-entry still passes — verified
1281
+ // against both orderings of the status write and the re-entry record — while rounds
1282
+ // banked on a unit that never re-entered do not.
1283
+ const BUDGET_EXEMPT_STATUSES = ['blocked', 'skipped-already-done']
1284
+
1285
+ function validateBreakerBudget(unit) {
1286
+ if (terminalDisposition(unit) !== null) return []
1287
+ const completedRounds = completedReviewRounds(unit)
1288
+ const authorized = breakerAuthorizedRounds(unit)
1289
+ if (!Number.isInteger(authorized) || completedRounds <= authorized) return []
1290
+ return [`${completedRounds} review rounds recorded but only ${authorized} authorized — the round circuit-breaker requires a recorded decision (record-breaker-decision, or --auto) before the budget is exceeded`]
1291
+ }
1292
+
1293
+ // BACK-COMPAT: a historical `units[].producePasses` field (the removed produce-pass
1294
+ // breaker's counter, issue #382) is deliberately NOT validated — it is ignored on
1295
+ // read-back so legacy manifests still validate. No live code reads or writes it.
1296
+
1297
+ // Guarantee 3 in state: a unit exits on a fresh passing verdict, never on a claim.
1298
+ // The budget check above cannot cover this — it counts rounds, and a stuck unit's
1299
+ // round count is legal. A unit that has absorbed the full budget and is still red
1300
+ // sits at exactly BREAKER_ROUND_LIMIT (the transition gates refuse round 4), which
1301
+ // is inside the budget, so "3 rounds, last verdict NEEDS_CHANGES, status
1302
+ // merge-ready" validated clean. Verdict is the discriminator the round count isn't:
1303
+ // passing on round 3 is the ordinary happy path and must stay valid, while claiming
1304
+ // the exit on a blocking verdict is false at any round count.
1305
+ //
1306
+ // Fail-closed on the verdict itself, not just on the blocking values: an earlier
1307
+ // version only rejected `NEEDS_CHANGES`/`FAIL`/`BLOCKED`, so `merge-ready` with
1308
+ // `lastVerdict` missing entirely validated clean — a unit could claim the exit
1309
+ // having never recorded a verdict at all, which is exactly the claim-not-verdict
1310
+ // exit guarantee 3 exists to refuse. `PASS` is the only value REVIEW_VERDICTS
1311
+ // treats as non-blocking, so it is the only value this check accepts (#388 audit).
1312
+ function validateTerminalVerdict(unit) {
1313
+ if (unit?.status !== 'merge-ready') return []
1314
+ if (unit?.lastVerdict === 'PASS') return []
1315
+ return [`status is merge-ready but lastVerdict is ${unit?.lastVerdict === undefined ? 'unset' : unit.lastVerdict} — a unit exits on a fresh passing verdict against its current HEAD, never on a claim (guarantee 3); include "lastVerdict":"PASS" in this same write once a fresh independent review actually returned PASS on this unit's HEAD — never write merge-ready ahead of that verdict`]
1316
+ }
1317
+
1318
+ function validateBreakerObject(breaker, label) {
1319
+ if (!isObject(breaker)) return [`${label} must be an object`]
1320
+ const errors = Object.keys(breaker)
1321
+ .filter((key) => !['firedAtRound', 'firedOn', 'decision', 'diagnosis', 'decidedAt', 'authorizedThroughRound', 'reenteredAt', 'contractShaAtDecision', 'autoApplied', 'signal', 'classCompleteFor', 'autonomyAtDecision'].includes(key))
1322
+ .map((key) => `${label} has unknown property: ${key}`)
1323
+ // Who decided. Absent reads as false (every manifest written before 2026-07-22 was a
1324
+ // human decision), so the handoff can separate "the loop decided this" from "you did"
1325
+ // without a migration.
1326
+ if (breaker.autoApplied !== undefined && typeof breaker.autoApplied !== 'boolean') {
1327
+ errors.push(`${label}.autoApplied must be a boolean`)
1328
+ }
1329
+ if (breaker.signal !== undefined && !BREAKER_SIGNALS.includes(breaker.signal)) {
1330
+ errors.push(`${label}.signal must be one of ${BREAKER_SIGNALS.join('|')}`)
1331
+ }
1332
+ // The theme this firing mandated a class-complete fix for. It is the ONE-SHOT marker:
1333
+ // diagnoseBreaker reads it back to tell "recurring theme, remedy not yet tried" from
1334
+ // "recurring theme, remedy already spent" — the second of which routes to a human.
1335
+ if (breaker.classCompleteFor !== undefined && !nonEmptyString(breaker.classCompleteFor)) {
1336
+ errors.push(`${label}.classCompleteFor must be a non-empty theme id`)
1337
+ }
1338
+ if (breaker.classCompleteFor !== undefined && breaker.decision !== 'continue') {
1339
+ errors.push(`${label}.classCompleteFor applies only to a "continue" decision — it mandates how the authorized round is spent`)
1340
+ }
1341
+ // An autonomous decision may only ever be one of the reversible ones. This is the
1342
+ // scope guard in state: even a caller that hand-writes the manifest cannot record the
1343
+ // loop having decided to split or descope a unit on its own.
1344
+ if (breaker.autoApplied === true && !autoDecisionAllowed(breaker.decision, breaker.autonomyAtDecision)) {
1345
+ errors.push(`${label}.decision "${breaker.decision}" cannot be autoApplied${breaker.decision === 'split' ? ' without `autonomyAtDecision: "autonomous"` recorded on the same breaker entry' : ''} — ${AUTO_BREAKER_DECISIONS.join('/')} are always the loop's, ${AUTONOMOUS_ONLY_AUTO_DECISIONS.join('/')} only under autonomy "autonomous"; descope delivers less than was asked and stays the human's call at every level`)
1346
+ }
1347
+ // The unit's contract pointer AT FIRE TIME, so a later re-entry can prove the design
1348
+ // input actually moved. `null` records "no contract pointer when this fired" — a real
1349
+ // state, and distinct from a legacy manifest written before this field existed (absent,
1350
+ // read as null by recordIntakeReentry).
1351
+ if (breaker.contractShaAtDecision !== undefined && breaker.contractShaAtDecision !== null && !nonEmptyString(breaker.contractShaAtDecision)) {
1352
+ errors.push(`${label}.contractShaAtDecision must be a non-empty sha or null`)
1353
+ }
1354
+ // firedOn is optional (a manifest written before #382 has none — it reads back as a
1355
+ // review-rounds firing) but must be a known value when present.
1356
+ if (breaker.firedOn !== undefined && !BREAKER_FIRED_ON.includes(breaker.firedOn)) {
1357
+ errors.push(`${label}.firedOn must be one of ${BREAKER_FIRED_ON.join('|')}`)
1358
+ }
1359
+ // BACK-COMPAT: a legacy `firedOn: 'produce-passes'` firing could happen with zero
1360
+ // completed reviews (the #376 shape), so firedAtRound was allowed to be 0 there. Kept so
1361
+ // those manifests still read back; new firings are always review-rounds, floor 1.
1362
+ const firedAtRoundFloor = breaker.firedOn === 'produce-passes' ? 0 : 1
1363
+ if (!Number.isInteger(breaker.firedAtRound) || breaker.firedAtRound < firedAtRoundFloor) {
1364
+ errors.push(`${label}.firedAtRound must be a ${firedAtRoundFloor === 0 ? 'non-negative' : 'positive'} integer`)
1365
+ }
1366
+ if (!BREAKER_DECISIONS.includes(breaker.decision)) errors.push(`${label}.decision must be one of ${BREAKER_DECISIONS.join('|')}`)
1367
+ // The breaker's whole purpose is that the ask leads with a diagnosis, never a
1368
+ // neutral menu (SKILL.md); an undiagnosed decision is the failure it guards.
1369
+ if (!nonEmptyString(breaker.diagnosis)) errors.push(`${label}.diagnosis is required — the breaker hands a diagnosis, not a menu`)
1370
+ if (!nonEmptyString(breaker.decidedAt)) errors.push(`${label}.decidedAt is required`)
1371
+ if (breaker.decision === 'continue') {
1372
+ if (breaker.authorizedThroughRound !== breaker.firedAtRound + 1) {
1373
+ errors.push(`${label}.authorizedThroughRound must be firedAtRound + 1 — "continue" authorizes exactly one more round`)
1374
+ }
1375
+ if (breaker.reenteredAt !== undefined) errors.push(`${label}.reenteredAt applies only to a design/split/descope decision`)
1376
+ } else {
1377
+ if (breaker.authorizedThroughRound !== undefined) errors.push(`${label}.authorizedThroughRound applies only to a "continue" decision`)
1378
+ if (breaker.reenteredAt !== undefined && !nonEmptyString(breaker.reenteredAt)) errors.push(`${label}.reenteredAt must be a non-empty timestamp`)
1379
+ }
1380
+ return errors
1381
+ }
1382
+
1383
+ export function validateBreaker(unit) {
1384
+ const errors = []
1385
+ if (unit?.breaker !== undefined) errors.push(...validateBreakerObject(unit.breaker, 'breaker'))
1386
+ // The append-only audit log of prior firings (see recordBreakerDecision). Each
1387
+ // entry is a complete past breaker object; the log is validated with the same bar.
1388
+ if (unit?.breakerLog !== undefined) {
1389
+ if (!Array.isArray(unit.breakerLog)) errors.push('breakerLog must be an array')
1390
+ else unit.breakerLog.forEach((entry, i) => errors.push(...validateBreakerObject(entry, `breakerLog[${i}]`)))
1391
+ }
1392
+ return errors
1393
+ }
1394
+
1395
+ function validateCoverage(coverage, theme) {
1396
+ const errors = []
1397
+ if (!Array.isArray(coverage)) return ['contract-complete round requires coverage for every state/task-kind cell']
1398
+ const expected = stateSpaceCells(theme)
1399
+ const seen = new Set()
1400
+ for (const cell of coverage) {
1401
+ if (!isObject(cell)) {
1402
+ errors.push('coverage cells require state and taskKind')
1403
+ continue
1404
+ }
1405
+ errors.push(...Object.keys(cell).filter((key) => !['state', 'taskKind', 'outcome', 'test', 'evidence'].includes(key)).map((key) => `coverage cell has unknown property: ${key}`))
1406
+ if (!nonEmptyString(cell.state) || !nonEmptyString(cell.taskKind)) {
1407
+ errors.push('coverage cells require non-empty state and taskKind')
1408
+ continue
1409
+ }
1410
+ if (cell.outcome !== 'accept' && cell.outcome !== 'reject') errors.push(`coverage for ${cell.state} × ${cell.taskKind} requires an accept/reject outcome`)
1411
+ const key = cellKey(cell)
1412
+ if (seen.has(key)) errors.push(`duplicate coverage for ${cell.state} × ${cell.taskKind}`)
1413
+ seen.add(key)
1414
+ if (!isLocator(cell.test)) errors.push(`coverage for ${cell.state} × ${cell.taskKind} requires a checkable test locator`)
1415
+ if (!isLocator(cell.evidence)) errors.push(`coverage for ${cell.state} × ${cell.taskKind} requires a checkable evidence locator`)
1416
+ }
1417
+ for (const cell of expected) {
1418
+ if (!seen.has(cellKey(cell))) errors.push(`missing coverage for ${cell.state} × ${cell.taskKind}`)
1419
+ }
1420
+ for (const cell of coverage) {
1421
+ if (isObject(cell) && nonEmptyString(cell.state) && nonEmptyString(cell.taskKind) && !expected.some((expectedCell) => cellKey(expectedCell) === cellKey(cell))) {
1422
+ errors.push(`coverage includes an undeclared state/task-kind cell: ${cell.state} × ${cell.taskKind}`)
1423
+ }
1424
+ }
1425
+ return errors
1426
+ }
1427
+
1428
+ function validateAdjacentReview(selfReview, theme) {
1429
+ const errors = []
1430
+ if (!isObject(selfReview) || Object.keys(selfReview).some((key) => key !== 'adjacentStates')) {
1431
+ errors.push('self-review must contain only adjacentStates')
1432
+ }
1433
+ const adjacent = selfReview?.adjacentStates
1434
+ if (!Array.isArray(adjacent)) return [...errors, 'self-review must provide one adjacent-state entry per declared state']
1435
+ const seen = new Set()
1436
+ for (const entry of adjacent) {
1437
+ if (!isObject(entry) || !nonEmptyString(entry.state)) {
1438
+ errors.push('adjacent-state review entries require a non-empty state')
1439
+ continue
1440
+ }
1441
+ errors.push(...Object.keys(entry).filter((key) => !['state', 'evidence'].includes(key)).map((key) => `adjacent-state review has unknown property: ${key}`))
1442
+ if (seen.has(entry.state)) errors.push(`duplicate adjacent-state review: ${entry.state}`)
1443
+ seen.add(entry.state)
1444
+ if (!isLocator(entry.evidence)) errors.push(`adjacent-state review for ${entry.state} requires a checkable evidence locator`)
1445
+ if (!theme.stateSpace.states.includes(entry.state)) errors.push(`adjacent-state review includes undeclared state: ${entry.state}`)
1446
+ }
1447
+ for (const state of theme.stateSpace.states) {
1448
+ if (!seen.has(state)) errors.push(`missing adjacent-state review for declared state: ${state}`)
1449
+ }
1450
+ return errors
1451
+ }
1452
+
1453
+ // Validate the durable, canonical fix record. The caller-facing proof is
1454
+ // validated by validateFixProof and never contains a redeclared theme.
1455
+ export function validateContractComplete(round, expectedTheme = null) {
1456
+ const errors = []
1457
+ if (!isObject(round)) return ['fix round must be an object']
1458
+ errors.push(...Object.keys(round).filter((key) => !['round', 'reviewRound', 'themeId', 'theme', 'coverage', 'selfReview'].includes(key)).map((key) => `fix round has unknown property: ${key}`))
1459
+ if (!Number.isInteger(round.round) || round.round < 1) errors.push('fix round must be a positive integer')
1460
+ if (!Number.isInteger(round.reviewRound) || round.reviewRound < 1) errors.push('fix round reviewRound must be a positive integer')
1461
+ if (!nonEmptyString(round.themeId)) errors.push('fix round themeId must be a non-empty string')
1462
+ let theme = expectedTheme
1463
+ if (!theme) {
1464
+ try { theme = themeFromPersisted(round.theme) } catch (error) { errors.push(error.message) }
1465
+ }
1466
+ if (theme) {
1467
+ errors.push(...validateTheme(theme))
1468
+ if (round.themeId !== theme.id) errors.push(`fix round themeId ${round.themeId} does not match canonical theme ${theme.id}`)
1469
+ if (CONTRACT_COMPLETE_KINDS.has(theme.kind)) {
1470
+ errors.push(...validateCoverage(round.coverage, theme))
1471
+ errors.push(...validateAdjacentReview(round.selfReview, theme))
1472
+ }
1473
+ }
1474
+ return errors
1475
+ }
1476
+
1477
+ export function validateFixProof(proof, expectedTheme) {
1478
+ const errors = []
1479
+ if (!isObject(proof)) return ['fix proof must be an object']
1480
+ errors.push(...Object.keys(proof).filter((key) => !['round', 'reviewRound', 'themeId', 'coverage', 'selfReview'].includes(key)).map((key) => `fix proof has unknown property: ${key}`))
1481
+ if (!Number.isInteger(proof.round) || proof.round < 1) errors.push('fix round must be a positive integer')
1482
+ if (!Number.isInteger(proof.reviewRound) || proof.reviewRound < 1) errors.push('fix round reviewRound must be a positive integer')
1483
+ if (!nonEmptyString(proof.themeId)) errors.push('fix proof themeId must be a non-empty string')
1484
+ if (!expectedTheme) return [...errors, 'fix proof must resolve a reviewed theme']
1485
+ if (proof.themeId !== expectedTheme.id) errors.push(`fix proof themeId ${proof.themeId} is not declared by review round`)
1486
+ if (CONTRACT_COMPLETE_KINDS.has(expectedTheme.kind)) {
1487
+ errors.push(...validateCoverage(proof.coverage, expectedTheme))
1488
+ errors.push(...validateAdjacentReview(proof.selfReview, expectedTheme))
1489
+ }
1490
+ return errors
1491
+ }
1492
+
1493
+ function validateReviewRoundShape(round) {
1494
+ if (!isObject(round)) return ['round must be an object']
1495
+ const hasThemes = Object.prototype.hasOwnProperty.call(round, 'themes')
1496
+ const hasFindings = Object.prototype.hasOwnProperty.call(round, 'findings')
1497
+ // `oracleEscalation` is tolerated and ignored, never written — history from the
1498
+ // removed oracle slot must still read back rather than fail closed.
1499
+ // `headSha` accepted on deep history too (#792) — same dual-schema trap as the write path.
1500
+ const allowed = hasThemes
1501
+ ? ['round', 'verdict', 'themes', 'oracleEscalation', 'round1Depth', 'headSha']
1502
+ : ['round', 'verdict', 'findings', 'oracleEscalation', 'round1Depth', 'headSha']
1503
+ const errors = Object.keys(round).filter((key) => !allowed.includes(key)).map((key) => `unknown property: ${key}`)
1504
+ if (!Number.isInteger(round.round) || round.round < 1) errors.push('round must be a positive integer')
1505
+ if (!REVIEW_VERDICTS.includes(round.verdict)) errors.push(`invalid verdict ${round.verdict}`)
1506
+ if (hasThemes === hasFindings) errors.push('round must contain exactly one themes or findings array')
1507
+ if (hasThemes && !Array.isArray(round.themes)) errors.push('themes must be an array')
1508
+ if (hasFindings && !Array.isArray(round.findings)) errors.push('findings must be an array')
1509
+ if (round.round1Depth !== undefined) errors.push(...validateRound1Depth(round.round1Depth))
1510
+ return errors
1511
+ }
1512
+
1513
+ // This mirrors the import-free workflow's round1Depth schema. The workflow
1514
+ // result is not a generic review document: it is a transition envelope with
1515
+ // two mandatory depth obligations that must survive into durable history.
1516
+ export function validateRound1Depth(depth) {
1517
+ if (!isObject(depth)) return ['round1Depth must be an object']
1518
+ const errors = Object.keys(depth)
1519
+ .filter((key) => !ROUND1_DEPTH_OBLIGATIONS.includes(key))
1520
+ .map((key) => `round1Depth has unknown property: ${key}`)
1521
+ for (const obligation of ROUND1_DEPTH_OBLIGATIONS) {
1522
+ const result = depth[obligation]
1523
+ if (!isObject(result)) {
1524
+ errors.push(`round1Depth.${obligation} must be an object`)
1525
+ continue
1526
+ }
1527
+ errors.push(...Object.keys(result)
1528
+ .filter((key) => !['status', 'evidence'].includes(key))
1529
+ .map((key) => `round1Depth.${obligation} has unknown property: ${key}`))
1530
+ if (!['completed', 'not-applicable'].includes(result.status)) {
1531
+ errors.push(`round1Depth.${obligation}.status must be completed|not-applicable`)
1532
+ }
1533
+ if (!nonEmptyString(result.evidence)) errors.push(`round1Depth.${obligation}.evidence must be a non-empty string`)
1534
+ }
1535
+ return errors
1536
+ }
1537
+
1538
+ function owedThemes(history, review) {
1539
+ if (review?.verdict !== 'NEEDS_CHANGES') return []
1540
+ return themesInRound(review).filter((theme) => CONTRACT_COMPLETE_KINDS.has(theme.kind))
1541
+ }
1542
+
1543
+ function validateFixReferences(unit, history, errors) {
1544
+ const fixes = Array.isArray(unit.fixRounds) ? unit.fixRounds : []
1545
+ const seenKeys = new Set()
1546
+ let previousFixRound = 0
1547
+ let previousReviewRound = 0
1548
+ for (const [index, fix] of fixes.entries()) {
1549
+ const prefix = `fixRounds[${index}]`
1550
+ errors.push(...validateContractComplete(fix).map((error) => `${prefix}: ${error}`))
1551
+ if (Number.isInteger(fix?.round)) {
1552
+ if (fix.round < previousFixRound) errors.push(`${prefix}: fix rounds must be append-only and ordered`)
1553
+ previousFixRound = fix.round
1554
+ }
1555
+ if (Number.isInteger(fix?.reviewRound)) {
1556
+ if (fix.reviewRound < previousReviewRound) errors.push(`${prefix}: review rounds referenced by fixes must be ordered`)
1557
+ previousReviewRound = fix.reviewRound
1558
+ }
1559
+ if (isObject(fix) && nonEmptyString(fix.themeId) && Number.isInteger(fix.reviewRound)) {
1560
+ const key = proofKey(fix)
1561
+ if (seenKeys.has(key)) errors.push(`${prefix}: duplicate fix proof for review round ${fix.reviewRound} and theme ${fix.themeId}`)
1562
+ seenKeys.add(key)
1563
+ const review = history.find((entry) => entry?.round === fix.reviewRound)
1564
+ if (!review) {
1565
+ errors.push(`${prefix}: fix proof has no referenced review round ${fix.reviewRound}`)
1566
+ continue
1567
+ }
1568
+ let reviewedThemes = []
1569
+ try { reviewedThemes = themesInRound(review) } catch (error) { errors.push(`${prefix}: cannot resolve referenced review: ${error.message}`); continue }
1570
+ const reviewed = reviewedThemes.find((theme) => theme.id === fix.themeId)
1571
+ if (!reviewed) errors.push(`${prefix}: fix proof theme ${fix.themeId} is not declared by review round ${fix.reviewRound}`)
1572
+ else if (themeFingerprint(reviewed) !== themeFingerprint({ ...fix.theme, id: fix.theme?.themeId })) errors.push(`${prefix}: fix proof theme snapshot drift for ${fix.themeId}`)
1573
+ }
1574
+ }
1575
+ for (let index = 0; index < history.length - 1; index++) {
1576
+ const review = history[index]
1577
+ const owed = owedThemes(history, review)
1578
+ for (const theme of owed) {
1579
+ if (!fixes.some((fix) => fix.reviewRound === review.round && fix.themeId === theme.id)) errors.push(`reviewHistory[${index}]: missing contract-complete fix proof for ${theme.id} before next review`)
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ function convertLegacyReview(review, warnings = []) {
1585
+ if (!isObject(review)) throw new Error('legacy review round must be an object')
1586
+ if (Object.prototype.hasOwnProperty.call(review, 'themes')) {
1587
+ throw new Error('legacy review round cannot contain current themes; reconstruct it explicitly')
1588
+ }
1589
+ const allowed = ['round', 'verdict', 'findings', 'oracleEscalation']
1590
+ const unknown = Object.keys(review).filter((key) => !allowed.includes(key))
1591
+ if (unknown.length) throw new Error(`legacy review round has unknown property: ${unknown[0]}`)
1592
+ if (!Array.isArray(review.findings)) throw new Error('legacy review round requires findings')
1593
+ // An obsolete key on a legacy finding is dropped, not refused — surface it the same
1594
+ // way validate() surfaces obsolete slots so the migration is visible, not silent.
1595
+ review.findings.forEach((finding, index) => {
1596
+ for (const warning of findingWarnings(finding, index)) warnings.push(`round ${review.round}: ${warning}`)
1597
+ })
1598
+ const findings = review.findings.map((finding) => migrateLegacyFinding(finding, { version: 1 }))
1599
+ const themes = uniqueThemes(findings.map(themeFromFinding)).map(persistedTheme)
1600
+ // A legacy `oracleEscalation` is read and dropped, never carried into v2 —
1601
+ // migrating it forward would persist state nothing reads.
1602
+ return {
1603
+ round: review.round,
1604
+ verdict: review.verdict,
1605
+ themes,
1606
+ }
1607
+ }
1608
+
1609
+ function convertLegacyUnitState(unit, warnings = []) {
1610
+ if (!isObject(unit)) throw new Error('legacy unit must be an object')
1611
+ if (unit.stateVersion !== undefined) throw new Error('legacy migration requires a unit without stateVersion')
1612
+ if (Object.prototype.hasOwnProperty.call(unit, 'reviewHistory') && !Array.isArray(unit.reviewHistory)) {
1613
+ throw new Error('legacy reviewHistory must be an array when present')
1614
+ }
1615
+ if (Object.prototype.hasOwnProperty.call(unit, 'fixRounds') && !Array.isArray(unit.fixRounds)) {
1616
+ throw new Error('legacy fixRounds must be an array when present')
1617
+ }
1618
+ const history = Array.isArray(unit.reviewHistory) ? unit.reviewHistory.map((review) => convertLegacyReview(review, warnings)) : []
1619
+ const fixes = Array.isArray(unit.fixRounds) ? unit.fixRounds.map((fix) => {
1620
+ if (!isObject(fix)) throw new Error('legacy fix round must be an object')
1621
+ const allowed = ['round', 'theme', 'coverage', 'selfReview']
1622
+ const unknown = Object.keys(fix).filter((key) => !allowed.includes(key))
1623
+ if (unknown.length) throw new Error(`legacy fix round has unknown/current property: ${unknown[0]}`)
1624
+ if (fix.reviewRound !== undefined || fix.themeId !== undefined) throw new Error('legacy fix round has current identity; reconstruct it explicitly')
1625
+ const legacyTheme = fix.theme
1626
+ if (!isObject(legacyTheme)) throw new Error(`legacy fix round ${fix.round} requires a theme`)
1627
+ const candidates = history.flatMap((review) => {
1628
+ let themes = []
1629
+ try { themes = themesInRound(review) } catch { return [] }
1630
+ return themes.filter((theme) => themeFingerprint(theme) === themeFingerprint(legacyTheme)).map((theme) => ({ reviewRound: review.round, theme }))
1631
+ })
1632
+ if (candidates.length !== 1) throw new Error(`legacy fix round ${fix.round} does not map uniquely to one reviewed theme; reconstruct it explicitly`)
1633
+ const { reviewRound, theme } = candidates[0]
1634
+ return {
1635
+ round: fix.round,
1636
+ reviewRound,
1637
+ themeId: theme.id,
1638
+ theme: persistedTheme(theme),
1639
+ coverage: clone(fix.coverage),
1640
+ selfReview: clone(fix.selfReview),
1641
+ }
1642
+ }) : undefined
1643
+ const out = clone(unit)
1644
+ out.reviewHistory = history
1645
+ if (fixes) out.fixRounds = fixes
1646
+ if (out.status === 'skipped-already-done' && !out.scopeDecision) out.disposition = 'already-done'
1647
+ else if (out.delivery?.outcome === 'merged') out.disposition = 'delivered'
1648
+ else if (out.delivery?.outcome === 'handed-back') out.disposition = 'handed-back'
1649
+ else if (out.status === 'blocked') out.disposition = 'blocked'
1650
+ out.stateVersion = 2
1651
+ return out
1652
+ }
1653
+
1654
+ // The legacy hand-off re-enters validateUnitRoundState on the migrated unit, so it has to
1655
+ // carry the run's delivery mode through. Dropping it made the re-entry silently re-validate
1656
+ // delivery against the DEFAULT 'direct' mode: a lean unit (the majority tier — versionless,
1657
+ // so always routed here) on a queue-mode run had every legal `enqueue` reported as an
1658
+ // illegal direct action, while validateDelivery on its own returned clean.
1659
+ function validateLegacyUnitState(unit, mode, provider = null) {
1660
+ try {
1661
+ return validateUnitRoundState(convertLegacyUnitState(unit), mode, provider)
1662
+ } catch (error) {
1663
+ return [error.message]
1664
+ }
1665
+ }
1666
+
1667
+ // `mode` is the run's delivery mode; it defaults to 'direct' for the same reason
1668
+ // `deliveryMode()` does — a manifest with no mode recorded ran the legacy merge path.
1669
+ // Callers holding the manifest (run-state validate, the settle path) pass the real one.
1670
+ // Known review-round numbers for a unit — the lean round log (`rounds[]`) and/or deep
1671
+ // `reviewHistory`. Used by lean fix referential integrity: a leanFixRound.reviewRound must
1672
+ // point at a review that actually exists (#792 / review finding lean-fix-round.referential-integrity).
1673
+ export function knownReviewRoundNumbers(unit) {
1674
+ const known = new Set()
1675
+ if (Array.isArray(unit?.rounds)) {
1676
+ for (const entry of unit.rounds) {
1677
+ if (Number.isInteger(entry?.round) && entry.round >= 1) known.add(entry.round)
1678
+ }
1679
+ }
1680
+ if (Array.isArray(unit?.reviewHistory)) {
1681
+ for (const entry of unit.reviewHistory) {
1682
+ if (Number.isInteger(entry?.round) && entry.round >= 1) known.add(entry.round)
1683
+ }
1684
+ }
1685
+ return known
1686
+ }
1687
+
1688
+ // Lean fix log integrity: each leanFixRounds[i].reviewRound must refer to a known review
1689
+ // for that unit. Orphan references make the manifest claim a fix closed findings from a
1690
+ // review that never happened.
1691
+ export function validateLeanFixRounds(unit) {
1692
+ const errors = []
1693
+ if (unit?.leanFixRounds === undefined) return errors
1694
+ if (!Array.isArray(unit.leanFixRounds)) return ['leanFixRounds must be an array']
1695
+ const known = knownReviewRoundNumbers(unit)
1696
+ for (const [i, entry] of unit.leanFixRounds.entries()) {
1697
+ const at = `leanFixRounds[${i}]`
1698
+ if (!isObject(entry)) { errors.push(`${at} must be an object`); continue }
1699
+ if (!Number.isInteger(entry.reviewRound) || entry.reviewRound < 1) {
1700
+ errors.push(`${at}.reviewRound must be a positive integer`)
1701
+ continue
1702
+ }
1703
+ if (!known.has(entry.reviewRound)) {
1704
+ errors.push(`${at}: reviewRound ${entry.reviewRound} has no matching review round on this unit (rounds[] / reviewHistory)`)
1705
+ }
1706
+ if (entry.changedSurfaces !== undefined) {
1707
+ try { normalizeChangedSurfaces(entry.changedSurfaces) } catch (error) { errors.push(`${at}.${error.message}`) }
1708
+ if (entry.productionMoved !== undefined) errors.push(`${at}.productionMoved is legacy-only and cannot accompany changedSurfaces`)
1709
+ } else if (typeof entry.productionMoved !== 'boolean') {
1710
+ errors.push(`${at} requires changedSurfaces; legacy records require productionMoved: boolean and read as unknown`)
1711
+ }
1712
+ }
1713
+ return errors
1714
+ }
1715
+
1716
+ export function validateUnitRoundState(unit, mode = 'direct', provider = null) {
1717
+ const errors = []
1718
+ if (!unit || typeof unit !== 'object' || Array.isArray(unit)) return ['unit must be an object']
1719
+ // Before any version routing: the budget binds every tier, and a lean unit is
1720
+ // often versionless — checking after the legacy hand-off would skip exactly the
1721
+ // units that have no transition-command gate to fall back on.
1722
+ const budgetErrors = [...validateBreakerBudget(unit), ...validateTerminalVerdict(unit), ...validateDelivery(unit, mode, provider)]
1723
+ // The round log is checked before version routing for the same reason as the budget:
1724
+ // it is the lean lane's record, and lean units are often versionless.
1725
+ const roundLogErrors = validateRoundLog(unit)
1726
+ // Lean fix referential integrity is lane-agnostic: orphan leanFixRounds must fail closed
1727
+ // on both versionless and deep units (the field is not gated on stateVersion).
1728
+ const leanFixErrors = validateLeanFixRounds(unit)
1729
+ // The legacy hand-off re-enters this function on the migrated unit, which recomputes
1730
+ // the budget/verdict errors — passing ours through as well printed each one twice.
1731
+ if (unit.stateVersion === undefined) return [...new Set([...budgetErrors, ...roundLogErrors, ...leanFixErrors, ...validateLegacyUnitState(unit, mode, provider)])]
1732
+ errors.push(...roundLogErrors, ...leanFixErrors)
1733
+ if (unit.stateVersion !== undefined && unit.stateVersion !== 2) errors.push('stateVersion must be 2; run explicit migrate-unit --version 1 for legacy state')
1734
+ if (unit.fixRounds !== undefined && !Array.isArray(unit.fixRounds)) errors.push('fixRounds must be an array')
1735
+ if (unit.reviewHistory !== undefined && !Array.isArray(unit.reviewHistory)) errors.push('reviewHistory must be an array')
1736
+ const history = Array.isArray(unit.reviewHistory) ? unit.reviewHistory : []
1737
+ let previousRound = 0
1738
+ const knownThemes = new Map()
1739
+ history.forEach((round, index) => {
1740
+ errors.push(...validateReviewRoundShape(round).map((error) => `reviewHistory[${index}]: ${error}`))
1741
+ if (Number.isInteger(round?.round)) {
1742
+ if (round.round !== previousRound + 1) errors.push(`reviewHistory[${index}]: rounds must start at 1 and advance exactly by one`)
1743
+ previousRound = round.round
1744
+ }
1745
+ let themes = []
1746
+ try { themes = uniqueThemes(themesInRound(round)) } catch (error) { errors.push(`reviewHistory[${index}]: ${error.message}`) }
1747
+ for (const theme of themes) {
1748
+ const fingerprint = themeFingerprint(theme)
1749
+ const prior = knownThemes.get(theme.id)
1750
+ if (prior && prior !== fingerprint) errors.push(`reviewHistory[${index}]: theme contract/state-space drift for ${theme.id}`)
1751
+ knownThemes.set(theme.id, fingerprint)
1752
+ }
1753
+ })
1754
+ validateFixReferences(unit, history, errors)
1755
+ errors.push(...budgetErrors, ...validateBreaker(unit))
1756
+ return errors
1757
+ }
1758
+
1759
+ function assertMigratedUnit(unit, transition) {
1760
+ if (unit?.stateVersion === undefined) {
1761
+ throw new Error(`${transition} rejects legacy unit state without stateVersion; run explicit migrate-unit --version 1`)
1762
+ }
1763
+ if (unit.stateVersion !== 2) {
1764
+ throw new Error(`${transition} requires stateVersion 2; run explicit migrate-unit --version 1 for legacy state`)
1765
+ }
1766
+ }
1767
+
1768
+ function candidateOrThrow(manifest, unitId, out) {
1769
+ const { units } = unitOrThrow(out, unitId)
1770
+ const unit = units.find((entry) => entry.id === unitId)
1771
+ assertMigratedUnit(unit, 'candidate state')
1772
+ const errors = validateUnitRoundState(unit, deliveryMode(out), deliveryProvider(out))
1773
+ if (errors.length) throw new Error(`candidate state is invalid: ${errors[0]}`)
1774
+ return out
1775
+ }
1776
+
1777
+ // The breaker binds BOTH lanes — completedReviewRounds folds in the lean round log
1778
+ // precisely so a unit that logs rounds without touching reviewHistory still hits the
1779
+ // budget — so *clearing* it has to be reachable from both. Routing the decision
1780
+ // through candidateOrThrow made it stateVersion-2-only, while the documented
1781
+ // unit-creation call (references/run-state.md) writes no stateVersion: the majority
1782
+ // tier could fire the breaker and never clear it, dead-ending at round 3 for exactly
1783
+ // the units it was built for. A dead-ended gate is worse than an absent one — it
1784
+ // gives the orchestrator a reason to stop recording state, and an orchestrator that
1785
+ // stops recording is one no later gate can refuse. Same reasoning as recordRound's,
1786
+ // one transition later.
1787
+ // The discriminator is the ABSENCE of deep-lane state, not `stateVersion` alone: a
1788
+ // versionless unit carrying reviewHistory/fixRounds is a deep-lane unit that never got
1789
+ // migrated, and sending it down the lean path would skip exactly the validation those
1790
+ // fields need. And the lean path still owes the budget check — this module's contract
1791
+ // (see the header) is that the CLI cannot persist a state the module would reject on
1792
+ // read-back, so a write that succeeds while `validate` goes red is the dead-end shape
1793
+ // this change set exists to remove, not to relocate.
1794
+ function settleBreakerOrThrow(manifest, unitId, out) {
1795
+ const { units } = unitOrThrow(out, unitId)
1796
+ const unit = units.find((entry) => entry.id === unitId)
1797
+ const isLean = unit?.stateVersion === undefined && unit?.reviewHistory === undefined && unit?.fixRounds === undefined
1798
+ if (!isLean) return candidateOrThrow(manifest, unitId, out)
1799
+ // Include lean-fix referential integrity so a write that would fail validate on
1800
+ // read-back cannot persist (same contract as the deep path's candidateOrThrow).
1801
+ const errors = [
1802
+ ...validateRoundLog(unit),
1803
+ ...validateLeanFixRounds(unit),
1804
+ ...validateBreaker(unit),
1805
+ ...validateBreakerBudget(unit),
1806
+ ...validateDelivery(unit, deliveryMode(out), deliveryProvider(out)),
1807
+ ]
1808
+ if (errors.length) throw new Error(`candidate state is invalid: ${errors[0]}`)
1809
+ return out
1810
+ }
1811
+
1812
+ // ---- lean vs deep recording (#792 / issue-664 / identity#669) ----
1813
+ //
1814
+ // The deep-audit matrix (reviewHistory themes + fixRounds coverage/selfReview) is the
1815
+ // high-risk protocol's optional mechanization (high-risk.md). Routine units only need
1816
+ // the lean round log. Before #792, record-review-round rejected `headSha` as unknown and
1817
+ // record-fix-round required migrate-unit + the full matrix even for an env-guard fix —
1818
+ // schema thrash that burned turns on identity#669.
1819
+ //
1820
+ // Lean shapes (no stateVersion / no deep fields required):
1821
+ // record-review-round { round, verdict, headSha?, scope?, themeIds?, findings?: lean[] }
1822
+ // record-fix-round { reviewRound, headSha, themeIds[], changedSurfaces?, classesClosed? }
1823
+ // Deep shape is unchanged: full finding.theme objects + coverage/selfReview matrix.
1824
+ // A payload that carries the deep fields (coverage, selfReview, or findings with theme
1825
+ // objects) takes the deep path and still requires stateVersion 2.
1826
+
1827
+ const LEAN_REVIEW_FIELDS = new Set([
1828
+ 'round', 'verdict', 'headSha', 'scope', 'themeIds', 'findings', 'summary',
1829
+ 'overall_correctness', 'overMockFindings', 'previousFindingsResolution',
1830
+ 'reviewModel', 'independent', 'contractValidates', 'round1Depth',
1831
+ 'changedSurfaces', 'reviewSubject', 'evidenceClasses', 'reopenReason', 'authorizationBoundary',
1832
+ ])
1833
+
1834
+ const LEAN_FIX_FIELDS = new Set([
1835
+ 'round', 'reviewRound', 'headSha', 'themeIds', 'themeId', 'changedSurfaces', 'productionMoved',
1836
+ 'classesClosed', 'coverage', 'selfReview',
1837
+ ])
1838
+
1839
+ function isLeanFinding(finding) {
1840
+ if (!isObject(finding)) return false
1841
+ // A deep finding carries a full theme object (kind/contract/stateSpace). A lean one
1842
+ // carries at most themeId (+ file/line/subject/confidence).
1843
+ return finding.theme === undefined
1844
+ }
1845
+
1846
+ function isDeepReviewPayload(review) {
1847
+ if (!isObject(review)) return false
1848
+ if (review.round1Depth !== undefined) return true
1849
+ if (Array.isArray(review.findings) && review.findings.some((f) => isObject(f) && f.theme !== undefined)) return true
1850
+ // Full review-document fields imply the deep path (workflow / independent-review envelope).
1851
+ if (review.overMockFindings !== undefined || review.previousFindingsResolution !== undefined) return true
1852
+ if (review.contractValidates !== undefined || review.independent !== undefined) return true
1853
+ return false
1854
+ }
1855
+
1856
+ // Lean signature: headSha present, no deep finding.theme objects, no deep envelope fields.
1857
+ // Without headSha, `{round, verdict, findings:[]}` is the pre-#792 deep API and must keep
1858
+ // requiring stateVersion 2 / migrate-unit — otherwise legacy rejection tests (and real deep
1859
+ // callers) silently become lean round-log writes.
1860
+ function isLeanReviewPayload(review) {
1861
+ if (!isObject(review)) return false
1862
+ if (isDeepReviewPayload(review)) return false
1863
+ if (!nonEmptyString(review.headSha)) return false
1864
+ if (Array.isArray(review.findings) && review.findings.some((f) => isObject(f) && !isLeanFinding(f))) return false
1865
+ return true
1866
+ }
1867
+
1868
+ function isDeepFixPayload(proof) {
1869
+ if (!isObject(proof)) return false
1870
+ return proof.coverage !== undefined || proof.selfReview !== undefined
1871
+ }
1872
+
1873
+ function validateLeanReviewFinding(finding, index = 0) {
1874
+ if (!isObject(finding)) return [`findings[${index}] must be an object`]
1875
+ const allowed = new Set(['file', 'line', 'line_start', 'themeId', 'subject', 'confidence', 'title', 'why', 'axis', 'priority'])
1876
+ const unknown = Object.keys(finding).filter((k) => !allowed.has(k))
1877
+ if (unknown.length) return [`findings[${index}] has unknown property: ${unknown[0]}`]
1878
+ if (!nonEmptyString(finding.file)) return [`findings[${index}].file is required`]
1879
+ const line = finding.line ?? finding.line_start
1880
+ if (!Number.isInteger(line) || line < 1) return [`findings[${index}].line must be a positive integer`]
1881
+ if (finding.themeId !== undefined && !nonEmptyString(finding.themeId)) return [`findings[${index}].themeId must be a non-empty string when present`]
1882
+ if (finding.subject !== undefined && !['code', 'evidence'].includes(finding.subject)) {
1883
+ return [`findings[${index}].subject must be code|evidence`]
1884
+ }
1885
+ if (finding.confidence !== undefined) {
1886
+ if (typeof finding.confidence !== 'number' || !Number.isFinite(finding.confidence) || finding.confidence < 0 || finding.confidence > 1) {
1887
+ return [`findings[${index}].confidence must be a number in 0..1`]
1888
+ }
1889
+ }
1890
+ return []
1891
+ }
1892
+
1893
+ function leanThemeIdsFromReview(review) {
1894
+ const ids = []
1895
+ if (Array.isArray(review.themeIds)) {
1896
+ for (const id of review.themeIds) {
1897
+ if (!nonEmptyString(id)) throw new Error('themeIds must be an array of non-empty strings')
1898
+ ids.push(id)
1899
+ }
1900
+ }
1901
+ if (Array.isArray(review.findings)) {
1902
+ for (const [i, f] of review.findings.entries()) {
1903
+ const errs = validateLeanReviewFinding(f, i)
1904
+ if (errs.length) throw new Error(errs[0])
1905
+ if (nonEmptyString(f.themeId) && !ids.includes(f.themeId)) ids.push(f.themeId)
1906
+ }
1907
+ }
1908
+ return ids
1909
+ }
1910
+
1911
+ export function recordFixRound(manifest, unitId, proof, { derivedChangedSurfaces } = {}) {
1912
+ if (!isObject(proof)) throw new Error('fix proof must be an object')
1913
+ const candidateUnit = unitOrThrow(manifest, unitId).units.find((unit) => unit.id === unitId)
1914
+ if (effectiveUnitKind(candidateUnit) !== 'implementation') {
1915
+ throw new Error(`record-fix-round is only for implementation units; ${unitId} is ${effectiveUnitKind(candidateUnit)}`)
1916
+ }
1917
+
1918
+ // ---- lean path (#792) ----
1919
+ // Accepts { reviewRound, headSha, themeIds[], changedSurfaces?, classesClosed? }
1920
+ // without migrate-unit or the deep matrix. Operational callers provide a Git-derived
1921
+ // surface set; the no-derivation branch exists only for legacy library compatibility.
1922
+ if (!isDeepFixPayload(proof)) {
1923
+ const unknown = Object.keys(proof).filter((k) => !LEAN_FIX_FIELDS.has(k))
1924
+ if (unknown.length) throw new Error(`fix round has unknown property: ${unknown[0]}`)
1925
+ if (!Number.isInteger(proof.reviewRound) || proof.reviewRound < 1) {
1926
+ throw new Error('fix round reviewRound must be a positive integer')
1927
+ }
1928
+ if (!nonEmptyString(proof.headSha)) throw new Error('lean fix round requires headSha')
1929
+ const typedWrite = derivedChangedSurfaces !== undefined
1930
+ let changedSurfaces
1931
+ if (typedWrite) {
1932
+ changedSurfaces = proof.changedSurfaces === undefined
1933
+ ? normalizeChangedSurfaces(derivedChangedSurfaces)
1934
+ : assertChangedSurfacesMatch(proof.changedSurfaces, derivedChangedSurfaces)
1935
+ if (proof.productionMoved !== undefined) throw new Error('productionMoved is legacy-only; new lean fix rounds use changedSurfaces')
1936
+ } else if (typeof proof.productionMoved !== 'boolean') {
1937
+ throw new Error('legacy lean fix round requires productionMoved: boolean')
1938
+ }
1939
+ // themeIds[] is preferred; a single themeId is accepted as a one-element list so a
1940
+ // deep-shaped call that only drops coverage still has a soft landing.
1941
+ let themeIds = Array.isArray(proof.themeIds) ? proof.themeIds : undefined
1942
+ if (themeIds === undefined && nonEmptyString(proof.themeId)) themeIds = [proof.themeId]
1943
+ if (!Array.isArray(themeIds) || !themeIds.length || !themeIds.every(nonEmptyString)) {
1944
+ throw new Error('lean fix round requires themeIds: non-empty string[]')
1945
+ }
1946
+ if (proof.classesClosed !== undefined) {
1947
+ if (!Array.isArray(proof.classesClosed) || !proof.classesClosed.every(nonEmptyString)) {
1948
+ throw new Error('classesClosed must be an array of non-empty strings when present')
1949
+ }
1950
+ }
1951
+ const out = clone(manifest)
1952
+ const { units, index } = unitOrThrow(out, unitId)
1953
+ const unit = units[index]
1954
+ assertNoPendingBreaker(unit, 'record-fix-round')
1955
+ // Referential integrity (#792): reviewRound must point at a review that exists on
1956
+ // this unit (lean rounds[] and/or deep reviewHistory). Without this, a call with
1957
+ // reviewRound: 99 against a unit that never had a review records cleanly and the
1958
+ // manifest claims a fix closed findings from a non-existent review.
1959
+ const knownReviews = knownReviewRoundNumbers(unit)
1960
+ if (!knownReviews.has(proof.reviewRound)) {
1961
+ throw new Error(
1962
+ `lean fix round reviewRound ${proof.reviewRound} has no matching review round on this unit (rounds[] / reviewHistory)`,
1963
+ )
1964
+ }
1965
+ // Fix proofs stay in their append-only side log; they do not increment the review-round budget.
1966
+ unit.leanFixRounds = Array.isArray(unit.leanFixRounds) ? unit.leanFixRounds : []
1967
+ unit.leanFixRounds.push({
1968
+ reviewRound: proof.reviewRound,
1969
+ headSha: proof.headSha,
1970
+ themeIds: [...themeIds],
1971
+ ...(typedWrite ? { changedSurfaces } : { productionMoved: proof.productionMoved }),
1972
+ ...(Array.isArray(proof.classesClosed) ? { classesClosed: [...proof.classesClosed] } : {}),
1973
+ })
1974
+ return settleBreakerOrThrow(manifest, unitId, out)
1975
+ }
1976
+
1977
+ // ---- deep path (unchanged) ----
1978
+ if (!Number.isInteger(proof?.round) || proof.round < 1) throw new Error('fix round must be a positive integer')
1979
+ const out = clone(manifest)
1980
+ const { units, index } = unitOrThrow(out, unitId)
1981
+ const unit = units[index]
1982
+ assertMigratedUnit(unit, 'record-fix-round')
1983
+ assertNoPendingBreaker(unit, 'record-fix-round')
1984
+ const persistedErrors = validateUnitRoundState(unit, deliveryMode(out), deliveryProvider(out))
1985
+ if (persistedErrors.length) throw new Error(`cannot record fix round from invalid persisted state: ${persistedErrors[0]}`)
1986
+ const latestReview = unit.reviewHistory?.at(-1)
1987
+ if (!latestReview) throw new Error('record-fix-round requires a referenced review round')
1988
+ if (proof?.reviewRound !== latestReview.round) throw new Error(`fix proof must reference the latest review round ${latestReview.round}`)
1989
+ const reviewedTheme = themesInRound(latestReview).find((theme) => theme.id === proof?.themeId)
1990
+ const errors = validateFixProof(proof, reviewedTheme)
1991
+ if (errors.length) throw new Error(errors[0])
1992
+ unit.fixRounds = Array.isArray(unit.fixRounds) ? unit.fixRounds : []
1993
+ const key = proofKey(proof)
1994
+ if (unit.fixRounds.some((existing) => proofKey(existing) === key)) throw new Error(`duplicate fix proof for review round ${proof.reviewRound} and theme ${proof.themeId}`)
1995
+ const previous = unit.fixRounds.at(-1)
1996
+ if (previous && proof.round < previous.round) throw new Error(`fix round ${proof.round} is older than ${previous.round}`)
1997
+ if (previous && proof.reviewRound < previous.reviewRound) throw new Error(`fix proof review round ${proof.reviewRound} is older than ${previous.reviewRound}`)
1998
+ unit.fixRounds.push({
1999
+ round: proof.round,
2000
+ reviewRound: proof.reviewRound,
2001
+ themeId: proof.themeId,
2002
+ theme: persistedTheme(reviewedTheme),
2003
+ coverage: clone(proof.coverage),
2004
+ selfReview: clone(proof.selfReview),
2005
+ })
2006
+ return candidateOrThrow(manifest, unitId, out)
2007
+ }
2008
+
2009
+ export function recordReviewRound(manifest, unitId, review, { derivedChangedSurfaces } = {}) {
2010
+ if (!Number.isInteger(review?.round) || review.round < 1) throw new Error('review round must be a positive integer')
2011
+ if (!isObject(review)) throw new Error('review round must be an object')
2012
+ const candidateUnit = unitOrThrow(manifest, unitId).units.find((unit) => unit.id === unitId)
2013
+ if (effectiveUnitKind(candidateUnit) !== 'implementation') {
2014
+ throw new Error(`record-review-round is only for implementation units; ${unitId} is ${effectiveUnitKind(candidateUnit)}`)
2015
+ }
2016
+
2017
+ // Route by unit state first: a unit already on the deep lane (stateVersion 2 or
2018
+ // reviewHistory present) stays on the deep path even for empty findings — otherwise
2019
+ // a deep unit's PASS with findings:[] would silently become a lean round-log write
2020
+ // and skip the matrix/owed-proof validation the deep lane exists for.
2021
+ // Lean only when the payload carries the lean signature (headSha, no deep themes).
2022
+ const probe = unitOrThrow(manifest, unitId).units.find((u) => u.id === unitId)
2023
+ const unitIsDeep = probe?.stateVersion !== undefined || Array.isArray(probe?.reviewHistory) || Array.isArray(probe?.fixRounds)
2024
+ const useDeep = unitIsDeep || !isLeanReviewPayload(review)
2025
+
2026
+ // Near-miss lean call: a versionless unit, no deep envelope fields, but headSha is
2027
+ // missing or empty. Without this, the deep path's "findings is not an array" / migrate
2028
+ // errors fire and the orchestrator re-learns the dual-schema trap #792 closed. Name
2029
+ // headSha as the missing lean discriminator before falling through.
2030
+ if (!unitIsDeep && !isDeepReviewPayload(review) && !nonEmptyString(review.headSha)) {
2031
+ if (review.headSha !== undefined) {
2032
+ throw new Error('headSha must be a non-empty string when present')
2033
+ }
2034
+ throw new Error('lean review round requires headSha (the SHA the verdict was rendered against); omit deep envelope fields and pass headSha, or migrate-unit for the deep path')
2035
+ }
2036
+
2037
+ // ---- lean path (#792) ----
2038
+ // Accepts headSha + themeIds + minimal findings without the deep-audit matrix and
2039
+ // without migrate-unit. Writes the lean round log (units[].rounds). The deep
2040
+ // reviewHistory path is unchanged for high-risk / full finding.theme payloads.
2041
+ if (!useDeep) {
2042
+ const unknown = Object.keys(review).filter((k) => !LEAN_REVIEW_FIELDS.has(k))
2043
+ if (unknown.length) throw new Error(`review round has unknown property: ${unknown[0]}`)
2044
+ if (!REVIEW_VERDICTS.includes(review.verdict)) {
2045
+ throw new Error(`invalid verdict ${review.verdict}`)
2046
+ }
2047
+ // headSha is accepted here (the dual-schema trap this issue closes): either
2048
+ // record-review-round or record-round may carry it; when present on the lean
2049
+ // review payload it is required non-empty and becomes the round-log headSha.
2050
+ if (review.headSha !== undefined && !nonEmptyString(review.headSha)) {
2051
+ throw new Error('headSha must be a non-empty string when present')
2052
+ }
2053
+ if (!nonEmptyString(review.headSha)) {
2054
+ throw new Error('lean review round requires headSha (the SHA the verdict was rendered against)')
2055
+ }
2056
+ if (review.scope !== undefined && !ROUND_SCOPES.includes(review.scope)) {
2057
+ throw new Error(`scope must be one of ${ROUND_SCOPES.join('|')}`)
2058
+ }
2059
+ const themeIds = leanThemeIdsFromReview(review)
2060
+ const current = unitOrThrow(manifest, unitId).units.find((entry) => entry.id === unitId)
2061
+ assertNoPendingReviewBreaker(current, 'record-review-round')
2062
+ const typedWrite = derivedChangedSurfaces !== undefined
2063
+ const entry = typedWrite ? {
2064
+ round: review.round,
2065
+ verdict: review.verdict,
2066
+ headSha: review.headSha,
2067
+ changedSurfaces: review.changedSurfaces,
2068
+ reviewSubject: review.reviewSubject,
2069
+ themeIds,
2070
+ authorizationBoundary: review.authorizationBoundary,
2071
+ ...(review.evidenceClasses ? { evidenceClasses: review.evidenceClasses } : {}),
2072
+ ...(review.reopenReason ? { reopenReason: review.reopenReason } : {}),
2073
+ ...(review.scope ? { scope: review.scope } : {}),
2074
+ } : {
2075
+ round: review.round,
2076
+ verdict: review.verdict,
2077
+ headSha: review.headSha,
2078
+ productionMoved: false,
2079
+ ...(themeIds.length ? { themeIds } : {}),
2080
+ ...(review.scope ? { scope: review.scope } : {}),
2081
+ }
2082
+ const logged = recordRound(manifest, unitId, entry, { derivedChangedSurfaces })
2083
+ const { units, index } = unitOrThrow(logged, unitId)
2084
+ units[index].lastVerdict = review.verdict
2085
+ units[index].lastReviewedSha = review.headSha
2086
+ return settleBreakerOrThrow(manifest, unitId, logged)
2087
+ }
2088
+
2089
+ // ---- deep path, plus the typed universal round entry ----
2090
+ const reviewAllowed = new Set([
2091
+ 'round', 'verdict', 'summary', 'overall_correctness', 'findings', 'overMockFindings',
2092
+ 'previousFindingsResolution', 'reviewModel', 'independent', 'contractValidates', 'round1Depth',
2093
+ // #792: headSha was the dual-schema trap — record-round accepted it, this command
2094
+ // rejected it. Accept and persist on the history entry; it does not change matrix rules.
2095
+ 'headSha', 'scope', 'themeIds', 'changedSurfaces', 'reviewSubject',
2096
+ 'evidenceClasses', 'reopenReason', 'authorizationBoundary',
2097
+ ])
2098
+ const unknownReviewKeys = Object.keys(review).filter((key) => !reviewAllowed.has(key))
2099
+ if (unknownReviewKeys.length) throw new Error(`review round has unknown property: ${unknownReviewKeys[0]}`)
2100
+ if (review.headSha !== undefined && !nonEmptyString(review.headSha)) {
2101
+ throw new Error('headSha must be a non-empty string when present')
2102
+ }
2103
+ const {
2104
+ round: _round, round1Depth, headSha, scope: _scope, themeIds: _themeIds,
2105
+ changedSurfaces: _changedSurfaces, reviewSubject: _reviewSubject,
2106
+ evidenceClasses: _evidenceClasses, reopenReason: _reopenReason,
2107
+ authorizationBoundary: _authorizationBoundary, ...reviewDocument
2108
+ } = review
2109
+ const reviewErrors = validateReviewDocument(reviewDocument)
2110
+ if (reviewErrors.length) throw new Error(reviewErrors[0])
2111
+ if (round1Depth !== undefined) {
2112
+ const depthErrors = validateRound1Depth(round1Depth)
2113
+ if (depthErrors.length) throw new Error(depthErrors[0])
2114
+ }
2115
+ const themes = uniqueThemes(review.findings.map(themeFromFinding))
2116
+ const out = clone(manifest)
2117
+ const { units, index } = unitOrThrow(out, unitId)
2118
+ const unit = units[index]
2119
+ assertMigratedUnit(unit, 'record-review-round')
2120
+ assertNoPendingReviewBreaker(unit, 'record-review-round')
2121
+ const persistedErrors = validateUnitRoundState(unit, deliveryMode(out), deliveryProvider(out))
2122
+ if (persistedErrors.length) throw new Error(`cannot record review round from invalid persisted state: ${persistedErrors[0]}`)
2123
+ unit.reviewHistory = Array.isArray(unit.reviewHistory) ? unit.reviewHistory : []
2124
+ const previous = unit.reviewHistory.at(-1)
2125
+ const expectedRound = previous ? previous.round + 1 : 1
2126
+ if (review.round !== expectedRound) throw new Error(`review round ${review.round} must advance exactly from ${previous?.round || 0} to ${expectedRound}`)
2127
+ const entry = {
2128
+ round: review.round,
2129
+ verdict: review.verdict,
2130
+ themes: themes.map(persistedTheme),
2131
+ ...(round1Depth === undefined ? {} : { round1Depth: clone(round1Depth) }),
2132
+ ...(nonEmptyString(headSha) ? { headSha } : {}),
2133
+ }
2134
+ const preexistingDeepHistoryWithoutRoundLog = unit.reviewHistory.length > 0 && !Array.isArray(unit.rounds)
2135
+ if (derivedChangedSurfaces !== undefined && preexistingDeepHistoryWithoutRoundLog) {
2136
+ throw new Error('legacy deep reviewHistory has no universal typed rounds; reconstruct explicit unknown round classifications before appending a new review')
2137
+ }
2138
+ unit.reviewHistory.push(entry)
2139
+ if (derivedChangedSurfaces !== undefined) {
2140
+ if (!nonEmptyString(headSha)) throw new Error('typed deep review round requires headSha')
2141
+ const logged = recordRound(out, unitId, {
2142
+ round: review.round,
2143
+ verdict: review.verdict,
2144
+ headSha,
2145
+ changedSurfaces: review.changedSurfaces,
2146
+ reviewSubject: review.reviewSubject,
2147
+ themeIds: themes.map((theme) => theme.id),
2148
+ authorizationBoundary: review.authorizationBoundary,
2149
+ ...(review.evidenceClasses ? { evidenceClasses: review.evidenceClasses } : {}),
2150
+ ...(review.reopenReason ? { reopenReason: review.reopenReason } : {}),
2151
+ ...(review.scope ? { scope: review.scope } : {}),
2152
+ }, { derivedChangedSurfaces })
2153
+ const typedUnit = logged.units.find((candidate) => candidate.id === unitId)
2154
+ typedUnit.lastVerdict = review.verdict
2155
+ typedUnit.lastReviewedSha = headSha
2156
+ return candidateOrThrow(manifest, unitId, logged)
2157
+ }
2158
+ return candidateOrThrow(manifest, unitId, out)
2159
+ }
2160
+
2161
+ // The independent-review workflow emits this envelope. unitId routes the
2162
+ // result and is deliberately not persisted inside reviewHistory; round1Depth
2163
+ // is review evidence and is deliberately persisted. The adapter owns the only
2164
+ // documented reshaping: it derives the next append-only round from durable
2165
+ // history, then delegates to the same transition used by direct reviews.
2166
+ export function recordWorkflowReviewResult(manifest, intendedUnitId, result, { derivedChangedSurfaces } = {}) {
2167
+ if (!isObject(result)) throw new Error('workflow review result must be an object')
2168
+ const allowed = new Set([
2169
+ 'unitId', 'verdict', 'summary', 'overall_correctness', 'findings', 'overMockFindings',
2170
+ 'previousFindingsResolution', 'reviewModel', 'independent', 'contractValidates', 'round1Depth',
2171
+ 'headSha', 'reviewSubject', 'evidenceClasses', 'reopenReason', 'authorizationBoundary', 'changedSurfaces',
2172
+ ])
2173
+ const unknown = Object.keys(result).find((key) => !allowed.has(key))
2174
+ if (unknown) throw new Error(`workflow review result has unknown property: ${unknown}`)
2175
+ const missing = WORKFLOW_REVIEW_REQUIRED.find((key) => !Object.prototype.hasOwnProperty.call(result, key))
2176
+ if (missing) throw new Error(`workflow review result requires ${missing}`)
2177
+ if (!nonEmptyString(result.unitId)) throw new Error('workflow review result requires a non-empty unitId')
2178
+ if (result.unitId !== intendedUnitId) {
2179
+ throw new Error(`workflow review result unitId ${result.unitId} does not match intended unit ${intendedUnitId}`)
2180
+ }
2181
+ const { units } = unitOrThrow(manifest, result.unitId)
2182
+ const unit = units.find((entry) => entry.id === result.unitId)
2183
+ const depthErrors = validateRound1Depth(result.round1Depth)
2184
+ if (depthErrors.length) throw new Error(depthErrors[0])
2185
+ const { unitId: _unitId, ...reviewDocument } = result
2186
+ const nextRound = (unit.reviewHistory?.at(-1)?.round || 0) + 1
2187
+ return recordReviewRound(manifest, result.unitId, { ...reviewDocument, round: nextRound }, { derivedChangedSurfaces })
2188
+ }
2189
+
2190
+ // Explicit v1 migration. Nothing is sorted, inferred, narrowed, or rewritten
2191
+ // unless a legacy fix theme maps to exactly one reviewed snapshot.
2192
+ // The human's answer to a fired breaker. Recording it is the ONLY way to unblock the
2193
+ // next transition, which is what makes the breaker a gate rather than prose. The decision
2194
+ // may be the human's or, for the two reversible ones, the loop's own (autoBreakerDecision).
2195
+ export function recordBreakerDecision(manifest, unitId, decision) {
2196
+ if (!isObject(decision)) throw new Error('breaker decision must be an object')
2197
+ const out = clone(manifest)
2198
+ const { units, index } = unitOrThrow(out, unitId)
2199
+ const unit = units[index]
2200
+ const completedRounds = completedReviewRounds(unit)
2201
+ // The precondition is exit-reachability: a fresh decision is authorized whenever a
2202
+ // breaker is PENDING — `pendingBreaker(unit)` non-null. That covers both a raw firing
2203
+ // (BREAKER_ROUND_LIMIT completed rounds with no PASS) and a re-fired one (an exhausted
2204
+ // "continue"), and only those — a healthy unit (pendingBreaker null) cannot stamp a
2205
+ // decision.
2206
+ const pending = pendingBreaker(unit)
2207
+ if (!pending) {
2208
+ throw new Error(`record-breaker-decision requires a pending circuit-breaker on ${unitId}: ${BREAKER_ROUND_LIMIT} completed review rounds with no passing review, or a re-fired breaker (an exhausted "continue"); ${unitId} has ${completedRounds} rounds with no breaker pending`)
2209
+ }
2210
+ // Every firing is a review-rounds firing now — the produce-pass threshold was removed
2211
+ // (see BREAKER_FIRED_ON). The code never writes `'produce-passes'`.
2212
+ const firedOn = 'review-rounds'
2213
+ // Two guards on `autoApplied`, ceiling first because it is the more specific reason: at
2214
+ // the ceiling BOTH would refuse, and "you are past the round ceiling" tells the caller
2215
+ // what to do, while "the log authorizes only continue" is true but hides why continue is
2216
+ // nonetheless unavailable.
2217
+ //
2218
+ // The grind bound. Autonomy removed the human stop that used to bound the loop, so the
2219
+ // ceiling is what replaces it — and it binds the AUTONOMOUS path only: a human at the
2220
+ // keyboard on round 9 has already seen the round count and is making an informed call,
2221
+ // which is precisely the state the breaker wanted to reach. What must be unreachable is
2222
+ // the loop granting ITSELF round 9 (epic-848: nine rounds, ~6.5 unattended hours).
2223
+ if (decision.autoApplied === true && completedRounds >= BREAKER_TOTAL_ROUND_CEILING) {
2224
+ throw new Error(`record-breaker-decision refused: ${completedRounds} review rounds on ${unitId} is at or past the autonomous ceiling of ${BREAKER_TOTAL_ROUND_CEILING} — no auto-decision above it. This unit has spent more rounds than any observed unit that converged; hand the human a split/descope decision (record it without autoApplied once they answer).`)
2225
+ }
2226
+ // `autoApplied` is a provenance claim — "the loop decided this, nobody was asked" — so it
2227
+ // has to be true, not merely well-formed. The CLI's `--auto` path always passes what
2228
+ // autoBreakerDecision computed, but the library path takes any object, and a hand-written
2229
+ // {decision:'continue', autoApplied:true} against a diagnosis that says `split` recorded
2230
+ // clean. Worse, when the diagnosis is `insufficient-data` the validate-time warning is
2231
+ // silent too (it needs a recommendation to compare against), so the manifest ends up
2232
+ // asserting the loop made a call it was never entitled to make, with nothing anywhere
2233
+ // contradicting it. Re-deriving the decision here is the only check that closes both.
2234
+ // Both knobs, and the level is NOT optional here. Omitting it re-derives at
2235
+ // autoBreakerDecision's `supervised` default, which authorizes only the always-auto
2236
+ // decisions — so the guard refused every auto-applied `split` on every run, including
2237
+ // the `autonomous` runs that had just computed one. The CLI's `--auto` path passed the
2238
+ // real level to autoBreakerDecision and then handed the result to this guard, which
2239
+ // re-derived it at a level nobody was on: a feature that could be computed but never
2240
+ // written. Reading the level off the manifest keeps one source for it. An absent or
2241
+ // typo'd value fails closed — `autoDecisionAllowed` authorizes on `=== 'autonomous'`
2242
+ // and nothing else — so this cannot silently widen what may be auto-applied.
2243
+ // The level the guard actually authorized against, stamped onto the record below. It is
2244
+ // not read off `decision`: a caller that could assert its own authorization would make
2245
+ // the stamp decorative, and `validateBreaker` refuses an auto-applied `split` that does
2246
+ // not carry it — so a record built without this is one the very next read rejects. That
2247
+ // was the other half of the same dead feature: the guard could be made to pass and the
2248
+ // write still failed validation, because `next` never carried the field.
2249
+ let autonomyAtDecision
2250
+ if (decision.autoApplied === true) {
2251
+ const authorized = autoBreakerDecision(unit, { autonomy: out.breakerAutonomy ?? 'auto', autonomyLevel: out.autonomy })
2252
+ if (!authorized || authorized.decision !== decision.decision) {
2253
+ const d = diagnoseBreaker(unit)
2254
+ throw new Error(`record-breaker-decision refused: "${decision.decision}" cannot be recorded as autoApplied on ${unitId} — the round log diagnoses "${d.signal}"${d.recommendation ? ` and authorizes only "${d.recommendation}"` : ' and authorizes no autonomous decision'}. Record it without autoApplied if a human made this call; autoApplied claims the loop decided it.`)
2255
+ }
2256
+ autonomyAtDecision = authorized.autonomyAtDecision
2257
+ }
2258
+ const next = {
2259
+ firedAtRound: completedRounds,
2260
+ firedOn,
2261
+ decision: decision.decision,
2262
+ diagnosis: decision.diagnosis,
2263
+ decidedAt: decision.decidedAt,
2264
+ ...(decision.autoApplied === true ? { autoApplied: true } : {}),
2265
+ ...(nonEmptyString(autonomyAtDecision) ? { autonomyAtDecision } : {}),
2266
+ ...(nonEmptyString(decision.signal) ? { signal: decision.signal } : {}),
2267
+ ...(nonEmptyString(decision.classCompleteFor) ? { classCompleteFor: decision.classCompleteFor } : {}),
2268
+ // Stamp the contract pointer this firing was decided against. record-intake-reentry
2269
+ // compares against it to prove a "design" decision's input actually landed before it
2270
+ // grants a fresh budget — a decision the human made, not a document, buys nothing.
2271
+ contractShaAtDecision: isObject(unit.contractRevision) && nonEmptyString(unit.contractRevision.sha) ? unit.contractRevision.sha : null,
2272
+ ...(decision.decision === 'continue' ? { authorizedThroughRound: completedRounds + 1 } : {}),
2273
+ }
2274
+ const errors = validateBreaker({ breaker: next })
2275
+ if (errors.length) throw new Error(errors[0])
2276
+ // Append-only audit trail: a unit that fires the breaker, re-enters intake, and
2277
+ // gets stuck again fires it a SECOND time — and the whole point of the breaker is
2278
+ // the forensic record of *why* it went around twice. Archive the prior decision
2279
+ // before overwriting `breaker` (which the round-budget logic reads as "current"),
2280
+ // so no diagnosis is ever lost. (Review finding: a single object silently
2281
+ // discarded the first firing.)
2282
+ if (isObject(unit.breaker)) {
2283
+ unit.breakerLog = Array.isArray(unit.breakerLog) ? unit.breakerLog : []
2284
+ unit.breakerLog.push(unit.breaker)
2285
+ }
2286
+ unit.breaker = next
2287
+ return settleBreakerOrThrow(manifest, unitId, out)
2288
+ }
2289
+
2290
+ // Set reviewedContractSha to the unit's CURRENT contractRevision.sha — the writer
2291
+ // the review found missing (a reader with no writer left the field hand-set, so a
2292
+ // wrong/premature value could silently mark a stale review as current). Call it
2293
+ // only after a full-scope review actually completed against the current contract;
2294
+ // it can then only ever record "reviewed against the contract that is current now",
2295
+ // which is exactly the freshness gate's exit condition. Requires a contractRevision
2296
+ // to point at — there is nothing to have reviewed against otherwise.
2297
+ export function recordReviewedContract(manifest, unitId) {
2298
+ const out = clone(manifest)
2299
+ const { units, index } = unitOrThrow(out, unitId)
2300
+ const unit = units[index]
2301
+ const cr = isObject(unit.contractRevision) ? unit.contractRevision : null
2302
+ if (!cr || !nonEmptyString(cr.sha)) throw new Error(`record-reviewed-contract requires units[${unitId}].contractRevision.sha (nothing to have reviewed against)`)
2303
+ unit.reviewedContractSha = cr.sha
2304
+ return out
2305
+ }
2306
+
2307
+ // Re-entering intake (after a design pass, split, or descope) is what unblocks the
2308
+ // loop again, and it grants a fresh round budget measured from where the breaker
2309
+ // fired. It is recorded, not cleared: the fired-and-decided history is the audit
2310
+ // trail for why the unit went around twice.
2311
+ export function recordIntakeReentry(manifest, unitId, { reenteredAt } = {}) {
2312
+ const out = clone(manifest)
2313
+ const { units, index } = unitOrThrow(out, unitId)
2314
+ const unit = units[index]
2315
+ const breaker = isObject(unit.breaker) ? unit.breaker : null
2316
+ if (!breaker) throw new Error(`record-intake-reentry requires a recorded breaker decision on ${unitId}`)
2317
+ if (breaker.decision === 'continue') throw new Error('a "continue" decision authorizes another round directly; intake re-entry applies to design/split/descope')
2318
+ if (breaker.reenteredAt) throw new Error(`${unitId} has already re-entered intake at ${breaker.reenteredAt}`)
2319
+ // A decision that ALREADY spent a re-entry on this unit cannot buy a second one. The
2320
+ // archive was built to record a unit that "fires, re-enters intake, and gets stuck
2321
+ // again" — this refuses the third lap. The first re-entry demonstrably did not change
2322
+ // the input, so repeating it is not a decision, it is the same loop with a fresh
2323
+ // budget. The human still has the other decisions; what they cannot do is re-buy this
2324
+ // one. (Named incident: epic-986 unit #996 — fired -> "design" -> re-entry -> fired
2325
+ // again at review round 4 -> "design" -> re-entry, both firings correctly diagnosing the
2326
+ // same unwritten trust boundary. The second firing produced the answer the first
2327
+ // re-entry should have carried; two full budgets bought it.)
2328
+ const spent = (Array.isArray(unit.breakerLog) ? unit.breakerLog : [])
2329
+ .find((entry) => isObject(entry) && entry.decision === breaker.decision && nonEmptyString(entry.reenteredAt))
2330
+ if (spent) {
2331
+ throw new Error(`${unitId} already re-entered intake on a "${breaker.decision}" decision at ${spent.reenteredAt} and fired again — a repeat "${breaker.decision}" is a hand-back, not a re-entry. Take a different decision (split/descope/continue) or hand the unit to the human.`)
2332
+ }
2333
+ // A "design" re-entry is authorized by the design ARTIFACT, not by the word. The
2334
+ // decision names the remedy; only a contract pointer that moved proves the remedy
2335
+ // landed. Absent that, the producer re-enters against the same unwritten contract that
2336
+ // fired the breaker — which is exactly how a unit buys a second identical firing.
2337
+ // A legacy firing (field absent) reads as null: it still owes a pointer, just no delta.
2338
+ if (breaker.decision === 'design') {
2339
+ const sha = isObject(unit.contractRevision) && nonEmptyString(unit.contractRevision.sha) ? unit.contractRevision.sha : null
2340
+ const atDecision = breaker.contractShaAtDecision === undefined ? null : breaker.contractShaAtDecision
2341
+ if (!sha) {
2342
+ throw new Error(`a "design" re-entry on ${unitId} requires units[].contractRevision.sha — the approved design input on the target branch. Land the ADR (\`/grill-with-docs\`), record the pointer with \`record-reviewed-contract\`'s source, then re-enter.`)
2343
+ }
2344
+ if (sha === atDecision) {
2345
+ throw new Error(`${unitId} still points at contractRevision.sha ${sha}, the same contract the breaker fired against — the design decision's input has not landed. Re-entering here re-runs the producer against the contract that fired the breaker.`)
2346
+ }
2347
+ }
2348
+ unit.breaker = { ...breaker, reenteredAt: reenteredAt || new Date().toISOString() }
2349
+ return settleBreakerOrThrow(manifest, unitId, out)
2350
+ }
2351
+
2352
+ // ---- delivery gate ----
2353
+
2354
+ // Absent `deliveryMode` reads as 'direct': a manifest written before this gate ran the
2355
+ // legacy `gh pr merge` path, so that is what it means (the `firedOn` precedent).
2356
+ export function deliveryMode(manifest) {
2357
+ const mode = manifest?.deliveryMode
2358
+ return mode === undefined || mode === null ? 'direct' : mode
2359
+ }
2360
+
2361
+ export function validateDeliveryAttempt(attempt, mode, label = 'delivery attempt', provider = null) {
2362
+ if (!isObject(attempt)) return [`${label} must be an object`]
2363
+ const flow = DELIVERY_FLOWS[deliveryFlowKey(mode, provider)] ?? null
2364
+ // The field allowlist is PER FLOW, not shared. A queue field on a direct attempt means
2365
+ // the run believes it is enqueueing when it is really calling `gh pr merge` — the
2366
+ // silent wrong-mode failure detect-delivery-mode exists to catch, and nothing
2367
+ // downstream said a word about it. Safe to tighten: `delivery` was written 0 times in
2368
+ // 522 audited units, so there is no legacy shape to preserve here.
2369
+ const allowedFields = flow ? flow.attemptFields : ['at', 'action', 'headSha', 'outcome', 'reason', 'rawReason', 'detail', 'queueState', 'ack', 'unmetConditions', 'pr']
2370
+ const errors = Object.keys(attempt)
2371
+ .filter((key) => !allowedFields.includes(key))
2372
+ .map((key) => `${label} has unknown property: ${key}${flow ? ` for the "${flow.key}" flow (allowed: ${allowedFields.join(', ')})` : ''}`)
2373
+ if (!nonEmptyString(attempt.at)) errors.push(`${label}.at is required`)
2374
+ if (!nonEmptyString(attempt.headSha)) errors.push(`${label}.headSha is required — a delivery attempt is against an exact HEAD`)
2375
+ const allowed = DELIVERY_ACTIONS[mode] || []
2376
+ if (!allowed.includes(attempt.action)) {
2377
+ errors.push(`${label}.action "${attempt.action}" is not a "${mode}" action (expected ${allowed.join('|')}) — the run's delivery mode decides the merge path`)
2378
+ }
2379
+ if (!DELIVERY_ATTEMPT_OUTCOMES.includes(attempt.outcome)) {
2380
+ errors.push(`${label}.outcome must be one of ${DELIVERY_ATTEMPT_OUTCOMES.join('|')} — enqueue path: requested (command posted, no ack yet) → waiting (ack or queue membership) → merged|dequeued|rejected`)
2381
+ }
2382
+ // `requested` is enqueue-only: the first post of the command, before any ack.
2383
+ if (attempt.outcome === 'requested' && attempt.action !== 'enqueue') {
2384
+ errors.push(`${label}.outcome "requested" is only legal on action "enqueue" (got "${attempt.action}") — it means the enqueue command was posted`)
2385
+ }
2386
+ // A dequeue/rejection without a reason is the failure this taxonomy exists to prevent:
2387
+ // "it came back out" collapses a fix round and a hand-back into one indistinguishable
2388
+ // retry. The reason is READ from the queue tool, never inferred by the orchestrator —
2389
+ // which is why `rawReason` (the tool's own string) is what the routing table consumes,
2390
+ // and `reason` is the routing class it maps to.
2391
+ if (attempt.outcome === 'dequeued' || attempt.outcome === 'rejected') {
2392
+ if (!DELIVERY_DEQUEUE_REASONS.includes(attempt.reason)) {
2393
+ errors.push(`${label}.reason must be one of ${DELIVERY_DEQUEUE_REASONS.join('|')} when the attempt did not merge — read it from the queue tool, never infer it (put the tool's own string in rawReason and map it with dequeueRouting)`)
2394
+ }
2395
+ if (!nonEmptyString(attempt.detail)) errors.push(`${label}.detail is required on a non-merge outcome — quote the tool verbatim`)
2396
+ // The mapping must be honest in both directions: a rawReason that routes somewhere
2397
+ // else than the recorded class is how a hand-back gets filed as a fix round.
2398
+ if (nonEmptyString(attempt.rawReason)) {
2399
+ const routed = dequeueRouting(attempt.rawReason)
2400
+ if (routed.known && routed.canonical !== attempt.reason) {
2401
+ errors.push(`${label}.reason "${attempt.reason}" contradicts rawReason "${attempt.rawReason}", which routes to "${routed.canonical}" — record what the queue said, not what you expected it to say`)
2402
+ }
2403
+ }
2404
+ } else if (attempt.reason !== undefined) {
2405
+ errors.push(`${label}.reason applies only to a dequeued/rejected attempt`)
2406
+ }
2407
+ // The acknowledgement, where the provider has one. An enqueue whose command was never
2408
+ // read sits in exactly the same manifest shape as one the queue is actively holding —
2409
+ // epic-986's "waiting for queue conditions" one layer up, and the 👍 is the only thing
2410
+ // that tells them apart. `requested` deliberately does NOT require ack (#946) — that is
2411
+ // how the first post is recorded before Mergify reacts.
2412
+ if (flow?.supportsAck && attempt.action === 'enqueue' && attempt.outcome === 'waiting') {
2413
+ if (!isObject(attempt.ack) || !nonEmptyString(attempt.ack.ref)) {
2414
+ errors.push(`${label}.ack {kind, ref} is required for a "${flow.key}" enqueue recorded as waiting — the queue acknowledges the command (👍 reaction / its status comment), and without that evidence "queued" and "nobody read the comment" are the same record. Use outcome "requested" when the command was posted but not yet acked.`)
2415
+ } else if (!['reaction', 'payload'].includes(attempt.ack.kind)) {
2416
+ errors.push(`${label}.ack.kind must be "reaction" or "payload" (got ${JSON.stringify(attempt.ack.kind)})`)
2417
+ }
2418
+ } else if (attempt.ack !== undefined && attempt.outcome === 'requested') {
2419
+ errors.push(`${label}.ack is not legal on outcome "requested" — that outcome means no ack yet; promote to "waiting" once ack or queue membership is observed`)
2420
+ } else if (attempt.ack !== undefined && flow && !flow.supportsAck) {
2421
+ // Only when the flow is RESOLVED. An unresolved flow (queue mode, provider not yet
2422
+ // settled) knows nothing about acknowledgements either way, and rejecting the field
2423
+ // there would turn "we have not settled the provider" into "this record is corrupt" —
2424
+ // wedging exactly the resumed run that still needs to write its state.
2425
+ errors.push(`${label}.ack is not meaningful in the "${flow.key}" flow — that provider's enqueue is an API call whose return value is the acknowledgement`)
2426
+ }
2427
+ return errors
2428
+ }
2429
+
2430
+ export function validateDelivery(unit, mode, provider = null) {
2431
+ const d = unit?.delivery
2432
+ if (d === undefined || d === null) return []
2433
+ if (!isObject(d)) return ['delivery must be an object']
2434
+ // `mergedAt` is the PR's own merge time (GitHub `mergedAt` / verified-merge clock).
2435
+ // Optional, recorded so postmortem can distinguish queue-wait from late bookkeeping
2436
+ // (#791 / issue-664 / identity#669 hybrid merge unobserved).
2437
+ const errors = Object.keys(d)
2438
+ .filter((key) => !['attempts', 'outcome', 'mergedSha', 'mergedAt', 'blockedOn', 'watch'].includes(key))
2439
+ .map((key) => `delivery has unknown property: ${key}`)
2440
+ if (d.watch !== undefined && d.watch !== null) {
2441
+ if (!isObject(d.watch)) errors.push('delivery.watch must be an object')
2442
+ else {
2443
+ errors.push(...Object.keys(d.watch)
2444
+ .filter((key) => !['firstObservedAt', 'lastObservedAt', 'lastChangeAt', 'queueState', 'position', 'polls', 'nudges', 'unmetConditions'].includes(key))
2445
+ .map((key) => `delivery.watch has unknown property: ${key}`))
2446
+ for (const f of ['firstObservedAt', 'lastObservedAt', 'lastChangeAt']) {
2447
+ if (!nonEmptyString(d.watch[f])) errors.push(`delivery.watch.${f} is required`)
2448
+ }
2449
+ if (!nonEmptyString(d.watch.queueState)) errors.push('delivery.watch.queueState is required — the state read from the queue, never inferred')
2450
+ // A watch only means something where there is a queue to watch. In `direct` mode the
2451
+ // merge is synchronous, so a watch record means the run thinks it is enqueueing while
2452
+ // it is really calling `gh pr merge` — wrong-mode, failing silently, which is the
2453
+ // whole reason the mode is probed at pre-flight rather than assumed.
2454
+ if (mode === 'direct') {
2455
+ errors.push('delivery.watch is not meaningful in "direct" mode — a direct merge is synchronous and has no queue to observe; a watch here means the run\'s deliveryMode does not match the repo')
2456
+ }
2457
+ if (d.watch.nudges !== undefined) {
2458
+ if (!Array.isArray(d.watch.nudges)) errors.push('delivery.watch.nudges must be an array')
2459
+ else {
2460
+ d.watch.nudges.forEach((n, i) => {
2461
+ if (!isObject(n) || !nonEmptyString(n.at) || !nonEmptyString(n.command)) {
2462
+ errors.push(`delivery.watch.nudges[${i}] requires {at, command} — a nudge nobody can point at is indistinguishable from a poll`)
2463
+ }
2464
+ })
2465
+ if (d.watch.nudges.length > DELIVERY_MAX_NUDGES) {
2466
+ errors.push(`delivery.watch.nudges has ${d.watch.nudges.length} entries (max ${DELIVERY_MAX_NUDGES}) — the nudge is one bounded re-evaluation request, not a poll loop that keeps a wedged unit looking alive`)
2467
+ }
2468
+ }
2469
+ }
2470
+ if (d.watch.unmetConditions !== undefined && d.watch.unmetConditions !== null
2471
+ && (!Array.isArray(d.watch.unmetConditions) || !d.watch.unmetConditions.every(nonEmptyString))) {
2472
+ errors.push('delivery.watch.unmetConditions must be an array of strings — the queue\'s own unmet leaves, quoted verbatim so a hand-back can name one')
2473
+ }
2474
+ }
2475
+ }
2476
+ if (d.attempts !== undefined) {
2477
+ if (!Array.isArray(d.attempts)) errors.push('delivery.attempts must be an array')
2478
+ else d.attempts.forEach((a, i) => errors.push(...validateDeliveryAttempt(a, mode, `delivery.attempts[${i}]`, provider)))
2479
+ }
2480
+ if (d.outcome !== undefined && d.outcome !== null && !DELIVERY_OUTCOMES.includes(d.outcome)) {
2481
+ errors.push(`delivery.outcome must be one of ${DELIVERY_OUTCOMES.join('|')} or null`)
2482
+ }
2483
+ // The whole point of the gate: "merged" is a claim about the target branch, so it owes
2484
+ // the SHA that landed. `combine-and-verify.mjs delivered` is what proves containment
2485
+ // live; this refuses the claim that has no SHA to check.
2486
+ if (d.outcome === 'merged' && !nonEmptyString(d.mergedSha)) {
2487
+ errors.push('delivery.outcome "merged" requires delivery.mergedSha — the commit verified present in the fetched target branch')
2488
+ }
2489
+ if (d.outcome === 'handed-back' && !nonEmptyString(d.blockedOn)) {
2490
+ errors.push('delivery.outcome "handed-back" requires delivery.blockedOn — the unsatisfied condition, quoted from the merge tool')
2491
+ }
2492
+ return errors
2493
+ }
2494
+
2495
+ // A legacy-shape round stays readable forever, but it CANNOT become component evidence:
2496
+ // `record-component-candidate` requires a typed `reviewSubject` on the unit's latest round,
2497
+ // and a legacy record projects to `unknown`. The round log is append-only, so by the time
2498
+ // the component gate refuses, the omission can no longer be repaired — the only routes left
2499
+ // are a fabricated second round at the same head (the epic-25 double-record pathology) or
2500
+ // hand-editing a protected log. Both are worse than the bug they would paper over.
2501
+ //
2502
+ // Hence a warning at the one moment it is still cheap to act on: the unit has reached
2503
+ // merge-ready, so its PASS is written and the component candidate is the very next step.
2504
+ // fmm-express epic-894 wrote all three units with the legacy shape, validated clean at every
2505
+ // checkpoint, and hit the dead end ~4h later at delivery with the merge gate unreachable and
2506
+ // no way back. Nothing between the write and the refusal said a word.
2507
+ export function legacyRoundWarnings(unit) {
2508
+ if (unit?.status !== 'merge-ready') return []
2509
+ const latest = (Array.isArray(unit.rounds) ? unit.rounds : []).at(-1)
2510
+ if (!isObject(latest) || !classifyRoundMetadata(latest).legacy) return []
2511
+ return [`${unit.id ?? 'unit'} latest round is the legacy shape (reviewSubject projects to "unknown") — `
2512
+ + 'record-component-candidate will refuse this unit, and the round log is append-only so this cannot be '
2513
+ + 'repaired later. Record rounds with typed reviewSubject + authorizationBoundary (references/loop.md).']
2514
+ }
2515
+
2516
+ // A merge-ready unit whose delivery has not reached a terminal outcome is IN-FLIGHT, not
2517
+ // delivered. Mid-run that is legal, so this is a warning; the handoff is where it becomes
2518
+ // a finding, because "merge-ready" read as "done" is exactly how epic-986 reported four
2519
+ // units as finished while `mergedSha` was null on every one of them.
2520
+ export function deliveryWarnings(unit, { now, boundMinutes, flow = null } = {}) {
2521
+ if (unit?.status !== 'merge-ready') return []
2522
+ const delivery = isObject(unit.delivery) ? unit.delivery : null
2523
+ const outcome = delivery ? delivery.outcome : null
2524
+ if (outcome === 'merged' || outcome === 'handed-back') return []
2525
+ const warnings = [`${unit.id ?? 'unit'} is merge-ready with no terminal delivery outcome — undelivered, not done`]
2526
+ // A stalled or explicitly-blocked watch is the actionable half of that: it says the
2527
+ // wait is not going to resolve itself, which is the difference between "in flight"
2528
+ // and "wedged" — precisely what twelve hours of epic-986 could not distinguish.
2529
+ // `nudge-due` and `not-enqueued` are actionable too, but they name a cheap action
2530
+ // instead of a hand-back, so they read differently.
2531
+ const status = watchStatus(delivery?.watch, { now, boundMinutes, flow })
2532
+ if (status.state === 'stalled' || status.state === 'blocked' || status.state === 'unknown') {
2533
+ warnings.push(`${unit.id ?? 'unit'} delivery watch is ${status.state}: ${status.reason}`)
2534
+ }
2535
+ if (status.state === 'nudge-due' || status.state === 'not-enqueued') {
2536
+ warnings.push(`${unit.id ?? 'unit'} delivery watch is ${status.state} (action: ${status.action}): ${status.reason}`)
2537
+ }
2538
+ warnings.push(...deliveryHeadDriftWarnings(unit))
2539
+ if (status.boundWarning) warnings.push(`${unit.id ?? 'unit'}: ${status.boundWarning}`)
2540
+ return warnings
2541
+ }
2542
+
2543
+ export function recordDeliveryAttempt(manifest, unitId, attempt) {
2544
+ const out = clone(manifest)
2545
+ const { units, index } = unitOrThrow(out, unitId)
2546
+ const unit = units[index]
2547
+ if (effectiveUnitKind(unit) !== 'implementation') {
2548
+ throw new Error(`record-delivery-attempt is only for implementation units; ${unitId} is ${effectiveUnitKind(unit)}`)
2549
+ }
2550
+ const blockedOn = completionBlockers(out, unit)
2551
+ if (blockedOn.length) {
2552
+ throw new Error(`record-delivery-attempt refuses ${unitId}: completion dependency ${blockedOn.join(', ')} is not complete`)
2553
+ }
2554
+ const mode = deliveryMode(out)
2555
+ const provider = deliveryProvider(out)
2556
+ if (!DELIVERY_MODES.includes(mode)) throw new Error(`deliveryMode must be one of ${DELIVERY_MODES.join('|')}`)
2557
+ // A queue run whose provider is unknown cannot be delivered mechanically: the enqueue
2558
+ // command, the state source, and whether a nudge or ack even exists all differ between
2559
+ // Mergify and GitHub's native queue. Refuse HERE rather than at pre-flight, so a legacy
2560
+ // manifest can still be read, resumed and reported — it is stopped only at the moment
2561
+ // it would take a provider-specific action.
2562
+ if (mode === 'queue' && !QUEUE_PROVIDERS.includes(provider)) {
2563
+ throw new Error(`record-delivery-attempt on a "queue" run requires queueProvider (${QUEUE_PROVIDERS.join('|')}) — the mode picks the state machine, the provider picks the mechanics; settle it with detect-delivery-mode.mjs and \`set <slug> queueProvider <${QUEUE_PROVIDERS.join('|')}>\``)
2564
+ }
2565
+ if (unit.status !== 'merge-ready') {
2566
+ throw new Error(`record-delivery-attempt requires ${unitId} to be merge-ready (it is "${unit.status}") — delivery is the exit from merge-ready, not a way into it`)
2567
+ }
2568
+ const delivery = isObject(unit.delivery) ? unit.delivery : { attempts: [], outcome: null, mergedSha: null, blockedOn: null }
2569
+ if (delivery.outcome === 'merged' || delivery.outcome === 'handed-back') {
2570
+ throw new Error(`${unitId} already reached the terminal delivery outcome "${delivery.outcome}"`)
2571
+ }
2572
+ const errors = validateDeliveryAttempt(attempt, mode, 'delivery attempt', provider)
2573
+ if (errors.length) throw new Error(errors[0])
2574
+ const attempts = Array.isArray(delivery.attempts) ? [...delivery.attempts] : []
2575
+ // Re-entry to the merge path is EARNED by a fix and a fresh verdict, never repeated by
2576
+ // the orchestrator. The queue already retries what should be retried (a red batch is
2577
+ // bisected and the innocent PRs carry on); what it deliberately does not retry is a PR
2578
+ // whose own checks failed, because that loops forever on broken code. So an attempt at
2579
+ // an unchanged HEAD after a non-merge outcome is refused here rather than left to
2580
+ // judgment.
2581
+ // Check EVERY prior attempt, not just the last: A -> B -> A is the same unchanged-HEAD
2582
+ // retry wearing a detour, and `attempts.at(-1)` alone let it through.
2583
+ // Recording that the queue MERGED this HEAD is an observation of success, not a retry
2584
+ // of a failed path — the rule's whole rationale ("do not retry what the queue
2585
+ // deliberately did not retry") does not reach it. Exempting it also keeps the happy
2586
+ // path recordable after an honest in-flight `waiting` attempt at the same HEAD.
2587
+ //
2588
+ // #946 / epic-867 carve-out: ONE re-request at the same HEAD is allowed when every
2589
+ // prior attempt at that HEAD is still pre-admission (`requested`, or `waiting` with
2590
+ // queueState `not-enqueued`). That is a re-post of a command that never admitted the
2591
+ // PR — not a retry of a failed merge of broken code. A second re-request, or any prior
2592
+ // dequeued/rejected at that HEAD, still refuses.
2593
+ const priorAtSha = attempt.outcome === 'merged'
2594
+ ? null
2595
+ : attempts.find((a) => a.outcome !== 'merged' && a.headSha === attempt.headSha)
2596
+ if (priorAtSha) {
2597
+ const preAdmission = (a) => a.outcome === 'requested'
2598
+ || (a.outcome === 'waiting' && a.queueState === 'not-enqueued')
2599
+ const priorsAtSha = attempts.filter((a) => a.outcome !== 'merged' && a.headSha === attempt.headSha)
2600
+ const allPreAdmission = priorsAtSha.every(preAdmission)
2601
+ const reRequest = (attempt.outcome === 'requested' || attempt.outcome === 'waiting')
2602
+ && allPreAdmission
2603
+ && priorsAtSha.length === 1
2604
+ if (!reRequest) {
2605
+ const because = priorAtSha.reason ? ` (${priorAtSha.reason})` : ''
2606
+ throw new Error(`${unitId} already attempted delivery at ${attempt.headSha} and it came back "${priorAtSha.outcome}"${because} — the skill does not retry the merge path at an unchanged HEAD; fix and earn a fresh verdict, or hand back. (One re-queue is allowed only when the prior attempt was pre-admission: outcome requested, or waiting with queueState not-enqueued.)`)
2607
+ }
2608
+ }
2609
+ attempts.push(attempt)
2610
+ unit.delivery = { ...delivery, attempts }
2611
+ // Routing is mechanized rather than described, and it is now driven by the TABLE rather
2612
+ // than by one hardcoded reason. Two classes send the unit back through the loop:
2613
+ // checks-failed the queue tested PR-merged-with-base and it failed — new
2614
+ // information about the tree that would land
2615
+ // pull-request-updated HEAD moved under the queue (our own push ejected it), so the
2616
+ // last verdict is stale by guarantee 3
2617
+ // Clearing merge-ready is what forces the fresh verdict; everything else stays
2618
+ // merge-ready and terminates as a hand-back or an evidence-lane rebase.
2619
+ if (attempt.outcome === 'dequeued' || attempt.outcome === 'rejected') {
2620
+ const routed = nonEmptyString(attempt.rawReason) ? dequeueRouting(attempt.rawReason) : { route: DELIVERY_DEQUEUE_ROUTES[attempt.reason]?.route ?? 'hand-back' }
2621
+ if (routed.route === 'fresh-verdict') unit.status = 'in-progress'
2622
+ }
2623
+ return settleBreakerOrThrow(manifest, unitId, out)
2624
+ }
2625
+
2626
+ // Is this watch progressing, explicitly blocked, or wedged? Pure, so the bound can be
2627
+ // reasoned about (and tested) without a clock or a queue.
2628
+ export function watchStatus(watch, { now, boundMinutes = DELIVERY_WATCH_BOUND_MINUTES, flow = null } = {}) {
2629
+ // A non-numeric bound (`"45m"` from a hand-edited manifest) made every comparison
2630
+ // `x > NaN` — false — so the watchdog silently never fired. One typo disabled the
2631
+ // guard while it still looked configured, which is worse than having no guard.
2632
+ // Fail CLOSED: an unusable bound falls back to the default and says so.
2633
+ let bound = boundMinutes
2634
+ let coerced = null
2635
+ if (typeof bound !== 'number' || !Number.isFinite(bound) || bound <= 0) {
2636
+ coerced = `deliveryWatchBoundMinutes ${JSON.stringify(boundMinutes)} is not a positive number — falling back to the ${DELIVERY_WATCH_BOUND_MINUTES}m default`
2637
+ bound = DELIVERY_WATCH_BOUND_MINUTES
2638
+ }
2639
+ if (!isObject(watch)) return { state: 'unwatched', reason: 'no observation recorded yet', ...(coerced ? { boundWarning: coerced } : {}) }
2640
+ if (DELIVERY_BLOCKING_QUEUE_STATES.includes(watch.queueState)) {
2641
+ return { state: 'blocked', stalledForMs: 0, reason: `the queue reports "${watch.queueState}" — a self-explaining block; hand back with it named rather than waiting out the bound`, ...(coerced ? { boundWarning: coerced } : {}) }
2642
+ }
2643
+ const last = Date.parse(watch.lastObservedAt)
2644
+ const changed = Date.parse(watch.lastChangeAt)
2645
+ const at = now === undefined ? Date.now() : (typeof now === 'string' ? Date.parse(now) : now)
2646
+ if (!Number.isFinite(last) || !Number.isFinite(changed) || !Number.isFinite(at)) {
2647
+ return { state: 'unknown', reason: `watch timestamps are unparseable (lastObservedAt=${JSON.stringify(watch.lastObservedAt)}, lastChangeAt=${JSON.stringify(watch.lastChangeAt)}) — the bound cannot be evaluated, so this is treated as ACTIONABLE, never as progress`, ...(coerced ? { boundWarning: coerced } : {}) }
2648
+ }
2649
+ // A nudge RESTARTS the stall clock, once — that is what "one bounded re-evaluation
2650
+ // request" buys, and it is why the nudge is capped: an uncapped one would keep a wedged
2651
+ // unit looking alive forever, which is the epic-986 shape with extra steps.
2652
+ const nudges = Array.isArray(watch.nudges) ? watch.nudges : []
2653
+ const lastNudge = nudges.length ? Date.parse(nudges[nudges.length - 1].at) : NaN
2654
+ const origin = Number.isFinite(lastNudge) && lastNudge > changed ? lastNudge : changed
2655
+ const stalledForMs = at - origin
2656
+ if (stalledForMs > bound * 60_000) {
2657
+ return { state: 'stalled', stalledForMs, reason: `the queue has reported "${watch.queueState}"${watch.position === undefined || watch.position === null ? '' : ` at position ${watch.position}`} without moving for ${Math.round(stalledForMs / 60_000)}m (bound ${bound}m)${nudges.length ? `, including after ${nudges.length} nudge(s)` : ''} — hand back with the state quoted`, ...(coerced ? { boundWarning: coerced } : {}) }
2658
+ }
2659
+ // The request never landed: nothing is holding this PR. Distinct from "the queue is
2660
+ // holding it pending conditions", and the remedy is the opposite — re-request rather
2661
+ // than wait. Not a retry of the merge path: nothing was ever attempted.
2662
+ if (watch.queueState === 'not-enqueued') {
2663
+ return { state: 'not-enqueued', stalledForMs, action: 'requeue', reason: 'the queue is not holding this PR — the enqueue request never landed (no acknowledgement), so re-request it; waiting cannot fix a command nobody read', ...(coerced ? { boundWarning: coerced } : {}) }
2664
+ }
2665
+ if (
2666
+ flow?.supportsNudge
2667
+ && DELIVERY_PREADMISSION_STATES.includes(watch.queueState)
2668
+ && nudges.length < DELIVERY_MAX_NUDGES
2669
+ && stalledForMs > DELIVERY_NUDGE_AFTER_MINUTES * 60_000
2670
+ // Only when the queue lists nothing outstanding. With a condition still unmet the
2671
+ // queue is not stale, it is correct, and a refresh would just re-read the same red.
2672
+ && !(Array.isArray(watch.unmetConditions) && watch.unmetConditions.length > 0)
2673
+ ) {
2674
+ return { state: 'nudge-due', stalledForMs, action: 'nudge', command: flow.ops?.nudge ?? null, reason: `pre-admission at "${watch.queueState}" for ${Math.round(stalledForMs / 60_000)}m with no condition listed unmet — spend the one ${flow.provider} re-evaluation request (${flow.ops?.nudge ?? 'nudge'}) before waiting out the ${bound}m bound`, ...(coerced ? { boundWarning: coerced } : {}) }
2675
+ }
2676
+ return { state: 'progressing', stalledForMs, reason: `last change ${Math.round(stalledForMs / 60_000)}m ago, within the ${bound}m bound`, ...(coerced ? { boundWarning: coerced } : {}) }
2677
+ }
2678
+
2679
+ // ---- gate results: making the removal path's rule 2 executable (#468) ----
2680
+ //
2681
+ // The removal path says the handoff must measure each mechanized gate's unique/blocking
2682
+ // findings, attributed fixes, repeated known warnings, noise, and optional cost. Retirement
2683
+ // requires durable evidence of zero yield across a configurable run window; narrowing
2684
+ // and deduplication are independent recommendations. That audit had never once run
2685
+ // because the data did
2686
+ // not exist: **a gate that passed and a gate that never ran were indistinguishable.**
2687
+ //
2688
+ // Measured on merged PRs in the window where all four newer gates existed (n=79): Self-
2689
+ // Review evidenced on 35%, reviewer-parity 27%, runtime smoke 10%, evidence-block 9%. Under
2690
+ // this skill's own rule — output without its proof did not run the skill — those are gates
2691
+ // that mostly did not run, and their *value* is therefore unmeasurable. So is the cost
2692
+ // argument for removing any of them, which is why #471 waits on this.
2693
+ //
2694
+ // Every committed artifact being green is survivorship, not a result: a producer commits
2695
+ // the artifact once it is green, so a gate that caught something and got fixed looks
2696
+ // exactly like a gate that never fired. `findings` and `fixed` exist to break that tie.
2697
+ //
2698
+ // THE DESIGN CONSTRAINT, learned from this manifest set: mechanizing a record works only
2699
+ // when something downstream READS it. From 522 units — `units[].rounds` 0 -> 83% adoption
2700
+ // (the breaker reads it), `skillsRoot` 0 -> 90% (`validate` reads it), `contractRevision`
2701
+ // 0 -> 57% (`contract-revision.mjs` reads it), and `delivery` **0 of 522** — fully
2702
+ // mechanized, documented, dedicated commands, and never once written, because nothing
2703
+ // consumes it. Hence `gateAudit` ships beside the writer rather than after it.
2704
+ export const GATE_NAMES = ['scan-diff', 'coverage-checklist', 'evidence-block', 'runtime-smoke', 'self-review', 'smoke']
2705
+ export const GATE_RESULTS = ['clean', 'findings', 'failed', 'skipped']
2706
+ export const GATE_FINDING_OUTCOMES = ['open', 'fixed', 'noise']
2707
+ export const GATE_RETIREMENT_MINIMUM_RUNS = 10
2708
+
2709
+ function validateGateFinding(finding, label) {
2710
+ if (!isObject(finding)) return [`${label} must be an object`]
2711
+ const errors = []
2712
+ if (!nonEmptyString(finding.class)) errors.push(`${label}.class must be a non-empty string`)
2713
+ if (!nonEmptyString(finding.locator)) errors.push(`${label}.locator must be a non-empty string`)
2714
+ if (typeof finding.blocking !== 'boolean') errors.push(`${label}.blocking must be a boolean`)
2715
+ if (!GATE_FINDING_OUTCOMES.includes(finding.outcome)) errors.push(`${label}.outcome must be one of ${GATE_FINDING_OUTCOMES.join('|')}`)
2716
+ const unknown = Object.keys(finding).filter((key) => !['class', 'locator', 'blocking', 'outcome'].includes(key))
2717
+ if (unknown.length) errors.push(`${label} has unknown property: ${unknown[0]}`)
2718
+ return errors
2719
+ }
2720
+
2721
+ function gateFinding(finding) {
2722
+ return {
2723
+ class: finding.class.trim(),
2724
+ locator: finding.locator.trim(),
2725
+ blocking: finding.blocking,
2726
+ outcome: finding.outcome,
2727
+ }
2728
+ }
2729
+
2730
+ const gateFingerprint = (finding) => `${finding.class}\u0000${finding.locator}`
2731
+
2732
+ // Refused ONLY on its own shape — never on unrelated unit state, and never by the breaker.
2733
+ // Same discipline as record-round and record-delivery-observation: a logging call that can
2734
+ // hard-fail is one an orchestrator learns to stop making, and non-adoption is exactly the
2735
+ // failure this exists to end.
2736
+ export function recordGateResult(manifest, unitId, entry) {
2737
+ if (!isObject(entry)) throw new Error('gate result must be an object')
2738
+ if (!GATE_NAMES.includes(entry.gate)) {
2739
+ throw new Error(`gate must be one of ${GATE_NAMES.join('|')} (got ${JSON.stringify(entry.gate)})`)
2740
+ }
2741
+ if (typeof entry.ran !== 'boolean') throw new Error('gate result requires ran: true|false — "it did not run" is the answer this record exists to make sayable')
2742
+ if (!GATE_RESULTS.includes(entry.result)) {
2743
+ throw new Error(`result must be one of ${GATE_RESULTS.join('|')} (got ${JSON.stringify(entry.result)})`)
2744
+ }
2745
+ if (entry.ran === false && entry.result !== 'skipped') {
2746
+ throw new Error('a gate result with ran:false must use result:"skipped" — a gate that did not execute cannot be clean, failed, or findings')
2747
+ }
2748
+ // A gate that did not run, or was skipped, owes WHY. Without it the audit can report a
2749
+ // count but not a decision, and "skipped 12 times" with no reason is indistinguishable
2750
+ // from a gate nobody bothered with — which is the state this issue found the loop in.
2751
+ if ((entry.ran === false || entry.result === 'skipped') && !nonEmptyString(entry.reason)) {
2752
+ throw new Error('a gate result with ran:false or result:"skipped" requires a reason — a silent skip is what made the removal-path audit unrunnable')
2753
+ }
2754
+ if (entry.findings !== undefined && (!Number.isInteger(entry.findings) || entry.findings < 0)) {
2755
+ throw new Error('gate result findings must be a non-negative integer')
2756
+ }
2757
+ // `result: 'findings'` with no count is the shape that breaks the tie this record was
2758
+ // built to break. `findings` is optional in general — a clean run has nothing to count —
2759
+ // but a gate ASSERTING it found something and then not saying how many audits as zero,
2760
+ // and zero findings with zero rejections is precisely the retirement verdict. So a gate
2761
+ // that just caught a bug gets flagged for removal. Require the count where the result
2762
+ // claims one, and require it to be at least 1: zero findings is `clean`, by definition.
2763
+ if (entry.result === 'findings' && !(Number.isInteger(entry.findings) && entry.findings >= 1)) {
2764
+ throw new Error('result "findings" requires findings >= 1 — a gate that reports a catch without a count audits as zero, and zero findings with zero rejections is the retirement verdict; if it found nothing, the result is "clean"')
2765
+ }
2766
+ if (entry.result !== 'findings' && Number.isInteger(entry.findings) && entry.findings > 0) {
2767
+ throw new Error(`result "${entry.result}" cannot report positive findings — use result:"findings" so the observation is not mislabeled or discarded`)
2768
+ }
2769
+ if (entry.result !== 'findings' && Array.isArray(entry.findingDetails) && entry.findingDetails.length > 0) {
2770
+ throw new Error(`result "${entry.result}" cannot carry findingDetails — use result:"findings" so the observation is not mislabeled or discarded`)
2771
+ }
2772
+ if (entry.result !== 'findings' && Array.isArray(entry.fixed) && entry.fixed.length > 0) {
2773
+ throw new Error(`result "${entry.result}" cannot carry fixed evidence — use result:"findings" so the attributed value is not discarded`)
2774
+ }
2775
+ // Silent coercion is data loss in a record whose whole premise is that gate history stops
2776
+ // going unrecorded. `round` and `headSha` are what tie a catch back to the round and
2777
+ // commit that made it, and a stringified round or numeric sha used to be dropped without
2778
+ // a word — the caller believing it recorded provenance that is not there.
2779
+ if (entry.round !== undefined && !Number.isInteger(entry.round)) {
2780
+ throw new Error('gate result round must be an integer — a stringified round is dropped silently otherwise, losing the link between a catch and the round that made it')
2781
+ }
2782
+ if (entry.headSha !== undefined && !nonEmptyString(entry.headSha)) {
2783
+ throw new Error('gate result headSha must be a non-empty string')
2784
+ }
2785
+ if (entry.fixed !== undefined && (!Array.isArray(entry.fixed) || !entry.fixed.every(nonEmptyString))) {
2786
+ throw new Error('gate result fixed must be an array of non-empty strings')
2787
+ }
2788
+ if (entry.fixed !== undefined && entry.findingDetails !== undefined) {
2789
+ throw new Error('gate result must not combine fixed and findingDetails — record each attributable fix once')
2790
+ }
2791
+ if (entry.durationMs !== undefined && (!Number.isFinite(entry.durationMs) || entry.durationMs < 0)) {
2792
+ throw new Error('gate result durationMs must be a non-negative number')
2793
+ }
2794
+ if (entry.findingDetails !== undefined) {
2795
+ if (!Array.isArray(entry.findingDetails)) throw new Error('gate result findingDetails must be an array')
2796
+ const errors = entry.findingDetails.flatMap((finding, index) => validateGateFinding(finding, `findingDetails[${index}]`))
2797
+ if (errors.length) throw new Error(`gate result ${errors[0]}`)
2798
+ }
2799
+ const out = clone(manifest)
2800
+ const { units, index } = unitOrThrow(out, unitId)
2801
+ const unit = units[index]
2802
+ const log = Array.isArray(unit.gateResults) ? unit.gateResults : []
2803
+ log.push({
2804
+ gate: entry.gate,
2805
+ ran: entry.ran,
2806
+ result: entry.result,
2807
+ ...(Number.isInteger(entry.round) ? { round: entry.round } : {}),
2808
+ ...(nonEmptyString(entry.headSha) ? { headSha: entry.headSha } : {}),
2809
+ ...(entry.findings !== undefined ? { findings: entry.findings } : {}),
2810
+ ...(Array.isArray(entry.fixed) && entry.fixed.length ? { fixed: entry.fixed } : {}),
2811
+ ...(nonEmptyString(entry.reason) ? { reason: entry.reason } : {}),
2812
+ ...(Array.isArray(entry.findingDetails) ? { findingDetails: entry.findingDetails.map(gateFinding) } : {}),
2813
+ ...(Number.isFinite(entry.durationMs) ? { durationMs: entry.durationMs } : {}),
2814
+ at: nonEmptyString(entry.at) ? entry.at : new Date().toISOString(),
2815
+ })
2816
+ unit.gateResults = log
2817
+ return out
2818
+ }
2819
+
2820
+ export function validateGateResults(unit) {
2821
+ const log = unit?.gateResults
2822
+ if (log === undefined) return []
2823
+ if (!Array.isArray(log)) return ['gateResults must be an array']
2824
+ const errors = []
2825
+ log.forEach((e, i) => {
2826
+ const at = `gateResults[${i}]`
2827
+ if (!isObject(e)) { errors.push(`${at} must be an object`); return }
2828
+ if (!GATE_NAMES.includes(e.gate)) errors.push(`${at}.gate must be one of ${GATE_NAMES.join('|')}`)
2829
+ if (typeof e.ran !== 'boolean') errors.push(`${at}.ran must be a boolean`)
2830
+ if (!GATE_RESULTS.includes(e.result)) errors.push(`${at}.result must be one of ${GATE_RESULTS.join('|')}`)
2831
+ if (e.ran === false && e.result !== 'skipped') {
2832
+ errors.push(`${at} has ran:false but result is not "skipped" — a non-execution cannot be clean, failed, or findings`)
2833
+ }
2834
+ if ((e.ran === false || e.result === 'skipped') && !nonEmptyString(e.reason)) {
2835
+ errors.push(`${at} has ran:false or result:"skipped" but no reason — a silent skip is unauditable`)
2836
+ }
2837
+ // These mirror recordGateResult exactly. They have to: `merge`, `set`, a hand-edit or a
2838
+ // future migration can all write this array, and anything validate does not check is
2839
+ // data the audit will silently believe.
2840
+ if (e.findings !== undefined && (!Number.isInteger(e.findings) || e.findings < 0)) {
2841
+ errors.push(`${at}.findings must be a non-negative integer`)
2842
+ }
2843
+ if (e.result === 'findings' && !(Number.isInteger(e.findings) && e.findings >= 1)) {
2844
+ errors.push(`${at} has result:"findings" but no findings >= 1 — it audits as zero, which is the retirement verdict for a gate that caught something`)
2845
+ }
2846
+ if (e.result !== 'findings' && Number.isInteger(e.findings) && e.findings > 0) {
2847
+ errors.push(`${at} has result:"${e.result}" but positive findings — the observation is mislabeled`)
2848
+ }
2849
+ if (e.result !== 'findings' && Array.isArray(e.findingDetails) && e.findingDetails.length > 0) {
2850
+ errors.push(`${at} has result:"${e.result}" but findingDetails — the observation is mislabeled`)
2851
+ }
2852
+ if (e.result !== 'findings' && Array.isArray(e.fixed) && e.fixed.length > 0) {
2853
+ errors.push(`${at} has result:"${e.result}" but fixed evidence — the attributed value would be discarded`)
2854
+ }
2855
+ if (e.fixed !== undefined && (!Array.isArray(e.fixed) || !e.fixed.every(nonEmptyString))) {
2856
+ errors.push(`${at}.fixed must be an array of non-empty strings`)
2857
+ }
2858
+ if (e.fixed !== undefined && e.findingDetails !== undefined) {
2859
+ errors.push(`${at} must not combine fixed and findingDetails — it double-counts attributable fixes`)
2860
+ }
2861
+ if (e.round !== undefined && !Number.isInteger(e.round)) errors.push(`${at}.round must be an integer`)
2862
+ if (e.headSha !== undefined && !nonEmptyString(e.headSha)) errors.push(`${at}.headSha must be a non-empty string`)
2863
+ if (e.durationMs !== undefined && (!Number.isFinite(e.durationMs) || e.durationMs < 0)) {
2864
+ errors.push(`${at}.durationMs must be a non-negative number`)
2865
+ }
2866
+ if (e.findingDetails !== undefined) {
2867
+ if (!Array.isArray(e.findingDetails)) errors.push(`${at}.findingDetails must be an array`)
2868
+ else errors.push(...e.findingDetails.flatMap((finding, index) => validateGateFinding(finding, `${at}.findingDetails[${index}]`)))
2869
+ }
2870
+ const unknown = Object.keys(e).filter((k) => !['gate', 'ran', 'result', 'round', 'headSha', 'findings', 'fixed', 'findingDetails', 'durationMs', 'reason', 'at'].includes(k))
2871
+ if (unknown.length) errors.push(`${at} has unknown property: ${unknown[0]}`)
2872
+ })
2873
+ return errors
2874
+ }
2875
+
2876
+ // THE CONSUMER. Without this the field is `delivery` all over again — mechanized,
2877
+ // documented, and never written. Returns one row per gate that has any record, plus the
2878
+ // gates that have none, because "no record at all" is the finding the removal path most
2879
+ // needs and the one an aggregate over present rows would hide.
2880
+ function gateAccumulator(gate) {
2881
+ return {
2882
+ gate, ran: 0, skipped: 0, notRun: 0, failed: 0, findings: 0, fixed: 0, clean: 0,
2883
+ unreadable: 0, reasons: [], units: new Set(), durationMs: [],
2884
+ structuredOccurrences: 0, unique: new Map(), legacyFixes: new Set(),
2885
+ }
2886
+ }
2887
+
2888
+ function mergeFinding(row, finding, occurrences = 1) {
2889
+ if (!isObject(finding) || !nonEmptyString(finding.class) || !nonEmptyString(finding.locator)) return false
2890
+ const normalized = {
2891
+ class: finding.class.trim(),
2892
+ locator: finding.locator.trim(),
2893
+ blocking: finding.blocking === true,
2894
+ }
2895
+ const fingerprint = nonEmptyString(finding.fingerprint) ? finding.fingerprint : gateFingerprint(normalized)
2896
+ const current = row.unique.get(fingerprint) || {
2897
+ fingerprint,
2898
+ class: normalized.class,
2899
+ locator: normalized.locator,
2900
+ blocking: false,
2901
+ outcomes: new Set(),
2902
+ occurrences: 0,
2903
+ }
2904
+ current.blocking ||= normalized.blocking
2905
+ const outcomes = Array.isArray(finding.outcomes) ? finding.outcomes : [finding.outcome]
2906
+ for (const outcome of outcomes) if (GATE_FINDING_OUTCOMES.includes(outcome)) current.outcomes.add(outcome)
2907
+ const count = Number.isInteger(occurrences) && occurrences > 0 ? occurrences : 1
2908
+ current.occurrences += count
2909
+ row.structuredOccurrences += count
2910
+ row.unique.set(fingerprint, current)
2911
+ return true
2912
+ }
2913
+
2914
+ function normalizedUnique(row) {
2915
+ return [...row.unique.values()].map((finding) => ({
2916
+ ...finding,
2917
+ outcomes: [...finding.outcomes].sort(),
2918
+ })).sort((a, b) => a.fingerprint.localeCompare(b.fingerprint))
2919
+ }
2920
+
2921
+ function localGateAudit(manifest) {
2922
+ const units = Array.isArray(manifest?.units) ? manifest.units : []
2923
+ const rows = new Map(GATE_NAMES.map((gate) => [gate, gateAccumulator(gate)]))
2924
+ for (const unit of units) {
2925
+ for (const entry of (Array.isArray(unit?.gateResults) ? unit.gateResults : [])) {
2926
+ const row = rows.get(entry?.gate)
2927
+ if (!row) continue
2928
+ row.units.add(unit.id)
2929
+ if (!isObject(entry) || validateGateResults({ gateResults: [entry] }).length > 0) {
2930
+ row.unreadable += 1
2931
+ continue
2932
+ }
2933
+ if (entry.ran === false || entry.result === 'skipped') {
2934
+ if (entry.ran === false) row.notRun += 1
2935
+ else row.skipped += 1
2936
+ if (nonEmptyString(entry.reason)) row.reasons.push(entry.reason)
2937
+ continue
2938
+ }
2939
+ row.ran += 1
2940
+ if (entry.result === 'clean') row.clean += 1
2941
+ if (entry.result === 'failed') row.failed += 1
2942
+ if (Number.isInteger(entry.findings) && entry.findings > 0) row.findings += entry.findings
2943
+ if (Array.isArray(entry.fixed)) {
2944
+ row.fixed += entry.fixed.length
2945
+ for (const fixed of entry.fixed) row.legacyFixes.add(fixed)
2946
+ }
2947
+ if (Array.isArray(entry.findingDetails)) {
2948
+ for (const finding of entry.findingDetails) mergeFinding(row, finding)
2949
+ }
2950
+ if (Number.isFinite(entry.durationMs)) row.durationMs.push(entry.durationMs)
2951
+ }
2952
+ }
2953
+ return GATE_NAMES.map((gate) => {
2954
+ const row = rows.get(gate)
2955
+ const unique = normalizedUnique(row)
2956
+ const totalDurationMs = row.durationMs.reduce((sum, value) => sum + value, 0)
2957
+ const recorded = row.ran + row.skipped + row.notRun + row.unreadable
2958
+ const repeatedKnownWarnings = unique.filter((finding) => !finding.blocking).reduce((sum, finding) => sum + Math.max(0, finding.occurrences - 1), 0)
2959
+ const noiseFindings = unique.filter((finding) => finding.outcomes.includes('noise')).length
2960
+ const fixesAttributed = unique.filter((finding) => finding.outcomes.includes('fixed')).length + row.legacyFixes.size
2961
+ return {
2962
+ gate,
2963
+ ran: row.ran,
2964
+ skipped: row.skipped,
2965
+ failed: row.failed,
2966
+ findings: row.findings,
2967
+ fixed: row.fixed,
2968
+ units: row.units.size,
2969
+ reasons: [...new Set(row.reasons)].slice(0, 5),
2970
+ uniqueFindings: unique.length,
2971
+ blockingFindings: unique.filter((finding) => finding.blocking).length,
2972
+ repeatedKnownWarnings,
2973
+ fixesAttributed,
2974
+ noiseFindings,
2975
+ unclassifiedFindings: Math.max(0, row.findings - row.structuredOccurrences),
2976
+ unique,
2977
+ states: { ranClean: row.clean, notRun: row.notRun, skipped: row.skipped, unreadable: row.unreadable },
2978
+ cost: {
2979
+ observedRuns: row.durationMs.length,
2980
+ totalDurationMs,
2981
+ approximateMeanMs: row.durationMs.length ? Math.round(totalDurationMs / row.durationMs.length) : null,
2982
+ },
2983
+ unrecorded: recorded === 0,
2984
+ retirementCandidate: false,
2985
+ recommendations: { retirement: false, narrowing: noiseFindings > 0 || repeatedKnownWarnings > 0, deduplication: repeatedKnownWarnings > 0 },
2986
+ observationWindow: { runs: 1, minimumRuns: GATE_RETIREMENT_MINIMUM_RUNS, sufficient: false },
2987
+ }
2988
+ })
2989
+ }
2990
+
2991
+ function mergeAuditRow(target, source) {
2992
+ if (!isObject(source)) {
2993
+ target.unreadable += 1
2994
+ return
2995
+ }
2996
+ for (const key of ['ran', 'skipped', 'failed', 'findings', 'fixed']) {
2997
+ if (Number.isInteger(source[key]) && source[key] >= 0) target[key] += source[key]
2998
+ }
2999
+ if (Number.isInteger(source.states?.ranClean) && source.states.ranClean >= 0) target.clean += source.states.ranClean
3000
+ if (Number.isInteger(source.states?.notRun) && source.states.notRun >= 0) target.notRun += source.states.notRun
3001
+ if (Number.isInteger(source.states?.unreadable) && source.states.unreadable >= 0) target.unreadable += source.states.unreadable
3002
+ if (Array.isArray(source.reasons)) target.reasons.push(...source.reasons.filter(nonEmptyString))
3003
+ if (Array.isArray(source.unique)) {
3004
+ for (const finding of source.unique) mergeFinding(target, finding, finding?.occurrences)
3005
+ }
3006
+ if (Number.isInteger(source.cost?.observedRuns) && source.cost.observedRuns > 0 && Number.isFinite(source.cost.totalDurationMs)) {
3007
+ const mean = source.cost.totalDurationMs / source.cost.observedRuns
3008
+ target.durationMs.push(...Array.from({ length: source.cost.observedRuns }, () => mean))
3009
+ }
3010
+ }
3011
+ function isZeroYieldObservation(observation, gate) {
3012
+ if (!isObject(observation) || observation.unreadable === true || !Array.isArray(observation.gateValue)) return false
3013
+ const row = observation.gateValue.find((candidate) => candidate?.gate === gate)
3014
+ if (!isObject(row)) return false
3015
+ return row.ran > 0
3016
+ && row.failed === 0
3017
+ && row.findings === 0
3018
+ && row.uniqueFindings === 0
3019
+ && row.states?.notRun === 0
3020
+ && row.states?.skipped === 0
3021
+ && row.states?.unreadable === 0
3022
+ }
3023
+
3024
+
3025
+ export function gateAudit(manifest, options = {}) {
3026
+ const minimumRuns = Number.isInteger(options.minimumObservationRuns) && options.minimumObservationRuns > 0
3027
+ ? options.minimumObservationRuns
3028
+ : GATE_RETIREMENT_MINIMUM_RUNS
3029
+ const local = localGateAudit(manifest)
3030
+ if (!Array.isArray(options.series)) {
3031
+ return local.map((row) => ({
3032
+ ...row,
3033
+ observationWindow: { runs: 1, minimumRuns, zeroYieldStreak: row.ran > 0 && row.findings === 0 && row.failed === 0 && row.uniqueFindings === 0 && row.skipped === 0 && row.states.unreadable === 0 ? 1 : 0, sufficient: false },
3034
+ }))
3035
+ }
3036
+
3037
+ return GATE_NAMES.map((gate) => {
3038
+ const aggregate = gateAccumulator(gate)
3039
+ let absentRuns = 0
3040
+ for (const observation of options.series) {
3041
+ if (!isObject(observation) || observation.unreadable === true || !Array.isArray(observation.gateValue)) {
3042
+ aggregate.unreadable += 1
3043
+ continue
3044
+ }
3045
+ const source = observation.gateValue.find((row) => row?.gate === gate)
3046
+ if (!source) {
3047
+ absentRuns += 1
3048
+ continue
3049
+ }
3050
+ mergeAuditRow(aggregate, source)
3051
+ }
3052
+ const unique = normalizedUnique(aggregate)
3053
+ const repeatedKnownWarnings = unique.filter((finding) => !finding.blocking).reduce((sum, finding) => sum + Math.max(0, finding.occurrences - 1), 0)
3054
+ const blockingFindings = unique.filter((finding) => finding.blocking).length
3055
+ const fixesAttributed = unique.filter((finding) => finding.outcomes.includes('fixed')).length + aggregate.fixed
3056
+ const noiseFindings = unique.filter((finding) => finding.outcomes.includes('noise')).length
3057
+ const totalDurationMs = aggregate.durationMs.reduce((sum, value) => sum + value, 0)
3058
+ const runs = options.series.length
3059
+ let zeroYieldStreak = 0
3060
+ for (let index = options.series.length - 1; index >= 0 && isZeroYieldObservation(options.series[index], gate); index--) zeroYieldStreak += 1
3061
+ const sufficient = zeroYieldStreak >= minimumRuns
3062
+ const states = { ranClean: aggregate.clean, notRun: aggregate.notRun + absentRuns, skipped: aggregate.skipped, unreadable: aggregate.unreadable }
3063
+ const retirement = sufficient
3064
+ return {
3065
+ gate,
3066
+ ran: aggregate.ran,
3067
+ skipped: aggregate.skipped,
3068
+ failed: aggregate.failed,
3069
+ findings: aggregate.findings,
3070
+ fixed: aggregate.fixed,
3071
+ units: null,
3072
+ reasons: [...new Set(aggregate.reasons)].slice(0, 5),
3073
+ uniqueFindings: unique.length,
3074
+ blockingFindings,
3075
+ repeatedKnownWarnings,
3076
+ fixesAttributed,
3077
+ noiseFindings,
3078
+ unclassifiedFindings: Math.max(0, aggregate.findings - aggregate.structuredOccurrences),
3079
+ unique,
3080
+ states,
3081
+ cost: {
3082
+ observedRuns: aggregate.durationMs.length,
3083
+ totalDurationMs,
3084
+ approximateMeanMs: aggregate.durationMs.length ? Math.round(totalDurationMs / aggregate.durationMs.length) : null,
3085
+ },
3086
+ unrecorded: aggregate.ran + aggregate.skipped + aggregate.notRun + aggregate.unreadable === 0,
3087
+ retirementCandidate: retirement,
3088
+ recommendations: {
3089
+ retirement,
3090
+ narrowing: noiseFindings > 0 || repeatedKnownWarnings > 0,
3091
+ deduplication: repeatedKnownWarnings > 0,
3092
+ },
3093
+ observationWindow: { runs, minimumRuns, zeroYieldStreak, sufficient },
3094
+ }
3095
+ })
3096
+ }
3097
+
3098
+ // Surfaced by validate. A merge-ready unit with no gate results at all is the state that
3099
+ // made this issue necessary — it reads as "all gates passed" and means "nobody said".
3100
+ export function gateResultWarnings(unit) {
3101
+ if (unit?.status !== 'merge-ready') return []
3102
+ const log = Array.isArray(unit?.gateResults) ? unit.gateResults : []
3103
+ if (log.length) return []
3104
+ return ['status is merge-ready but no gate results were recorded — a gate that passed and a gate that never ran are indistinguishable here; record them with `record-gate-result` so the handoff\'s gate audit means something (removal path, rule 2)']
3105
+ }
3106
+
3107
+ // Recording an observation is NEVER refused, for the same reason record-round is not: a
3108
+ // logging call that can hard-fail is one an orchestrator learns to stop making, and a
3109
+ // watch nobody writes is the failure this bound exists to end. The signal is the
3110
+ // returned status (and the CLI's exit code), not a throw.
3111
+ export function recordDeliveryObservation(manifest, unitId, observation) {
3112
+ if (!isObject(observation)) throw new Error('delivery observation must be an object')
3113
+ if (!nonEmptyString(observation.queueState)) throw new Error('delivery observation requires queueState — read it from the queue tool, never infer it')
3114
+ const at = nonEmptyString(observation.at) ? observation.at : new Date().toISOString()
3115
+ const out = clone(manifest)
3116
+ const { units, index } = unitOrThrow(out, unitId)
3117
+ const unit = units[index]
3118
+ const delivery = isObject(unit.delivery) ? unit.delivery : { attempts: [], outcome: null, mergedSha: null, blockedOn: null }
3119
+ const prev = isObject(delivery.watch) ? delivery.watch : null
3120
+ const position = observation.position === undefined ? null : observation.position
3121
+ // The queue's own unmet leaves, quoted. Pre-admission this is the difference between
3122
+ // "stale evaluation" and "correctly waiting on a red check" — the first is nudgeable,
3123
+ // the second is not — and on a hand-back it is the condition the loop owes verbatim.
3124
+ const unmetConditions = Array.isArray(observation.unmetConditions) ? [...observation.unmetConditions] : (prev?.unmetConditions ?? null)
3125
+ const conditionsKey = (list) => (Array.isArray(list) ? list.join('\u0000') : '')
3126
+ // The (state, position) pair is what "moving" means — plus the unmet-condition set,
3127
+ // because a condition going green is progress by any honest reading even while the
3128
+ // state string sits at "waiting". A poll that observes all three unchanged is evidence
3129
+ // of a stall, not of life.
3130
+ const moved = !prev
3131
+ || prev.queueState !== observation.queueState
3132
+ || (prev.position ?? null) !== position
3133
+ || conditionsKey(prev.unmetConditions) !== conditionsKey(unmetConditions)
3134
+ unit.delivery = {
3135
+ ...delivery,
3136
+ watch: {
3137
+ firstObservedAt: prev?.firstObservedAt || at,
3138
+ lastObservedAt: at,
3139
+ lastChangeAt: moved ? at : prev.lastChangeAt,
3140
+ queueState: observation.queueState,
3141
+ position,
3142
+ polls: (prev?.polls ?? 0) + 1,
3143
+ ...(Array.isArray(prev?.nudges) && prev.nudges.length ? { nudges: prev.nudges } : {}),
3144
+ ...(unmetConditions === null ? {} : { unmetConditions }),
3145
+ },
3146
+ }
3147
+ // NOT settleBreakerOrThrow. A watch write cannot violate a breaker or budget
3148
+ // invariant, and routing it through the full unit gate meant a unit carrying
3149
+ // UNRELATED pre-existing invalid state (a stateVersion-2 unit `merge-ready` with
3150
+ // `lastVerdict` unset — a resumable legacy shape run-state.md documents) could not
3151
+ // record observations at all. That recreates the silent stall this bound exists to
3152
+ // end, on exactly the resumed run that most needs it. Validate what we touched.
3153
+ const errors = validateDelivery(unit, deliveryMode(out), deliveryProvider(out))
3154
+ if (errors.length) throw new Error(errors[0])
3155
+ return out
3156
+ }
3157
+
3158
+ // The nudge, recorded. Refusable on its own shape and on the flow's capabilities — a
3159
+ // nudge on a provider that has no re-evaluation command is a fiction, and a second nudge
3160
+ // is the poll loop the cap exists to prevent. It deliberately does NOT go through
3161
+ // `recordDeliveryAttempt`: a nudge attempts no merge and touches no HEAD, so the
3162
+ // unchanged-HEAD retry refusal does not reach it and must not be made to.
3163
+ export function recordDeliveryNudge(manifest, unitId, nudge) {
3164
+ if (!isObject(nudge)) throw new Error('delivery nudge must be an object')
3165
+ const out = clone(manifest)
3166
+ const { units, index } = unitOrThrow(out, unitId)
3167
+ const unit = units[index]
3168
+ const mode = deliveryMode(out)
3169
+ const provider = deliveryProvider(out)
3170
+ const flow = DELIVERY_FLOWS[deliveryFlowKey(mode, provider)] ?? null
3171
+ if (!flow) throw new Error(`record-delivery-nudge cannot resolve the delivery flow (mode "${mode}", provider ${JSON.stringify(provider)}) — settle queueProvider before acting on the queue`)
3172
+ if (!flow.supportsNudge) {
3173
+ throw new Error(`the "${flow.key}" flow has no re-evaluation command — a nudge here would be a fiction; wait out the stall bound and hand back with the queue state quoted`)
3174
+ }
3175
+ const delivery = isObject(unit.delivery) ? unit.delivery : { attempts: [], outcome: null, mergedSha: null, blockedOn: null }
3176
+ if (delivery.outcome === 'merged' || delivery.outcome === 'handed-back') {
3177
+ throw new Error(`${unitId} already reached the terminal delivery outcome "${delivery.outcome}"`)
3178
+ }
3179
+ const watch = isObject(delivery.watch) ? delivery.watch : null
3180
+ if (!watch) throw new Error(`${unitId} has no delivery watch to nudge — record an observation first; a nudge with nothing observed is a poke in the dark`)
3181
+ const nudges = Array.isArray(watch.nudges) ? [...watch.nudges] : []
3182
+ if (nudges.length >= DELIVERY_MAX_NUDGES) {
3183
+ throw new Error(`${unitId} already spent its ${DELIVERY_MAX_NUDGES} nudge(s) — the second one is a poll loop, and the stall bound is what ends this unit now: hand back with "${watch.queueState}" quoted`)
3184
+ }
3185
+ const at = nonEmptyString(nudge.at) ? nudge.at : new Date().toISOString()
3186
+ nudges.push({ at, command: nonEmptyString(nudge.command) ? nudge.command : (flow.ops?.nudge ?? 'nudge'), ...(nonEmptyString(nudge.ref) ? { ref: nudge.ref } : {}) })
3187
+ unit.delivery = { ...delivery, watch: { ...watch, nudges } }
3188
+ const errors = validateDelivery(unit, mode, provider)
3189
+ if (errors.length) throw new Error(errors[0])
3190
+ return out
3191
+ }
3192
+
3193
+ // A queued PR whose HEAD has moved is a PR the queue is about to eject (`pull-request-
3194
+ // updated`), or has already ejected without anyone reading the reason. The loop pushes
3195
+ // for several ordinary purposes — an evidence-lane commit, a rebase, `mergify stack push`
3196
+ // — so the freeze cannot be enforced by refusing writes; it is surfaced instead, at the
3197
+ // one place that compares what is enqueued against what the unit's rounds now say.
3198
+ export function deliveryHeadDriftWarnings(unit) {
3199
+ const delivery = isObject(unit?.delivery) ? unit.delivery : null
3200
+ if (!delivery || delivery.outcome === 'merged' || delivery.outcome === 'handed-back') return []
3201
+ const attempts = Array.isArray(delivery.attempts) ? delivery.attempts : []
3202
+ const waiting = [...attempts].reverse().find((a) => a.outcome === 'waiting')
3203
+ if (!waiting) return []
3204
+ const rounds = Array.isArray(unit.rounds) ? unit.rounds : []
3205
+ const latest = [...rounds].reverse().find((r) => nonEmptyString(r?.headSha))
3206
+ if (!latest || latest.headSha === waiting.headSha) return []
3207
+ return [`${unit.id ?? 'unit'} has ${waiting.headSha} sitting in the queue while its latest round is at ${latest.headSha} — a push under an enqueued PR ejects it (dequeue reason "pull-request-updated"); dequeue explicitly before writing to an enqueued branch, and expect the queue to have dropped it already`]
3208
+ }
3209
+
3210
+ export function recordDeliveryOutcome(manifest, unitId, outcome) {
3211
+ if (!isObject(outcome)) throw new Error('delivery outcome must be an object')
3212
+ const out = clone(manifest)
3213
+ const { units, index } = unitOrThrow(out, unitId)
3214
+ const unit = units[index]
3215
+ if (effectiveUnitKind(unit) !== 'implementation') {
3216
+ throw new Error(`record-delivery-outcome is only for implementation units; ${unitId} is ${effectiveUnitKind(unit)}`)
3217
+ }
3218
+ const blockedOn = completionBlockers(out, unit)
3219
+ if (blockedOn.length) {
3220
+ throw new Error(`record-delivery-outcome refuses ${unitId}: completion dependency ${blockedOn.join(', ')} is not complete`)
3221
+ }
3222
+ if (!DELIVERY_OUTCOMES.includes(outcome.outcome)) throw new Error(`delivery outcome must be one of ${DELIVERY_OUTCOMES.join('|')}`)
3223
+ const delivery = isObject(unit.delivery) ? unit.delivery : { attempts: [], outcome: null, mergedSha: null, blockedOn: null }
3224
+ // Terminal is terminal for *re-decision* — you cannot flip merged→handed-back or
3225
+ // re-merge at a different SHA. Enrichment of the merge clock is different: #791's
3226
+ // postmortem prefers delivery.mergedAt, and an orchestrator that recorded `merged`
3227
+ // before the watch returned mergedAt (or against a skill build that did not yet
3228
+ // accept the field) must be able to fill it in without inventing a second outcome.
3229
+ // Refuse anything that would change outcome/mergedSha/blockedOn; accept only a
3230
+ // same-outcome merge that supplies a non-empty mergedAt when the stored one is empty.
3231
+ if (delivery.outcome === 'merged' || delivery.outcome === 'handed-back') {
3232
+ const sameOutcome = outcome.outcome === delivery.outcome
3233
+ const sameSha = outcome.outcome !== 'merged'
3234
+ || !nonEmptyString(outcome.mergedSha)
3235
+ || outcome.mergedSha === delivery.mergedSha
3236
+ const enrichingMergedAt = delivery.outcome === 'merged'
3237
+ && sameOutcome
3238
+ && sameSha
3239
+ && !nonEmptyString(delivery.mergedAt)
3240
+ && nonEmptyString(outcome.mergedAt)
3241
+ if (!enrichingMergedAt) {
3242
+ throw new Error(`${unitId} already reached the terminal delivery outcome "${delivery.outcome}"`)
3243
+ }
3244
+ const next = { ...delivery, mergedAt: outcome.mergedAt }
3245
+ const errors = validateDelivery({ ...unit, delivery: next }, deliveryMode(out), deliveryProvider(out))
3246
+ if (errors.length) throw new Error(errors[0])
3247
+ unit.delivery = next
3248
+ return settleBreakerOrThrow(manifest, unitId, out)
3249
+ }
3250
+ // The terminal write is the one that CLAIMS SUCCESS, so it is gated at least as hard
3251
+ // as the attempt that precedes it. It was gated on nothing: `merged` was accepted on a
3252
+ // `queued` unit with zero attempts, and on a unit just moved to `in-progress` by a
3253
+ // checks-failed dequeue — the exact state the doctrine says "owes a fresh verdict".
3254
+ if (unit.status !== 'merge-ready') {
3255
+ throw new Error(`record-delivery-outcome requires ${unitId} to be merge-ready (it is "${unit.status}") — a unit routed back into the loop owes a fresh verdict before it can claim delivery`)
3256
+ }
3257
+ const priorAttempts = Array.isArray(delivery.attempts) ? delivery.attempts : []
3258
+ // Only `merged` owes an attempt. A HAND-BACK is precisely the honest exit for a
3259
+ // delivery that could never be attempted — a unit whose first observation is `frozen`
3260
+ // (a prod incident you would not enqueue into) has zero attempts by construction, and
3261
+ // requiring one left it unable to terminate honestly at all: fabricate an attempt, or
3262
+ // stay "undelivered" forever.
3263
+ if (outcome.outcome === 'merged' && priorAttempts.length === 0) {
3264
+ throw new Error(`record-delivery-outcome "merged" on ${unitId} requires at least one recorded delivery attempt — a success with no action behind it is a claim, not a delivery`)
3265
+ }
3266
+ // Deliberately NOT gated on the last attempt's outcome. The documented queue flow
3267
+ // records the attempt as `waiting` and then watches; the queue merges at that same
3268
+ // HEAD, and demanding a second attempt that says `merged` made the only reachable
3269
+ // terminal for a successfully merged PR `handed-back` — the two refusals pointed at
3270
+ // each other. It was also the wrong instrument: an attempt outcome is a recorded
3271
+ // CLAIM, exactly what this gate's own doctrine says is not evidence. `mergedSha` is
3272
+ // required above, and `combine-and-verify.mjs delivered` is what proves it — only
3273
+ // ancestry is a result.
3274
+ const next = {
3275
+ ...delivery,
3276
+ outcome: outcome.outcome,
3277
+ mergedSha: outcome.outcome === 'merged' ? (outcome.mergedSha ?? null) : (delivery.mergedSha ?? null),
3278
+ // Prefer an explicit caller-supplied `mergedAt` (from watch-delivery / gh pr view) over
3279
+ // anything previously recorded. Only meaningful on a merged outcome — a hand-back has
3280
+ // no merge time, so drop a stale value rather than keep one that would lie.
3281
+ // (#791 / issue-664 / identity#669 hybrid merge unobserved)
3282
+ ...(outcome.outcome === 'merged'
3283
+ ? { mergedAt: nonEmptyString(outcome.mergedAt) ? outcome.mergedAt : (delivery.mergedAt ?? null) }
3284
+ : {}),
3285
+ blockedOn: outcome.outcome === 'handed-back' ? (outcome.blockedOn ?? null) : (delivery.blockedOn ?? null),
3286
+ }
3287
+ if (outcome.outcome !== 'merged' && 'mergedAt' in next) delete next.mergedAt
3288
+ const errors = validateDelivery({ ...unit, delivery: next }, deliveryMode(out), deliveryProvider(out))
3289
+ if (errors.length) throw new Error(errors[0])
3290
+ unit.delivery = next
3291
+ unit.disposition = outcome.outcome === 'merged' ? 'delivered' : 'handed-back'
3292
+ return settleBreakerOrThrow(manifest, unitId, out)
3293
+ }
3294
+
3295
+ // `warnings` is an optional sink the caller passes to collect migration warnings
3296
+ // (e.g. an obsolete `blocking` dropped from a legacy finding); the return stays the
3297
+ // migrated manifest so existing callers are unaffected.
3298
+ export function migrateLegacyUnitState(manifest, unitId, { version, warnings = [] } = {}) {
3299
+ if (version !== 1) throw new Error('legacy run-state migration requires version 1')
3300
+ const out = clone(manifest)
3301
+ const { units, index } = unitOrThrow(out, unitId)
3302
+ const unit = units[index]
3303
+ if (unit.stateVersion !== undefined) throw new Error('migrate-unit requires a legacy unit without stateVersion; repeated migration is not allowed')
3304
+ const migrated = convertLegacyUnitState(unit, warnings)
3305
+ units[index] = migrated
3306
+ return candidateOrThrow(manifest, unitId, out)
3307
+ }