@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,1014 @@
1
+ // evidence-bundle — the durable release-evidence bundle, emitted once per release attempt (#441).
2
+ //
3
+ // WHY THIS EXISTS. Principle 5 is that the loop exits on OBSERVED production state, observed
4
+ // twice — the candidate at 0%, then the live service after the shift. Step 4b's proof-of-execution
5
+ // captures those observations, but they land in the RUN MANIFEST, which is session-scoped
6
+ // (`run-state.mjs`'s job), plus whatever the transcript happens to hold. So the evidence that a
7
+ // release was verified — the thing the central principle rests on — does not reliably outlive the
8
+ // session that produced it, and a FAILED attempt is worse: it leaves a deleted revision, a normal
9
+ // issue, and nothing durably linking the two. This script makes the evidence a first-class,
10
+ // immutable artifact with a defined lifetime, keyed to the immutable attempt id AND its terminal
11
+ // state (principle 10), so a post-incident question is answerable months later without the originating
12
+ // session. The state is in the key because one attempt can reach two terminal states in sequence — a
13
+ // lease-holding `blocked-manual` hold that recovers on the SAME attempt and closes `released` — and
14
+ // each of those exits owes its own durable record (see the filename section).
15
+ //
16
+ // KEYED ON THE ATTEMPT ID, NOT THE RC TAG (#599). It used to be the rc tag, and that made the whole
17
+ // artifact unreachable on three of the four declarable trigger shapes: branch push, the legacy final
18
+ // tag, and "no deployed surface" all mint NO rc, so a terminal exit on them had no key to write
19
+ // under — and inventing one would fabricate the very artifact the bundle exists to make trustworthy.
20
+ // The attempt id exists on EVERY shape by construction, because the Release Attempt Lease opens at
21
+ // the promotion authorization rather than at the mint. Two consequences worth stating:
22
+ // - `rcTag` is demoted to an ordinary recorded field. It is still REQUIRED where an rc exists, and
23
+ // an absent one must be DECLARED (`deployTrigger` naming a shape that mints none) rather than
24
+ // merely omitted — otherwise dropping the old `e:missing-rc-tag` would silently let an rc-shaped
25
+ // release emit a durable record that forgot the tag it shipped under.
26
+ // - Bundles written BEFORE #599 are unaffected: one carrying only `rcTag` still keys, validates
27
+ // and locates (the key falls back to it), so `v1.2.0-rc.1--released.json` stays byte-identical.
28
+ // The id itself is `<version>-attempt.<n>` on all four shapes and is minted, not composed — see
29
+ // `attemptIdFor` / `nextAttemptId` below for why the version alone was not a usable key.
30
+ // The final tag was rejected as the key: it exists only after verification, which would leave a
31
+ // FAILED no-rc attempt with no key — precisely the case the bundle exists for.
32
+ //
33
+ // THE MANIFEST IS THE WORKING STATE; THE BUNDLE IS THE DURABLE RECORD. They are different things
34
+ // (#441's AC says so): the manifest is mutable working state the run reads and rewrites at every
35
+ // transition; the bundle is written ONCE, at a terminal exit, and never rewritten. This script
36
+ // does not touch the manifest and `run-state.mjs` does not write bundles.
37
+ //
38
+ // KEYS OFF #432'S ENUM, NEVER A PARALLEL ONE. A bundle is emitted per TERMINAL attempt state, and
39
+ // those states are the Release Attempt Lease's — imported from `run-state.mjs` (ATTEMPT_TERMINAL_STATES,
40
+ // ATTEMPT_CLOSE_CAUSES, ATTEMPT_TAG_DISPOSITIONS). Adding or renaming a terminal state there breaks
41
+ // this module's tests, which is the point: the bundle CONSUMES the state machine, it does not
42
+ // re-implement the enum.
43
+ //
44
+ // EMITTED ON EVERY TERMINAL PATH. `released` (verified), `failed-pre-promotion` (pre-exposure
45
+ // discard), `rolled-back` (post-exposure), `revoked`, `blocked-manual`. A failed attempt is
46
+ // validated exactly as strictly as a successful one — each state carries its OWN required
47
+ // observation set (`REQUIRED_BY_STATE`), so "a failed attempt is as well-evidenced as a successful
48
+ // one" is a schema rule, not an aspiration. What it is NOT: it does not degrade into workflow logs
49
+ // alone, a mutable summary, or final-tag-only evidence (which would lose every failed attempt).
50
+ //
51
+ // FAILS CLOSED. An incomplete or malformed bundle REFUSES — an under-populated durable record that
52
+ // looks complete is worse than none, the same reading candidate-identity.mjs takes on a null field
53
+ // the doctrine later compares against. And a bundle that CLAIMS an observation its state never took
54
+ // (a pre-exposure discard carrying a post-shift live verdict) refuses too: the point of a per-state
55
+ // schema is that the evidence present is evidence that was actually produced.
56
+ //
57
+ // IMMUTABLE ONCE WRITTEN (AC5). A mutable summary is not evidence. `emit` refuses to overwrite an
58
+ // existing bundle for an (attempt id, terminal state) with DIFFERENT content; an evidence-identical
59
+ // re-emit (a resumed session re-running the same close) is a resume-safe no-op that keeps the first
60
+ // write. Immutability is per (id, state): a DIFFERENT terminal state for the same attempt (the
61
+ // recovery case above) is a new, legal bundle, not an overwrite. Only the `emittedAt` write-stamp is
62
+ // exempt from the comparison — it is metadata about the write, not the evidence. The guarantee kept
63
+ // its exact shape across #599; only what identifies the attempt changed.
64
+ //
65
+ // Contract:
66
+ // validate --bundle <file> exit 0 valid · 1 invalid (failing ids in `failed`) · 2 usage
67
+ // emit --bundle <file> --dir <d> validate then write immutably to <d>/<attemptId>--<state>.json;
68
+ // exit 0 written/no-op · 1 invalid-or-immutability-violation · 2 usage
69
+ // path --attempt-id <id> --dir <d> [--state <s>] with --state, print the one file for that
70
+ // (id, state); without it, list every bundle for the attempt
71
+ // (reachable by id alone — one attempt can hold >1 bundle).
72
+ // `--rc-tag <tag>` is the pre-#599 spelling and still resolves,
73
+ // because a pre-#599 bundle IS keyed by its rc tag.
74
+
75
+ import fs from 'node:fs'
76
+ import path from 'node:path'
77
+
78
+ import {
79
+ ATTEMPT_TERMINAL_STATES,
80
+ ATTEMPT_CLOSE_CAUSES,
81
+ ATTEMPT_TAG_DISPOSITIONS,
82
+ ATTEMPT_ID_KEY_SAFE,
83
+ RELEASE_EVIDENCE_FIELDS,
84
+ } from '../../resolve-issues/scripts/release-state-contract.mjs'
85
+ import { shaMatches } from './candidate-identity.mjs'
86
+ import { classifyFailureOwner, classifyFailurePhase } from './version-postmortem.mjs'
87
+ import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
88
+
89
+ // The bundle's terminal-state roster is #432's, re-exported so callers (and the test) key off ONE
90
+ // list. Never redefine the five values here — import them.
91
+ export const BUNDLE_TERMINAL_STATES = ATTEMPT_TERMINAL_STATES
92
+ export const BUNDLE_FIELDS = RELEASE_EVIDENCE_FIELDS
93
+
94
+ // Present on every bundle regardless of terminal state. `attemptId` replaced `rcTag` here in #599 —
95
+ // see `bundleKey` for the pre-#599 fallback that keeps existing bundles keyed and valid.
96
+ export const REQUIRED_ALWAYS = ['attemptId', 'sha', 'buildIdentity', 'terminalState']
97
+
98
+ // The four declarable deploy-trigger shapes (SKILL.md pre-flight item 5 / references/versioning.md
99
+ // §5). The bundle carries the declaration so that an ABSENT rc tag is a decided absence with a
100
+ // checkable reason rather than an undeclared skip — principle 13's own distinction.
101
+ export const DEPLOY_TRIGGER_SHAPES = ['rc-tag', 'branch-push', 'final-tag', 'no-deploy']
102
+
103
+ // The three shapes that mint no rc at step 3. `rc-tag` is the only one that does, and it is the only
104
+ // one whose bundle owes an `rcTag`. Derived from the roster above rather than written out twice, so
105
+ // a fifth shape cannot be added on one side only.
106
+ export const NO_RC_TRIGGER_SHAPES = DEPLOY_TRIGGER_SHAPES.filter((s) => s !== 'rc-tag')
107
+
108
+ // The observation set each terminal state owes ON TOP of REQUIRED_ALWAYS, on a DEPLOYED shape.
109
+ // This table is the whole "a failed attempt is as well-evidenced as a successful one" rule made
110
+ // mechanical. It applies to `rc-tag`, `branch-push` and `final-tag`, and — because an UNDECLARED
111
+ // shape is every pre-#599 bundle, all of which deployed — to an undeclared one too. `no-deploy`
112
+ // has its own table below (#604); see `requiredFor`, which is the only thing that should read
113
+ // either of these:
114
+ // - released — the full verified path: both principle-5 observations (candidateVerdict at
115
+ // 0%, liveVerdict after the shift), the exposure approval, the traffic
116
+ // transition, and the candidate receipt.
117
+ // - failed-pre-promotion — a candidate-validation discard carries the candidate receipt, the
118
+ // 4a verdict (the DEAD/EMPTY/cannot-run that stopped it), and cleanup.
119
+ // A deployed pre-candidate failure carries its verified disposition instead:
120
+ // it has no candidate receipt/verdict to claim. Neither form exposed, so it
121
+ // owes NO liveVerdict and NO trafficTransition — claiming one is refused.
122
+ // - rolled-back — post-exposure: it DID shift, so it owes the transition + the live verdict
123
+ // that went unhealthy + the rollback disposition.
124
+ // - revoked — a deliberately abandoned attempt: the disposition, and tagDisposition
125
+ // "retained" (principle 10 — revocation revokes the attempt, never the tag).
126
+ // It abandoned WITHOUT shipping, so like failed-pre-promotion it owes NO
127
+ // liveVerdict and NO trafficTransition, and claiming one is refused below.
128
+ // - blocked-manual — parked on an operator: a reason and the disposition (what is being waited
129
+ // on). It holds the lease, so unlike the four above it carries NO closedBy.
130
+ export const REQUIRED_BY_STATE = {
131
+ released: ['candidateReceipt', 'candidateVerdict', 'approval', 'trafficTransition', 'liveVerdict'],
132
+ 'failed-pre-promotion': ['candidateReceipt', 'candidateVerdict', 'disposition'],
133
+ 'rolled-back': ['candidateReceipt', 'candidateVerdict', 'approval', 'trafficTransition', 'liveVerdict', 'disposition'],
134
+ revoked: ['disposition', 'tagDisposition'],
135
+ 'blocked-manual': ['reason', 'disposition'],
136
+ }
137
+
138
+ // A failed pre-candidate attempt has no candidate receipt or candidate verdict by definition.
139
+ // It still binds the attempt, SHA, build identity, close cause, and verified disposition through
140
+ // REQUIRED_ALWAYS plus this state-specific set; any candidate/exposure observation is fabricated.
141
+ export const REQUIRED_BY_STATE_PRE_CANDIDATE = {
142
+ 'failed-pre-promotion': ['disposition'],
143
+ }
144
+
145
+ // ---- the required set is (terminal state × TRIGGER SHAPE), not the state alone (#604) ----
146
+ //
147
+ // #599 made the KEY reachable on the three shapes that mint no rc. The required CONTENT above
148
+ // stayed keyed to a DEPLOYMENT, and on `no-deploy` — a shape #599 itself added — all five of
149
+ // `released`'s fields are artifacts only a deployment produces: a 0%-traffic candidate, a smoke
150
+ // against it, an exposure authorization, a traffic shift, a live service. None can exist there, so
151
+ // that shape could never reach a valid terminal bundle at all, and this repo's `v0.5.0` shipped
152
+ // with NO bundle for exactly that reason — principle 5's "a durable bundle at every terminal exit"
153
+ // quietly excluding a shape the skill offers at pre-flight, which is the state #599 was filed to end.
154
+ //
155
+ // The principle does not change: a bundle carries the evidence its shape actually produced, and
156
+ // refuses to claim an observation nobody took. What changes is that "what the shape produced" stops
157
+ // being ASSUMED to include a deployment. So the deployed shapes are untouched — `released` still
158
+ // owes all five, and a deployed release that cannot produce them is still not releasable — and
159
+ // `no-deploy` owes what it does produce: the version identity and immutable tag (`releaseTag`), the
160
+ // Release reference (`releaseRef`), and the verification evidence standing in for principle 5's two
161
+ // observations (`verification` — in a no-deploy repo, CI plus the full suite on the tagged SHA, and
162
+ // the consumer-facing read that shows the artifact resolvable at the tag; versioning.md §5).
163
+ //
164
+ // THIS MAKES THE REQUIREMENT SATISFIABLE; IT MUST NOT MAKE IT OPTIONAL. Two halves, and the second
165
+ // is the one a widening would drop: the fields above are required per shape (`requiredFor`), AND the
166
+ // five deployment fields are FORBIDDEN on `no-deploy` (`DEPLOY_EVIDENCE_FIELDS` below) — a no-deploy
167
+ // bundle asserting a traffic transition is the same fabrication `EXPOSURE_FORBIDDEN` already refuses,
168
+ // read per shape instead of per state.
169
+ //
170
+ // Only three states differ, because only three of them named deployment artifacts: `revoked`
171
+ // (disposition + tagDisposition) and `blocked-manual` (reason + disposition) owe nothing a
172
+ // deployment produces, so they are shape-independent and are NOT restated here — `requiredFor`
173
+ // falls through to the table above for them, which is what keeps one rule in one place.
174
+ //
175
+ // The final tag is cut only AFTER verification on this shape (versioning.md §5), so a no-deploy
176
+ // attempt that never got there has no tag and no Release: `failed-pre-promotion` owes the
177
+ // verification that stopped it and the cleanup record, and only the two post-publication states
178
+ // owe `releaseTag`/`releaseRef`.
179
+ export const REQUIRED_BY_STATE_NO_DEPLOY = {
180
+ released: ['releaseTag', 'releaseRef', 'verification'],
181
+ 'failed-pre-promotion': ['verification', 'disposition'],
182
+ 'rolled-back': ['releaseTag', 'releaseRef', 'verification', 'disposition'],
183
+ }
184
+
185
+ // The five fields that only a deployment can produce. Required on the deployed shapes at the states
186
+ // above; REFUSED on `no-deploy`, at EVERY terminal state including `blocked-manual` — the carve-out
187
+ // that lets a deployed `blocked-manual` carry exposure evidence exists because it can legitimately
188
+ // be parked POST-shift at step 4b, and on a shape with no traffic there is no post-shift to park in.
189
+ export const DEPLOY_EVIDENCE_FIELDS = ['candidateReceipt', 'candidateVerdict', 'approval', 'trafficTransition', 'liveVerdict']
190
+
191
+ // The one reader of both tables. A shape that is absent, null or unknown falls through to the
192
+ // deployed set: an undeclared shape is every pre-#599 bundle (all deployed), and an unknown one is
193
+ // already refused by `e:unknown-deploy-trigger` — neither may take the SMALLER required set, because
194
+ // "unrecognised" must never be the cheap way to owe less.
195
+ export function requiredFor(terminalState, deployTrigger, failurePhase = null) {
196
+ // A no-deploy release never creates a candidate, but it has its own required verification
197
+ // evidence. Keep that shape-specific contract ahead of the deployed pre-candidate failure set.
198
+ if (deployTrigger === 'no-deploy' && REQUIRED_BY_STATE_NO_DEPLOY[terminalState]) {
199
+ return REQUIRED_BY_STATE_NO_DEPLOY[terminalState]
200
+ }
201
+ if (failurePhase === 'pre-candidate' && REQUIRED_BY_STATE_PRE_CANDIDATE[terminalState]) {
202
+ return REQUIRED_BY_STATE_PRE_CANDIDATE[terminalState]
203
+ }
204
+ return REQUIRED_BY_STATE[terminalState]
205
+ }
206
+
207
+ // The states that ended WITHOUT shipping, so a live post-shift verdict or a traffic transition on
208
+ // one is not missing evidence, it is fabricated evidence: `failed-pre-promotion` (discarded before
209
+ // exposure) and `revoked` (deliberately abandoned without shipping — SKILL.md:433). NOT
210
+ // `blocked-manual`: it can legitimately be parked at step 4b, POST-shift, so partial exposure
211
+ // evidence on it is real, not a lie — it must stay allowed.
212
+ const EXPOSURE_FORBIDDEN = {
213
+ 'failed-pre-promotion': ['liveVerdict', 'trafficTransition'],
214
+ revoked: ['liveVerdict', 'trafficTransition'],
215
+ }
216
+
217
+ // NULL IS ABSENCE, EVERYWHERE IN THIS SCHEMA (#604's second finding, generalised). `"rcTag": null`
218
+ // used to produce `e:unusable-rc-tag` while OMITTING the key validated — and null is the natural
219
+ // thing for a generator to write for "this shape has none". The same trap sat one layer down on the
220
+ // forbidden fields, where `"liveVerdict": null` on a pre-exposure discard read as a CLAIMED
221
+ // observation and refused. One rule, applied at every optional-field decision: a key written null
222
+ // is a key that is not there. It is deliberately NOT applied to the required fields' own content
223
+ // checks, because those already answer correctly — a required object that is null is missing, and
224
+ // `e:missing-*` is exactly what it should say.
225
+ const absent = (v) => v === undefined || v === null
226
+
227
+ const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
228
+ const nonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0
229
+ // An observation field is a non-empty object; `reason` and `tagDisposition` are strings, handled
230
+ // on their own so the message names what each one is.
231
+ const nonEmptyObject = (v) => isObject(v) && Object.keys(v).length > 0
232
+
233
+ // EVIDENCE A DURABLE RECORD MAY CARRY AS PROSE OR AS A STRUCTURE (#604 fix round). The bundle is a
234
+ // record a human reads months later, and a Release URL and a verification statement are naturally
235
+ // STRINGS — so requiring an object rejected the spelling a human writes, and said
236
+ // `e:missing-release-ref` about a field that was right there. Which spelling is right depends on
237
+ // one question, and only one: does anything CROSS-CHECK a field inside it?
238
+ // - `releaseRef` / `verification` — nothing does. `{ ci: "green" }` is no more checkable than
239
+ // "CI green", so the object requirement bought no soundness and only cost a spelling. Both.
240
+ // - `candidateReceipt` — its `sourceSha` IS cross-checked (`e:receipt-sha-mismatch`), and a
241
+ // string receipt would slip past that guard silently. Object only, and the four fields beside
242
+ // it stay object-only with it rather than splitting the deployment set on a technicality.
243
+ // - `releaseTag` — a NAME that is compared against the attempt's version. String only.
244
+ const proseOrStructure = (v) => nonEmptyString(v) || nonEmptyObject(v)
245
+ const PROSE_OR_STRUCTURE_FIELDS = new Set(['releaseRef', 'verification'])
246
+
247
+ // A SHA the bundle can bind to: git's default abbreviation floor, same as candidate-identity.
248
+ const isSha = (s) => typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)
249
+
250
+ // ---- validation ----
251
+ //
252
+ // Returns { valid, failed: [id...] }. FAIL CLOSED: any missing, malformed, or fabricated field
253
+ // appends an id and flips `valid` false. The ids are kebab e:* strings so a caller can branch on
254
+ // which invariant broke, the same shape candidate-identity.mjs uses.
255
+
256
+ const DURABLE_RECEIPT_SCHEMA = 'resolve-release/durable-processing-receipt@1'
257
+ const DURABLE_PAUSE_ORDER = ['database-admission', 'worker-scheduling']
258
+ const DURABLE_RESUME_ORDER = [...DURABLE_PAUSE_ORDER].reverse()
259
+
260
+ function exactList(actual, expected) {
261
+ return Array.isArray(actual)
262
+ && actual.length === expected.length
263
+ && actual.every((value, index) => value === expected[index])
264
+ }
265
+
266
+ function validDurableReceipt(receipt) {
267
+ return nonEmptyObject(receipt)
268
+ && receipt.schema === DURABLE_RECEIPT_SCHEMA
269
+ && nonEmptyObject(receipt.writer)
270
+ && nonEmptyString(receipt.writer.id)
271
+ && nonEmptyString(receipt.writtenAt)
272
+ && Number.isFinite(Date.parse(receipt.writtenAt))
273
+ }
274
+
275
+ function validateDurableProcessing(value, add) {
276
+ if (!isObject(value) || typeof value.present !== 'boolean') {
277
+ add('e:unusable-durable-processing')
278
+ return
279
+ }
280
+ if (value.present === false) {
281
+ if (Object.keys(value).some((field) => field !== 'present')) {
282
+ add('e:stateless-carries-durable-processing-receipt')
283
+ }
284
+ return
285
+ }
286
+ if (!validDurableReceipt(value.preflight)) {
287
+ add('e:durable-processing-preflight-missing')
288
+ return
289
+ }
290
+ if (!exactList(value.preflight.pauseOrder, DURABLE_PAUSE_ORDER)) {
291
+ add('e:durable-processing-pause-order-invalid')
292
+ }
293
+ if (!exactList(value.preflight.resumeOrder, DURABLE_RESUME_ORDER)) {
294
+ add('e:durable-processing-preflight-resume-order-invalid')
295
+ }
296
+ if (!Number.isSafeInteger(value.preflight.quiescence?.timeoutMs)
297
+ || value.preflight.quiescence.timeoutMs <= 0
298
+ || value.preflight.evidenceSchema !== DURABLE_RECEIPT_SCHEMA) {
299
+ add('e:durable-processing-preflight-contract-incomplete')
300
+ }
301
+ const hasUp = !absent(value.up)
302
+ const hasUnwind = !absent(value.unwind)
303
+ if (hasUp === hasUnwind) {
304
+ add('e:durable-processing-sequence-ambiguous')
305
+ return
306
+ }
307
+ const sequence = hasUp ? value.up : value.unwind
308
+ if (!validDurableReceipt(value.down) || !validDurableReceipt(sequence)) {
309
+ add('e:durable-processing-sequence-missing')
310
+ return
311
+ }
312
+ if (!Number.isSafeInteger(value.down.proof?.generation) || !nonEmptyString(value.down.proof?.fence)) {
313
+ add('e:durable-processing-fence-missing')
314
+ }
315
+ if (hasUp) {
316
+ if (['blocked', 'refused'].includes(value.down.state)) {
317
+ add('e:blocked-down-requires-unwind')
318
+ }
319
+ if (value.down.state !== 'ready-to-shift' || value.down.proof?.observed?.blockers !== 0) {
320
+ add('e:durable-processing-not-quiescent')
321
+ }
322
+ if (!nonEmptyObject(value.down.shiftFence)) add('e:durable-processing-shift-fence-missing')
323
+ if (sequence.state !== 'restored') add('e:durable-processing-restoration-unproven')
324
+ const traffic = sequence.trafficProof
325
+ const identitiesDeclared = nonEmptyString(sequence.incumbentRevision)
326
+ && nonEmptyString(sequence.candidateRevision)
327
+ && sequence.incumbentRevision !== sequence.candidateRevision
328
+ const candidateRestored = identitiesDeclared
329
+ && sequence.servingRevisionAtResume === sequence.candidateRevision
330
+ && isObject(traffic)
331
+ && traffic.incumbentRevision === sequence.incumbentRevision
332
+ && traffic.candidateRevision === sequence.candidateRevision
333
+ && traffic.candidatePercent === 100
334
+ && traffic.incumbentPercent === 0
335
+ const incumbentRestored = identitiesDeclared
336
+ && sequence.servingRevisionAtResume === sequence.incumbentRevision
337
+ && isObject(traffic)
338
+ && traffic.incumbentRevision === sequence.incumbentRevision
339
+ && traffic.candidateRevision === sequence.candidateRevision
340
+ && traffic.incumbentPercent === 100
341
+ && traffic.candidatePercent === 0
342
+ if (!candidateRestored && !incumbentRestored) {
343
+ add('e:post-shift-traffic-proof-invalid')
344
+ }
345
+ if (nonEmptyObject(value.down.shiftFence)
346
+ && (value.down.shiftFence.generation !== value.down.proof?.generation
347
+ || value.down.shiftFence.fence !== value.down.proof?.fence)) {
348
+ add('e:durable-processing-shift-fence-mismatch')
349
+ }
350
+ } else {
351
+ if (!['blocked', 'refused'].includes(value.down.state)) add('e:unwind-without-pre-shift-failure')
352
+ if (!absent(value.down.shiftFence)) add('e:blocked-down-carries-shift-fence')
353
+ if (sequence.state !== 'unwound') add('e:blocked-down-requires-unwind')
354
+ const traffic = sequence.trafficProof
355
+ if (!isObject(traffic) || traffic.incumbentPercent !== 100 || traffic.candidatePercent !== 0
356
+ || traffic.incumbentRevision !== sequence.servingRevisionAtResume) {
357
+ add('e:pre-shift-traffic-proof-invalid')
358
+ }
359
+ }
360
+ if (!exactList(sequence.resumeOrder, DURABLE_RESUME_ORDER)) add('e:durable-processing-resume-order-invalid')
361
+ if (!nonEmptyString(sequence.servingRevisionAtResume)) add('e:durable-processing-serving-revision-missing')
362
+ }
363
+
364
+ export function validateBundle(bundle) {
365
+ const failed = []
366
+ const add = (id) => { if (!failed.includes(id)) failed.push(id) }
367
+
368
+ if (!isObject(bundle)) return { valid: false, failed: ['e:not-an-object'] }
369
+ for (const field of Object.keys(bundle)) {
370
+ if (!BUNDLE_FIELDS.includes(field)) add(`e:unknown-field-${kebab(field)}`)
371
+ }
372
+
373
+ if (!absent(bundle.durableProcessing)) {
374
+ validateDurableProcessing(bundle.durableProcessing, add)
375
+ }
376
+
377
+
378
+ // The key. `attemptId ?? rcTag` (bundleKey) so a pre-#599 bundle — which carries only the rc tag,
379
+ // and whose attempt id was that tag — stays keyed, valid and locatable.
380
+ //
381
+ // VALIDATE AND EMIT MUST AGREE. The key has to be WRITABLE, not merely present: without this
382
+ // second check `validate` exits 0 on a bundle `emit` then refuses with an unsafe-key error, and
383
+ // the operator is told a durable record is good by the only command that can tell them so before
384
+ // the write. Same predicate as `bundleFilename`, applied at the same point in the contract.
385
+ const key = bundleKey(bundle)
386
+ if (!nonEmptyString(key)) add('e:missing-attempt-id')
387
+ else if (!SAFE_KEY.test(key)) add('e:unsafe-attempt-id')
388
+
389
+ // `rcTag` is now an ordinary recorded field, but its ABSENCE must be declared, never implied.
390
+ // Dropping the old `e:missing-rc-tag` outright would be a guard that silently widens: an rc-shaped
391
+ // release could then emit a durable record that forgot the tag it shipped under, and nothing
392
+ // downstream re-asks. So: present -> it must be usable; absent -> `deployTrigger` must name one of
393
+ // the three shapes that mint no rc.
394
+ if (!absent(bundle.deployTrigger) && !DEPLOY_TRIGGER_SHAPES.includes(bundle.deployTrigger)) {
395
+ add('e:unknown-deploy-trigger')
396
+ }
397
+ // ...and the converse, which is the same rule read from the other side: a bundle DECLARING a
398
+ // shape that mints no rc, while carrying one, is a record that contradicts itself. One of the
399
+ // two statements is false and nothing downstream can tell which, so it is refused in the same
400
+ // shape as the other declaration failures rather than accepted as harmless extra evidence. An
401
+ // UNDECLARED shape carrying a tag stays valid — that is every pre-#599 bundle.
402
+ // `rcTag: null` is `rcTag` ABSENT, not a present-but-unusable tag (#604) — see `absent` above.
403
+ if (!absent(bundle.rcTag)) {
404
+ if (!nonEmptyString(bundle.rcTag)) add('e:unusable-rc-tag')
405
+ else if (NO_RC_TRIGGER_SHAPES.includes(bundle.deployTrigger)) add('e:rc-tag-on-no-rc-shape')
406
+ } else if (!NO_RC_TRIGGER_SHAPES.includes(bundle.deployTrigger)) {
407
+ add('e:undeclared-missing-rc-tag')
408
+ }
409
+
410
+ if (!isSha(bundle.sha)) add(nonEmptyString(bundle.sha) ? 'e:unusable-sha' : 'e:missing-sha')
411
+
412
+ // buildIdentity is an object; if it carries a sha it must be THIS release's — a build record for
413
+ // another commit bound into this bundle is the same class of error as a stale candidate receipt.
414
+ // Same missing/unusable split as the required loop below: it stays object-only (its `sha` is
415
+ // cross-checked, exactly like the candidate receipt's), but a PRESENT one is never called missing.
416
+ if (absent(bundle.buildIdentity)) add('e:missing-build-identity')
417
+ else if (!nonEmptyObject(bundle.buildIdentity)) add('e:unusable-build-identity')
418
+ else if (!absent(bundle.buildIdentity.sha) && !(isSha(bundle.sha) && shaMatches(bundle.buildIdentity.sha, bundle.sha))) {
419
+ add('e:build-identity-sha-mismatch')
420
+ }
421
+
422
+ const state = bundle.terminalState
423
+ if (!BUNDLE_TERMINAL_STATES.includes(state)) {
424
+ // Without a known state there is no required set to check — the state IS the router.
425
+ add('e:unknown-terminal-state')
426
+ return { valid: false, failed }
427
+ }
428
+
429
+ // The four lease-closing states each have exactly ONE accepted close cause (#432's map, reused).
430
+ // blocked-manual holds the lease and carries none — a closedBy on it is a mis-recorded close.
431
+ const expectedCause = ATTEMPT_CLOSE_CAUSES[state]
432
+ if (expectedCause) {
433
+ if (bundle.closedBy !== expectedCause) add('e:close-cause-mismatch')
434
+ } else if (!absent(bundle.closedBy)) {
435
+ // ...and `closedBy: null` on the lease-HOLDING state is a key that is not there (#604), not a
436
+ // mis-recorded close. The four closing states are untouched: a null there still fails the
437
+ // exact-cause check above, because null is not the cause they owe.
438
+ add('e:blocked-manual-carries-close-cause')
439
+ }
440
+
441
+ // The required set is (state × shape) — `requiredFor`, never either table directly (#604).
442
+ const failurePhase = classifyFailurePhase(bundle)
443
+ const required = requiredFor(state, bundle.deployTrigger, failurePhase)
444
+
445
+ // A pre-candidate failure has no candidate evidence by definition. Accepting any of these
446
+ // fields would turn a pre-candidate bundle into a contradictory record that a later reader
447
+ // cannot reconcile with its stamped phase.
448
+ if (failurePhase === 'pre-candidate' && bundle.deployTrigger !== 'no-deploy') {
449
+ for (const field of ['candidateReceipt', 'candidateVerdict', 'approval', 'trafficTransition', 'liveVerdict']) {
450
+ if (!absent(bundle[field])) add(`e:${kebab(field)}-on-pre-candidate`)
451
+ }
452
+ }
453
+
454
+ // MISSING AND UNUSABLE ARE DIFFERENT FAILURES, AND THE ID MUST SAY WHICH (#604 fix round).
455
+ // `e:missing-release-ref` on a key that is right there is a message asserting one thing while the
456
+ // check tests another — it sends the next reader to look for a field they already wrote. Absent
457
+ // (or null, which is absence here) -> `e:missing-*`; present but carrying nothing this schema can
458
+ // use -> `e:unusable-*`. It is the split `sha` and `rcTag` already made, applied to every field
459
+ // the required set names. The VERDICT is unchanged in every case: both refuse.
460
+ for (const field of required) {
461
+ const value = bundle[field]
462
+ if (field === 'tagDisposition') {
463
+ // Its id already names the shape rather than claiming absence, so it keeps its own message.
464
+ if (!ATTEMPT_TAG_DISPOSITIONS.includes(bundle.tagDisposition)) add('e:bad-tag-disposition')
465
+ continue
466
+ }
467
+ if (absent(value)) {
468
+ add(`e:missing-${kebab(field)}`)
469
+ continue
470
+ }
471
+ // `reason` and `releaseTag` are strings; the two prose-or-structure fields take either; every
472
+ // remaining required field is a structured observation (see `proseOrStructure` above for why
473
+ // the deployment five must stay that way).
474
+ const usable = field === 'reason' || field === 'releaseTag'
475
+ ? nonEmptyString(value)
476
+ : PROSE_OR_STRUCTURE_FIELDS.has(field)
477
+ ? proseOrStructure(value)
478
+ : nonEmptyObject(value)
479
+ if (!usable) add(`e:unusable-${kebab(field)}`)
480
+ }
481
+
482
+ // The final tag IS the version claim on a no-deploy shape — the only one there is, since nothing
483
+ // was deployed. So bind it to the attempt whose record this is, the same cross-artifact rule the
484
+ // receipt and buildIdentity get bound by: a bundle whose durable record pairs one attempt's id
485
+ // with another release's tag contradicts itself, and nothing downstream can tell which half is
486
+ // true. Compared on the version alone (`v0.5.0-attempt.1` -> `v0.5.0`) and with a leading `v`
487
+ // normalised off BOTH sides, because a repo may spell its version `0.5.0` while its tag is
488
+ // `v0.5.0` — the check is for a DIFFERENT release, not for a different spelling.
489
+ if (required.includes('releaseTag') && nonEmptyString(bundle.releaseTag) && nonEmptyString(key)) {
490
+ if (versionKey(bundle.releaseTag) !== versionKey(attemptVersion(key))) add('e:release-tag-version-mismatch')
491
+ }
492
+
493
+ // The candidate receipt is the release.candidateIdentity tuple; where a state requires it, bind
494
+ // it to this release's SHA so a receipt copied from an earlier rc refuses (the cross-artifact
495
+ // rule candidate-identity enforces at every hand-off, applied to the durable record).
496
+ if (required.includes('candidateReceipt') && nonEmptyObject(bundle.candidateReceipt)) {
497
+ if (!isSha(bundle.sha) || !shaMatches(bundle.candidateReceipt.sourceSha, bundle.sha)) add('e:receipt-sha-mismatch')
498
+ }
499
+
500
+ // A state that never shipped must not carry exposure evidence — fabricated, not merely spare.
501
+ // The id names the state so failed-pre-promotion and revoked are distinguishable in `failed`.
502
+ // `null` is an absent field here too (#604): writing `"liveVerdict": null` to mean "there was
503
+ // none" is not a claim, and refusing it taught generators to omit keys they had no way to know
504
+ // were forbidden.
505
+ for (const field of EXPOSURE_FORBIDDEN[state] ?? []) {
506
+ if (!absent(bundle[field])) add(`e:exposure-claimed-${state}`)
507
+ }
508
+
509
+ // ...and the same rule read per SHAPE rather than per state (#604). On `no-deploy` there is no
510
+ // candidate, no exposure, no traffic and no live service, so ANY of the five is an observation
511
+ // nobody took — at every terminal state, `blocked-manual` included. The id names the field and
512
+ // the shape, matching `e:rc-tag-on-no-rc-shape`, so a generator is told which key to drop.
513
+ if (bundle.deployTrigger === 'no-deploy') {
514
+ for (const field of DEPLOY_EVIDENCE_FIELDS) {
515
+ if (!absent(bundle[field])) add(`e:${kebab(field)}-on-no-deploy`)
516
+ }
517
+ }
518
+
519
+ return { valid: failed.length === 0, failed }
520
+ }
521
+
522
+ const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
523
+
524
+ // The version an attempt id was minted for: `v0.5.0-attempt.1` -> `v0.5.0`. A key with no ordinal
525
+ // (a pre-#599 rc-tag key, or an id composed by hand before the convention) is returned unchanged —
526
+ // this is a comparison helper, not a validator, and the key's own legality is checked above.
527
+ const attemptVersion = (key) => (typeof key === 'string' ? key.replace(ATTEMPT_ORDINAL, '') : key)
528
+ // Plain, v-prefixed, and npm package-scoped tags bind to the same semantic version.
529
+ //
530
+ // Exported because the @1aboveio/skills release gate binds a package-scoped tag to package semver
531
+ // with the same split (#998). It is a COMPARISON helper, not a validator: it normalises a leading
532
+ // `v` away and returns an unparseable input unchanged, so a caller that needs "this tag is legal"
533
+ // must add its own strictness on top rather than read a non-throw as a pass.
534
+ export const versionKey = (v) => {
535
+ if (typeof v !== 'string') return v
536
+ const trimmed = v.trim()
537
+ const packageSeparator = trimmed.lastIndexOf('@')
538
+ const version = packageSeparator > 0 ? trimmed.slice(packageSeparator + 1) : trimmed
539
+ return version.replace(/^v/, '')
540
+ }
541
+
542
+ // ---- filename keyed by (attempt id, terminal state) ----
543
+ //
544
+ // The key is (attempt id, terminal state), NOT the id alone. WHY THE STATE IS IN THE KEY: one
545
+ // attempt can legitimately reach two terminal states IN SEQUENCE, because #432 makes
546
+ // `blocked-manual` a lease-HOLDING state that clears on the SAME attempt via operator recovery, and
547
+ // the attempt id (like the rc tag it may carry) is immutable throughout (principle 10 — no new rc is
548
+ // minted for a recovery). So an attempt can park at `blocked-manual` (owed its own bundle — AC2
549
+ // lists it), then recover and close `released`; an id-only key would make the `released` emit
550
+ // collide with the stale `blocked-manual` bundle and be refused, losing the attempt's actual final
551
+ // outcome. Keying by (id, state) lets both durable records coexist while immutability still holds
552
+ // per (id, state).
553
+ //
554
+ // Attempt ids are tag-shaped (`v0.5.0-attempt.2`, or on a pre-#599 bundle the rc tag `v1.4.0-rc.2`
555
+ // itself — single dashes) and terminal states are single-kebab like `blocked-manual`, so the `--`
556
+ // separator cannot collide with either half: `v1.4.0-rc.2--released` and `v1.4.0-rc.2--blocked-manual` are two
557
+ // distinct, unambiguous filenames. The job here is to REFUSE anything unsafe (a traversal-shaped or
558
+ // empty id, or a state outside #432's enum) rather than silently sanitizing it into a different key.
559
+ // Imported from run-state.mjs, never re-declared: the manifest refuses an unkeyable attempt id at
560
+ // the WRITE and this module refuses it at the emit, and two regexes for one rule is how those two
561
+ // refusals drift into disagreeing about which attempts have a durable record.
562
+ const SAFE_KEY = ATTEMPT_ID_KEY_SAFE
563
+
564
+ // ---- minting the attempt id: unique per ATTEMPT, not per version ----
565
+ //
566
+ // WHY A CONVENTION AT ALL (#599 fix round 2). Keying on the attempt id only helps if the id is
567
+ // unique per attempt, and step 3's first spelling — "the version being cut" — is not: doctrine
568
+ // deliberately re-cuts the SAME version on a retry, and both failure tiers reuse the terminal
569
+ // state. So attempt 1 of v2.0.0 wrote `v2.0.0--failed-pre-promotion.json`, its retry failed the
570
+ // same way, and the immutability guarantee (working exactly as designed) refused the second emit —
571
+ // the second failure's durable record lost, which is the one thing this artifact exists to
572
+ // prevent. Pre-#599 the key was the rc tag, which carries its own per-attempt ordinal, so the
573
+ // change to an attempt id is what introduced the collision.
574
+ //
575
+ // THE CONVENTION IS `<version>-attempt.<n>`, ON ALL FOUR SHAPES. It is deliberately NOT
576
+ // shape-dependent, and in particular the rc shape does NOT reuse its rc tag: decision 1 opens the
577
+ // lease at the promotion AUTHORIZATION, which is before the mint, so at the moment the id is
578
+ // written no rc tag exists yet on any shape. It mirrors the rc tag's own `-rc.<n>` ordinal so the
579
+ // two read alike, and it is SAFE_KEY-legal by construction rather than by the author remembering.
580
+ //
581
+ // AND THE ORDINAL IS READ, NOT REMEMBERED. `nextAttemptId` derives it from the bundles already in
582
+ // the durable evidence dir — the same "look at what exists, mint the next" move `version.mjs rc`
583
+ // makes for rc tags. A caller composing the id by hand still gets the legality check (below) and
584
+ // run-state's spent-id refusal, but the mint is the path doctrine points at.
585
+ //
586
+ // WHAT THAT DOES AND DOES NOT BUY — stated exactly, because an overclaim here is what let two
587
+ // review rounds each close one route into this class and miss the next. Reading the dir makes the
588
+ // mint free of collision with every attempt that has CLOSED, and only that. It is not a
589
+ // self-sufficient uniqueness guarantee, and the two things it does not cover are covered elsewhere
590
+ // or deliberately deferred:
591
+ // - an attempt still IN FLIGHT has emitted no bundle and does not move the ordinal. Closed by
592
+ // the Release Attempt Lease precondition — step 3 mints only on `free`, and an in-flight
593
+ // attempt reads `held` while a reaped manifest reads `unknown`. Neither authorizes a mint.
594
+ // - two sessions minting concurrently, before either emits. Deferred to #509 with the rest of
595
+ // lease atomicity (the take is a check-then-act read, advisory by design).
596
+ // And it holds at all only if the read actually happened, which is why `nextAttemptId` rethrows
597
+ // every readdir failure but ENOENT instead of treating an unreadable dir as an empty one.
598
+ //
599
+ // Pre-#599 bundles are a DIFFERENT namespace (`v1.4.0-rc.2--released.json`) and cannot collide
600
+ // with `<version>-attempt.<n>`, so they neither shift the ordinal nor are shifted by it.
601
+ const ATTEMPT_ORDINAL = /-attempt\.(\d+)$/
602
+
603
+ export function attemptIdFor(version, ordinal) {
604
+ if (!nonEmptyString(version)) throw new Error('attemptIdFor: a version is required — the attempt id is <version>-attempt.<n>')
605
+ if (ATTEMPT_ORDINAL.test(version)) {
606
+ throw new Error(`attemptIdFor: ${JSON.stringify(version)} already carries an attempt ordinal — pass the version being cut (v2.0.0), not an attempt id`)
607
+ }
608
+ if (!Number.isInteger(ordinal) || ordinal < 1) {
609
+ throw new Error(`attemptIdFor: the ordinal must be a positive integer (attempts count from 1); got ${JSON.stringify(ordinal)}`)
610
+ }
611
+ const id = `${version}-attempt.${ordinal}`
612
+ if (!SAFE_KEY.test(id)) {
613
+ throw new Error(`attemptIdFor: version ${JSON.stringify(version)} yields the unsafe attempt id ${JSON.stringify(id)} — the id keys a file, so it must be a plain name (no slashes, no traversal, no "#")`)
614
+ }
615
+ return id
616
+ }
617
+
618
+ // The next free ordinal for this version, read off the durable evidence dir.
619
+ //
620
+ // A MISSING dir (ENOENT) means no attempt has emitted yet, which is ordinal 1 — the one case where
621
+ // absence really is emptiness, because the dir is created by the first emit. An UNREADABLE dir is
622
+ // NOT that case and never was: EACCES, ENOTDIR (a `--dir` typo pointing at a file), ELOOP, EMFILE
623
+ // are a dir that could not be read, and answering "no attempts yet" off one hands back an ordinal
624
+ // that may already be SPENT — two attempts under one key, which is the whole thing the ordinal
625
+ // exists to prevent. It is the same "reading absence as freedom" the lease refuses one file away
626
+ // (`run-state.mjs` attemptLeaseStatus answers `unknown`, NOT `free`, when there is no manifest).
627
+ // So: rethrow anything that is not ENOENT, and let the caller fail closed.
628
+ //
629
+ // What this does and does not guarantee, stated precisely because a claim of more was itself the
630
+ // bug: the ordinal is read off bundles, and a bundle exists only after a TERMINAL exit. So this
631
+ // mint is free of collision with every CLOSED attempt, by construction. An attempt still in flight
632
+ // has emitted nothing and does not move the ordinal — that route is closed by the Release Attempt
633
+ // Lease precondition (step 3 mints only on `free`; an in-flight attempt reads `held` and a reaped
634
+ // manifest reads `unknown`, and neither authorizes a mint), not by this function. Two sessions
635
+ // minting concurrently before either emits is the remaining route, deferred to #509 with the rest
636
+ // of lease atomicity.
637
+ export function nextAttemptId(version, dir) {
638
+ if (!nonEmptyString(version)) throw new Error('nextAttemptId: a version is required')
639
+ if (ATTEMPT_ORDINAL.test(version)) {
640
+ throw new Error(`nextAttemptId: ${JSON.stringify(version)} already carries an attempt ordinal — pass the version being cut (v2.0.0), not an attempt id`)
641
+ }
642
+ let entries
643
+ try {
644
+ entries = fs.readdirSync(dir)
645
+ } catch (e) {
646
+ if (e.code !== 'ENOENT') throw e
647
+ entries = []
648
+ }
649
+ let highest = 0
650
+ for (const entry of entries) {
651
+ if (!entry.endsWith('.json')) continue
652
+ const [key] = entry.slice(0, -'.json'.length).split('--')
653
+ if (!key.startsWith(`${version}-attempt.`)) continue
654
+ const m = ATTEMPT_ORDINAL.exec(key)
655
+ if (m) highest = Math.max(highest, Number(m[1]))
656
+ }
657
+ return attemptIdFor(version, highest + 1)
658
+ }
659
+
660
+ // The bundle's key. `rcTag` is the pre-#599 fallback and is exactly right for the bundles that used
661
+ // it: they WERE keyed by their rc tag, so an existing `v1.2.0-rc.1--released.json` keeps its
662
+ // filename and stays locatable rather than being re-keyed by a schema change.
663
+ export function bundleKey(bundle) {
664
+ return isObject(bundle) ? (bundle.attemptId ?? bundle.rcTag) : undefined
665
+ }
666
+
667
+ export function bundleFilename(attemptId, terminalState) {
668
+ if (!nonEmptyString(attemptId)) throw new Error('bundleFilename: an attempt id is required (the bundle is keyed by it; a pre-#599 bundle is keyed by its rc tag)')
669
+ if (!SAFE_KEY.test(attemptId)) {
670
+ throw new Error(`bundleFilename: unsafe attempt id ${JSON.stringify(attemptId)} — it keys a file, so it must be a plain name (no slashes, no traversal)`)
671
+ }
672
+ if (!BUNDLE_TERMINAL_STATES.includes(terminalState)) {
673
+ throw new Error(`bundleFilename: a terminal state is required and must be one of ${BUNDLE_TERMINAL_STATES.join(' | ')} — the bundle is keyed by (attempt id, terminal state) so one attempt's two terminal exits (e.g. a blocked-manual hold and its later released close) do not collide; got ${JSON.stringify(terminalState)}`)
674
+ }
675
+ return `${attemptId}--${terminalState}.json`
676
+ }
677
+
678
+ // The evidence content, minus the write-stamp — what "same bundle" means for immutability. Keys are
679
+ // recursively sorted so the comparison is stable across property order (a stored bundle read back
680
+ // from disk vs an in-memory one can differ only in key order for identical evidence). A recursive
681
+ // canonicalizer, NOT JSON.stringify's replacer-array — that filters NESTED keys too, which would
682
+ // silently drop the content of `disposition`, `candidateVerdict`, etc. from the comparison and let
683
+ // a different-content re-emit read as identical.
684
+ function canonical(v) {
685
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`
686
+ if (v != null && typeof v === 'object') {
687
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`
688
+ }
689
+ return JSON.stringify(v)
690
+ }
691
+ function evidenceCanonical(bundle) {
692
+ const { emittedAt, ...evidence } = bundle
693
+ return canonical(evidence)
694
+ }
695
+
696
+ // ---- emit: immutable once written ----
697
+ //
698
+ // Validates, then writes `<dir>/<attemptId>--<terminalState>.json`. If a bundle already exists for
699
+ // this (attempt id, terminal state):
700
+ // - evidence-identical (differs only in emittedAt) -> resume-safe NO-OP, the first write stands;
701
+ // - different content -> REFUSE (AC5, the bundle is immutable).
702
+ // A DIFFERENT terminal state for the same attempt is a different file (a recovery stays on the same
703
+ // attempt and the same rc — principle 10), so it is a new, legal record rather than a collision.
704
+ // Returns { path, noop, emittedAt }.
705
+ // The close-time classification stamp (#962). A failure bundle leaves the lane with the
706
+ // NORMALIZED failure owner and phase recorded on it — #961's taxonomy, computed at the moment
707
+ // the record is written, so the durable attempt carries the classification rather than every
708
+ // later reader re-deriving it (the version post-mortem and the rc circuit breaker both read it
709
+ // back verbatim; a derived value here is exactly what `classifyFailureOwner`'s rule 1 calls the
710
+ // declared extension point). A bundle whose author already declared `failureOwner` (top-level
711
+ // or in the disposition) keeps the declaration — whoever recorded the failure closest to it
712
+ // knows more than the heuristic — and a non-failure terminal state gets neither field, because
713
+ // there is no failure to own. The stamp is a pure function of the bundle, so a resumed
714
+ // re-emit recomputes the identical record and stays the resume-safe no-op.
715
+ export function stampFailureClassification(bundle) {
716
+ if (!isObject(bundle)) return bundle
717
+ const failure = classifyFailureOwner(bundle)
718
+ if (!failure) return bundle
719
+ const out = { ...bundle }
720
+ const declared = nonEmptyString(out.failureOwner)
721
+ || (isObject(out.disposition) && nonEmptyString(out.disposition.failureOwner))
722
+ if (!declared) out.failureOwner = failure.owner
723
+ if (!nonEmptyString(out.failurePhase)) {
724
+ const phase = classifyFailurePhase(bundle)
725
+ if (phase) out.failurePhase = phase
726
+ }
727
+ return out
728
+ }
729
+
730
+ export function emitBundle({ bundle, dir, at = new Date().toISOString() }) {
731
+ const classified = stampFailureClassification(bundle)
732
+ const { valid, failed } = validateBundle(classified)
733
+ if (!valid) throw new Error(`evidence-bundle: refusing to emit an invalid bundle — ${failed.join(', ')}`)
734
+ if (!nonEmptyString(dir)) throw new Error('evidence-bundle: emit requires a --dir to write the durable record into')
735
+
736
+ const file = path.join(dir, bundleFilename(bundleKey(classified), classified.terminalState))
737
+ const stamped = { ...classified, emittedAt: at }
738
+
739
+ // The existsSync-then-writeFileSync check is not atomic against a concurrent emitter (TOCTOU).
740
+ // That concurrency-safety half is deferred to #509; here the Release Attempt Lease serializes
741
+ // attempts in practice, so this guard stays advisory-immutability. No behaviour change owed here.
742
+ if (fs.existsSync(file)) {
743
+ let existing
744
+ try {
745
+ existing = JSON.parse(fs.readFileSync(file, 'utf8'))
746
+ } catch {
747
+ throw new Error(`evidence-bundle: a bundle already exists at ${file} but is unreadable — refusing to overwrite (the bundle is immutable once written)`)
748
+ }
749
+ if (evidenceCanonical(existing) === evidenceCanonical(stamped)) {
750
+ return { path: file, noop: true, emittedAt: existing.emittedAt ?? null }
751
+ }
752
+ throw new Error(`evidence-bundle: a bundle for attempt "${bundleKey(bundle)}" in terminal state "${bundle.terminalState}" already exists at ${file} with different content — the bundle is immutable once written (principle 10 / #441 AC5); a re-emit for the same (attempt id, state) must be byte-identical evidence. A recovery stays on the SAME attempt and closes at a DIFFERENT terminal state, which is its own bundle — it never overwrites this one.`)
753
+ }
754
+
755
+ fs.mkdirSync(dir, { recursive: true })
756
+ fs.writeFileSync(file, JSON.stringify(stamped, null, 2) + '\n')
757
+ return { path: file, noop: false, emittedAt: at }
758
+ }
759
+
760
+ // ---- CLI ----
761
+
762
+ const USAGE = `evidence-bundle — the durable release-evidence bundle, one per release attempt (#441).
763
+
764
+ The run manifest is the working state; this bundle is the DURABLE record — written once at a
765
+ terminal exit, keyed to the immutable ATTEMPT ID and its terminal state (principle 10), immutable
766
+ once written per (attempt id, state). One attempt can reach two terminal states in sequence (a
767
+ blocked-manual hold that recovers on the same attempt and closes released), so each exit is its own
768
+ bundle. A failed attempt is validated as strictly as a successful one; each terminal state owes its
769
+ own evidence. The key is the attempt id, not the rc tag (#599): three of the four declarable trigger
770
+ shapes mint no rc, and a terminal exit on those had no key at all. On the rc shape the attempt id IS
771
+ the rc tag, so bundles written before #599 keep their filenames and stay locatable.
772
+
773
+ The attempt id is <version>-attempt.<n> on EVERY shape — unique per ATTEMPT, not per version,
774
+ because a retry re-cuts the same version and both failure tiers reuse the terminal state, so a
775
+ version-keyed retry would collide with the attempt it is retrying and lose its record. It is NOT
776
+ the rc tag on any shape: the lease opens at the promotion authorization, before the mint, so no rc
777
+ tag exists yet when the id is written. Mint it with \`next-attempt-id\` rather than composing it.
778
+
779
+ Schema (JSON): {
780
+ attemptId, sha, buildIdentity, # always required; buildIdentity.sha, if set, must match sha
781
+ # attemptId must be able to key a file — validate
782
+ # refuses one emit could not write
783
+ # (a pre-#599 bundle keys off rcTag instead)
784
+ rcTag, # recorded, not the key; REQUIRED where an rc exists —
785
+ # an absent one must be declared by deployTrigger below,
786
+ # and a declared no-rc shape must NOT carry one
787
+ deployTrigger, # one of ${DEPLOY_TRIGGER_SHAPES.join(' | ')} (pre-flight item 5);
788
+ # ${NO_RC_TRIGGER_SHAPES.join('/')} mint no rc, so they declare the absence
789
+ terminalState, # one of ${BUNDLE_TERMINAL_STATES.join(' | ')} (#432's enum)
790
+ closedBy, # the state's ONE verified close cause (#432); absent on blocked-manual
791
+ # a deployed failed-pre-promotion bundle with no candidate evidence
792
+ # is a pre-candidate failure: it owes disposition only and must
793
+ # carry no candidateReceipt/candidateVerdict/exposure evidence
794
+ candidateReceipt, # release.candidateIdentity tuple; sourceSha bound to sha
795
+ candidateVerdict, liveVerdict, # principle 5's two observations (0% candidate, post-shift live)
796
+ approval, trafficTransition, disposition, # exposure authorization, the 0->100 shift, the cleanup/rollback/revocation record
797
+ # the five above are DEPLOYMENT evidence: required on the
798
+ # deployed shapes, REFUSED on no-deploy (#604)
799
+ releaseTag, # the no-deploy shape's immutable final tag — the only version
800
+ # claim there is; a STRING, because it is compared against the
801
+ # attempt's version (a leading "v" is normalised off both sides)
802
+ releaseRef, verification, # the rest of the no-deploy shape's evidence: the GitHub Release,
803
+ # and the verification standing in for principle 5's two
804
+ # observations (CI + the full suite on the tagged SHA, and the
805
+ # consumer-facing read). EITHER SPELLING — a plain string
806
+ # ("https://…/releases/tag/v0.5.0", "CI green, 1590/1590") or an
807
+ # object ({"url": "…"}); nothing cross-checks a field inside
808
+ # them, so prose is evidence too
809
+ tagDisposition, # "retained" — required for revoked
810
+ reason, # required for blocked-manual
811
+ emittedAt # stamped by emit; excluded from the immutability comparison
812
+ }
813
+ A field written NULL is a field that is ABSENT — "rcTag": null is no rc tag, not an unusable one, and
814
+ "liveVerdict": null on a state that never shipped is not a claimed observation (#604). Null is never
815
+ a way to satisfy a required field: a required object that is null is missing, and says so.
816
+
817
+ MISSING and UNUSABLE are different failures and the id says which: e:missing-<field> means the key is
818
+ not there (or is null); e:unusable-<field> means it IS there and carries nothing this schema can use.
819
+ A present field is never reported missing. The five deployment fields above stay OBJECTS — the
820
+ candidate receipt's sourceSha is cross-checked, and a prose receipt would slip past that guard.
821
+
822
+ Required-by-state x SHAPE (#604) — the table is a property of both, because "what evidence exists"
823
+ is a property of both. On the three DEPLOYED shapes (${DEPLOY_TRIGGER_SHAPES.filter((s) => s !== 'no-deploy').join(', ')}), and on an
824
+ undeclared shape (every pre-#599 bundle deployed):
825
+ ${Object.entries(REQUIRED_BY_STATE).map(([s, f]) => `${s} -> ${f.join(',')}`).join('\n ')}
826
+ On no-deploy — a versioned artifact nobody deploys, so there is no candidate, no exposure, no traffic
827
+ and no live service to observe; the states not listed are shape-independent and take the set above:
828
+ ${Object.entries(REQUIRED_BY_STATE_NO_DEPLOY).map(([s, f]) => `${s} -> ${f.join(',')}`).join('\n ')}
829
+ and it must carry NONE of ${DEPLOY_EVIDENCE_FIELDS.join(', ')} — at any
830
+ terminal state. This makes the requirement satisfiable on that shape; it does not make it optional.
831
+
832
+ Usage:
833
+ evidence-bundle validate --bundle <file> 0 valid · 1 invalid (ids in \`failed\`) · 2 usage
834
+ evidence-bundle emit --bundle <file> --dir <d> validate then write immutably to
835
+ <d>/<attemptId>--<terminalState>.json
836
+ 0 written or resume-safe no-op · 1 invalid or
837
+ immutability violation · 2 usage
838
+ evidence-bundle next-attempt-id --version <v> --dir <d>
839
+ print the attempt id step 3 opens the lease
840
+ under: <version>-attempt.<n>, with n read
841
+ off the bundles already in <d>. Unique per
842
+ ATTEMPT (a retry re-cuts the same version,
843
+ so the version alone collides) and key-legal
844
+ by construction. Free of collision with every
845
+ CLOSED attempt; an IN-FLIGHT one has emitted
846
+ no bundle, so call this only on a FREE lease
847
+ (concurrent mints: #509). Fails closed on a
848
+ durable dir it cannot read —
849
+ only ENOENT (nothing emitted yet) is
850
+ ordinal 1. 0 minted · 2 usage or unreadable
851
+ dir
852
+ evidence-bundle path --attempt-id <id> --dir <d> [--state <s>]
853
+ with --state, print the one file for that
854
+ (id, state); without it, list every bundle
855
+ written for the attempt (reachable by id
856
+ alone, without the originating session).
857
+ Without --state this REFUSES (2) a dir it
858
+ cannot read rather than reporting it empty;
859
+ an absent dir holds no bundles (0, silent).
860
+ --rc-tag <tag> is the pre-#599 spelling and
861
+ still resolves on the rc shape.
862
+
863
+ Doctrine: resolve-release/SKILL.md step 5 (emitted at every terminal exit) and its Definition of
864
+ done (which reports the bundle location). On the verified path the released bundle is attached to the
865
+ GitHub Release; every attempt's bundles are indexed by attempt id (one per terminal state reached).`
866
+
867
+ function parseArgs(argv) {
868
+ const opts = {}
869
+ const positional = []
870
+ for (let i = 0; i < argv.length; i++) {
871
+ const a = argv[i]
872
+ if (a === '--help' || a === '-h') opts.help = true
873
+ else if (a.startsWith('--')) opts[a.slice(2)] = argv[++i]
874
+ else positional.push(a)
875
+ }
876
+ return { opts, positional }
877
+ }
878
+
879
+ function readJson(file, what) {
880
+ if (!nonEmptyString(file)) return { error: `usage: --${what} <file> is required` }
881
+ let raw
882
+ try {
883
+ raw = fs.readFileSync(file, 'utf8')
884
+ } catch {
885
+ return { error: `${what} not readable: ${file}` }
886
+ }
887
+ try {
888
+ return { value: JSON.parse(raw) }
889
+ } catch {
890
+ return { error: `${what} is not valid JSON: ${file}` }
891
+ }
892
+ }
893
+
894
+ export function main(argv) {
895
+ const { opts, positional } = parseArgs(argv)
896
+ const cmd = positional[0]
897
+
898
+ if (opts.help || cmd === 'help') {
899
+ console.log(USAGE)
900
+ return 0
901
+ }
902
+
903
+ if (cmd !== 'validate' && cmd !== 'emit' && cmd !== 'path' && cmd !== 'next-attempt-id') {
904
+ console.error(`evidence-bundle: ${cmd ? `unknown command "${cmd}"` : 'no subcommand given'}. Use validate|emit|path|next-attempt-id (see --help).`)
905
+ return 2
906
+ }
907
+
908
+ // Minting the attempt id is a READ of the durable dir, so it answers at the promotion
909
+ // authorization — before anything has been written for this attempt, which is exactly when
910
+ // step 3 needs it.
911
+ if (cmd === 'next-attempt-id') {
912
+ if (!nonEmptyString(opts.version)) {
913
+ console.error('evidence-bundle: usage: next-attempt-id requires --version <v> (the version being cut)')
914
+ return 2
915
+ }
916
+ if (!nonEmptyString(opts.dir)) {
917
+ console.error('evidence-bundle: usage: next-attempt-id requires --dir <durable-evidence-dir> — the ordinal is read off the bundles already written, never remembered')
918
+ return 2
919
+ }
920
+ try {
921
+ console.log(nextAttemptId(opts.version, opts.dir))
922
+ return 0
923
+ } catch (e) {
924
+ console.error(`evidence-bundle: ${e.message}`)
925
+ return 2
926
+ }
927
+ }
928
+
929
+ if (cmd === 'path') {
930
+ // `--rc-tag` is the pre-#599 spelling, kept because on the rc shape the attempt id IS the rc
931
+ // tag, so every existing caller and every existing bundle still resolves through it.
932
+ const key = nonEmptyString(opts['attempt-id']) ? opts['attempt-id'] : opts['rc-tag']
933
+ if (!nonEmptyString(key)) {
934
+ console.error('evidence-bundle: usage: path requires --attempt-id <id> (or the pre-#599 --rc-tag <tag>)')
935
+ return 2
936
+ }
937
+ if (!nonEmptyString(opts.dir)) {
938
+ console.error('evidence-bundle: usage: path requires --dir <dir>')
939
+ return 2
940
+ }
941
+ // With --state: print the one deterministic file for that (id, state). It is pure derivation,
942
+ // so it answers even before the bundle is written. Without --state: one attempt can hold more
943
+ // than one bundle (one per terminal state — a recovery stays on the same attempt), so list every
944
+ // bundle that EXISTS for it, which keeps "reachable by the key alone, without the session" (AC4)
945
+ // true when you do not yet know which terminal state the attempt reached.
946
+ if (nonEmptyString(opts.state)) {
947
+ let name
948
+ try {
949
+ name = bundleFilename(key, opts.state)
950
+ } catch (e) {
951
+ console.error(`evidence-bundle: ${e.message}`)
952
+ return 2
953
+ }
954
+ console.log(path.join(opts.dir, name))
955
+ return 0
956
+ }
957
+ if (!SAFE_KEY.test(key)) {
958
+ console.error(`evidence-bundle: unsafe attempt id ${JSON.stringify(key)} — must be a plain name (no slashes, no traversal)`)
959
+ return 2
960
+ }
961
+ const prefix = `${key}--`
962
+ let entries
963
+ // Same rule as the mint above, aimed at the reader: ENOENT is a dir the first emit has not
964
+ // created yet, so it genuinely holds no bundles (exit 0, no output). Any OTHER read failure is
965
+ // a dir nobody read, and listing nothing off it reports "this attempt has no bundles" for an
966
+ // attempt that has them — which is how an operator concludes a spent key is free.
967
+ try {
968
+ entries = fs.readdirSync(opts.dir)
969
+ } catch (e) {
970
+ if (e.code !== 'ENOENT') {
971
+ console.error(`evidence-bundle: ${opts.dir} could not be read (${e.code}) — refusing to report "no bundles" off a directory that was never read; an unreadable dir is not an empty one`)
972
+ return 2
973
+ }
974
+ entries = []
975
+ }
976
+ for (const f of entries.filter((f) => f.startsWith(prefix) && f.endsWith('.json')).sort()) {
977
+ console.log(path.join(opts.dir, f))
978
+ }
979
+ return 0
980
+ }
981
+
982
+ const read = readJson(opts.bundle, 'bundle')
983
+ if (read.error) {
984
+ console.error(`evidence-bundle: ${read.error}`)
985
+ return 2
986
+ }
987
+
988
+ if (cmd === 'validate') {
989
+ const result = validateBundle(read.value)
990
+ console.log(JSON.stringify(result, null, 2))
991
+ if (!result.valid) console.error(`evidence-bundle: INVALID — ${result.failed.join(', ')}`)
992
+ return result.valid ? 0 : 1
993
+ }
994
+
995
+ // emit
996
+ if (!nonEmptyString(opts.dir)) {
997
+ console.error('evidence-bundle: usage: emit requires --dir <dir>')
998
+ return 2
999
+ }
1000
+ try {
1001
+ const { path: p, noop } = emitBundle({ bundle: read.value, dir: opts.dir })
1002
+ console.log(JSON.stringify({ path: p, noop }, null, 2))
1003
+ if (noop) console.error(`evidence-bundle: ${p} already holds this exact evidence — resume-safe no-op (immutable once written).`)
1004
+ else console.error(`evidence-bundle: wrote ${p}`)
1005
+ return 0
1006
+ } catch (e) {
1007
+ console.error(`evidence-bundle: ${e.message}`)
1008
+ return 1
1009
+ }
1010
+ }
1011
+
1012
+ if (isMainModule(import.meta.url)) {
1013
+ process.exit(main(process.argv.slice(2)))
1014
+ }