approval-md 0.0.1 → 0.2.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 (722) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +940 -4
  4. package/SPEC.md +476 -34
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.d.ts +426 -0
  7. package/dist/src/adapters/agentmail.js +1200 -0
  8. package/dist/src/adapters/agentmail.js.map +1 -0
  9. package/dist/src/adapters/conformance.d.ts +149 -0
  10. package/dist/src/adapters/conformance.js +461 -0
  11. package/dist/src/adapters/conformance.js.map +1 -0
  12. package/dist/src/adapters/contract.d.ts +628 -0
  13. package/dist/src/adapters/contract.js +1035 -0
  14. package/dist/src/adapters/contract.js.map +1 -0
  15. package/dist/src/adapters/email.d.ts +324 -0
  16. package/dist/src/adapters/email.js +749 -0
  17. package/dist/src/adapters/email.js.map +1 -0
  18. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  19. package/dist/src/adapters/env-passphrase.js +132 -0
  20. package/dist/src/adapters/env-passphrase.js.map +1 -0
  21. package/dist/src/adapters/public.d.ts +11 -0
  22. package/dist/src/adapters/public.js +11 -0
  23. package/dist/src/adapters/public.js.map +1 -0
  24. package/dist/src/adapters/registry.d.ts +59 -0
  25. package/dist/src/adapters/registry.js +77 -0
  26. package/dist/src/adapters/registry.js.map +1 -0
  27. package/dist/src/adapters/smtp.d.ts +213 -0
  28. package/dist/src/adapters/smtp.js +499 -0
  29. package/dist/src/adapters/smtp.js.map +1 -0
  30. package/dist/src/adapters/vault-provider.d.ts +114 -0
  31. package/dist/src/adapters/vault-provider.js +161 -0
  32. package/dist/src/adapters/vault-provider.js.map +1 -0
  33. package/dist/src/adapters/zzz.d.ts +66 -0
  34. package/dist/src/adapters/zzz.js +299 -0
  35. package/dist/src/adapters/zzz.js.map +1 -0
  36. package/dist/src/channels/batch.d.ts +109 -0
  37. package/dist/src/channels/batch.js +121 -0
  38. package/dist/src/channels/batch.js.map +1 -0
  39. package/dist/src/channels/cli.d.ts +193 -0
  40. package/dist/src/channels/cli.js +468 -0
  41. package/dist/src/channels/cli.js.map +1 -0
  42. package/dist/src/channels/conformance.d.ts +92 -0
  43. package/dist/src/channels/conformance.js +445 -0
  44. package/dist/src/channels/conformance.js.map +1 -0
  45. package/dist/src/channels/contract.d.ts +623 -0
  46. package/dist/src/channels/contract.js +494 -0
  47. package/dist/src/channels/contract.js.map +1 -0
  48. package/dist/src/channels/payload-view.d.ts +35 -0
  49. package/dist/src/channels/payload-view.js +43 -0
  50. package/dist/src/channels/payload-view.js.map +1 -0
  51. package/dist/src/channels/render-queue.d.ts +149 -0
  52. package/dist/src/channels/render-queue.js +564 -0
  53. package/dist/src/channels/render-queue.js.map +1 -0
  54. package/dist/src/channels/tagging.d.ts +196 -0
  55. package/dist/src/channels/tagging.js +723 -0
  56. package/dist/src/channels/tagging.js.map +1 -0
  57. package/dist/src/channels/telegram.d.ts +1832 -0
  58. package/dist/src/channels/telegram.js +3190 -0
  59. package/dist/src/channels/telegram.js.map +1 -0
  60. package/dist/src/channels/web.d.ts +341 -0
  61. package/dist/src/channels/web.js +903 -0
  62. package/dist/src/channels/web.js.map +1 -0
  63. package/dist/src/cli/adapter.d.ts +90 -0
  64. package/dist/src/cli/adapter.js +288 -0
  65. package/dist/src/cli/adapter.js.map +1 -0
  66. package/dist/src/cli/amend.d.ts +59 -0
  67. package/dist/src/cli/amend.js +2171 -0
  68. package/dist/src/cli/amend.js.map +1 -0
  69. package/dist/src/cli/args.d.ts +43 -0
  70. package/dist/src/cli/args.js +86 -0
  71. package/dist/src/cli/args.js.map +1 -0
  72. package/dist/src/cli/attest.d.ts +41 -0
  73. package/dist/src/cli/attest.js +307 -0
  74. package/dist/src/cli/attest.js.map +1 -0
  75. package/dist/src/cli/audit-card.d.ts +62 -0
  76. package/dist/src/cli/audit-card.js +201 -0
  77. package/dist/src/cli/audit-card.js.map +1 -0
  78. package/dist/src/cli/audit.d.ts +59 -0
  79. package/dist/src/cli/audit.js +460 -0
  80. package/dist/src/cli/audit.js.map +1 -0
  81. package/dist/src/cli/channel-telegram.d.ts +806 -0
  82. package/dist/src/cli/channel-telegram.js +2063 -0
  83. package/dist/src/cli/channel-telegram.js.map +1 -0
  84. package/dist/src/cli/channel-web.d.ts +131 -0
  85. package/dist/src/cli/channel-web.js +357 -0
  86. package/dist/src/cli/channel-web.js.map +1 -0
  87. package/dist/src/cli/channel.d.ts +71 -0
  88. package/dist/src/cli/channel.js +438 -0
  89. package/dist/src/cli/channel.js.map +1 -0
  90. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  91. package/dist/src/cli/checkpoint-tap.js +238 -0
  92. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  93. package/dist/src/cli/codex.d.ts +2 -0
  94. package/dist/src/cli/codex.js +172 -0
  95. package/dist/src/cli/codex.js.map +1 -0
  96. package/dist/src/cli/coverage.d.ts +61 -0
  97. package/dist/src/cli/coverage.js +343 -0
  98. package/dist/src/cli/coverage.js.map +1 -0
  99. package/dist/src/cli/daemon.d.ts +120 -0
  100. package/dist/src/cli/daemon.js +631 -0
  101. package/dist/src/cli/daemon.js.map +1 -0
  102. package/dist/src/cli/doctor.d.ts +129 -0
  103. package/dist/src/cli/doctor.js +2762 -0
  104. package/dist/src/cli/doctor.js.map +1 -0
  105. package/dist/src/cli/env.d.ts +65 -0
  106. package/dist/src/cli/env.js +302 -0
  107. package/dist/src/cli/env.js.map +1 -0
  108. package/dist/src/cli/execute.d.ts +202 -0
  109. package/dist/src/cli/execute.js +1682 -0
  110. package/dist/src/cli/execute.js.map +1 -0
  111. package/dist/src/cli/exit-codes.d.ts +73 -0
  112. package/dist/src/cli/exit-codes.js +82 -0
  113. package/dist/src/cli/exit-codes.js.map +1 -0
  114. package/dist/src/cli/feedback.d.ts +60 -0
  115. package/dist/src/cli/feedback.js +205 -0
  116. package/dist/src/cli/feedback.js.map +1 -0
  117. package/dist/src/cli/gate-window.d.ts +40 -0
  118. package/dist/src/cli/gate-window.js +294 -0
  119. package/dist/src/cli/gate-window.js.map +1 -0
  120. package/dist/src/cli/gate.d.ts +68 -0
  121. package/dist/src/cli/gate.js +557 -0
  122. package/dist/src/cli/gate.js.map +1 -0
  123. package/dist/src/cli/git-scope.d.ts +190 -0
  124. package/dist/src/cli/git-scope.js +295 -0
  125. package/dist/src/cli/git-scope.js.map +1 -0
  126. package/dist/src/cli/gloss-attach.d.ts +85 -0
  127. package/dist/src/cli/gloss-attach.js +107 -0
  128. package/dist/src/cli/gloss-attach.js.map +1 -0
  129. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  130. package/dist/src/cli/gloss-codex-child.js +149 -0
  131. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  132. package/dist/src/cli/gloss-codex.d.ts +24 -0
  133. package/dist/src/cli/gloss-codex.js +255 -0
  134. package/dist/src/cli/gloss-codex.js.map +1 -0
  135. package/dist/src/cli/gloss-options.d.ts +42 -0
  136. package/dist/src/cli/gloss-options.js +79 -0
  137. package/dist/src/cli/gloss-options.js.map +1 -0
  138. package/dist/src/cli/gloss.d.ts +265 -0
  139. package/dist/src/cli/gloss.js +362 -0
  140. package/dist/src/cli/gloss.js.map +1 -0
  141. package/dist/src/cli/help.d.ts +103 -0
  142. package/dist/src/cli/help.js +2339 -0
  143. package/dist/src/cli/help.js.map +1 -0
  144. package/dist/src/cli/hook-codex.d.ts +78 -0
  145. package/dist/src/cli/hook-codex.js +167 -0
  146. package/dist/src/cli/hook-codex.js.map +1 -0
  147. package/dist/src/cli/hook.d.ts +331 -0
  148. package/dist/src/cli/hook.js +2849 -0
  149. package/dist/src/cli/hook.js.map +1 -0
  150. package/dist/src/cli/import.d.ts +35 -0
  151. package/dist/src/cli/import.js +175 -0
  152. package/dist/src/cli/import.js.map +1 -0
  153. package/dist/src/cli/init.d.ts +84 -0
  154. package/dist/src/cli/init.js +336 -0
  155. package/dist/src/cli/init.js.map +1 -0
  156. package/dist/src/cli/instructions.d.ts +23 -0
  157. package/dist/src/cli/instructions.js +262 -0
  158. package/dist/src/cli/instructions.js.map +1 -0
  159. package/dist/src/cli/journal.d.ts +41 -0
  160. package/dist/src/cli/journal.js +238 -0
  161. package/dist/src/cli/journal.js.map +1 -0
  162. package/dist/src/cli/log-advance.d.ts +287 -0
  163. package/dist/src/cli/log-advance.js +840 -0
  164. package/dist/src/cli/log-advance.js.map +1 -0
  165. package/dist/src/cli/log-anchor.d.ts +176 -0
  166. package/dist/src/cli/log-anchor.js +387 -0
  167. package/dist/src/cli/log-anchor.js.map +1 -0
  168. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  169. package/dist/src/cli/log-checkpoint.js +128 -0
  170. package/dist/src/cli/log-checkpoint.js.map +1 -0
  171. package/dist/src/cli/log-sync.d.ts +243 -0
  172. package/dist/src/cli/log-sync.js +849 -0
  173. package/dist/src/cli/log-sync.js.map +1 -0
  174. package/dist/src/cli/log-verbs.d.ts +16 -0
  175. package/dist/src/cli/log-verbs.js +360 -0
  176. package/dist/src/cli/log-verbs.js.map +1 -0
  177. package/dist/src/cli/long-help.d.ts +70 -0
  178. package/dist/src/cli/long-help.js +148 -0
  179. package/dist/src/cli/long-help.js.map +1 -0
  180. package/dist/src/cli/main.d.ts +77 -0
  181. package/dist/src/cli/main.js +1206 -0
  182. package/dist/src/cli/main.js.map +1 -0
  183. package/dist/src/cli/mcp.d.ts +52 -0
  184. package/dist/src/cli/mcp.js +306 -0
  185. package/dist/src/cli/mcp.js.map +1 -0
  186. package/dist/src/cli/paths.d.ts +56 -0
  187. package/dist/src/cli/paths.js +79 -0
  188. package/dist/src/cli/paths.js.map +1 -0
  189. package/dist/src/cli/payload.d.ts +58 -0
  190. package/dist/src/cli/payload.js +253 -0
  191. package/dist/src/cli/payload.js.map +1 -0
  192. package/dist/src/cli/policy.d.ts +43 -0
  193. package/dist/src/cli/policy.js +229 -0
  194. package/dist/src/cli/policy.js.map +1 -0
  195. package/dist/src/cli/preflight.d.ts +363 -0
  196. package/dist/src/cli/preflight.js +1175 -0
  197. package/dist/src/cli/preflight.js.map +1 -0
  198. package/dist/src/cli/progress.d.ts +78 -0
  199. package/dist/src/cli/progress.js +112 -0
  200. package/dist/src/cli/progress.js.map +1 -0
  201. package/dist/src/cli/prompt.d.ts +209 -0
  202. package/dist/src/cli/prompt.js +312 -0
  203. package/dist/src/cli/prompt.js.map +1 -0
  204. package/dist/src/cli/quickstart.d.ts +46 -0
  205. package/dist/src/cli/quickstart.js +297 -0
  206. package/dist/src/cli/quickstart.js.map +1 -0
  207. package/dist/src/cli/records.d.ts +34 -0
  208. package/dist/src/cli/records.js +66 -0
  209. package/dist/src/cli/records.js.map +1 -0
  210. package/dist/src/cli/render.d.ts +22 -0
  211. package/dist/src/cli/render.js +132 -0
  212. package/dist/src/cli/render.js.map +1 -0
  213. package/dist/src/cli/sandbox.d.ts +51 -0
  214. package/dist/src/cli/sandbox.js +150 -0
  215. package/dist/src/cli/sandbox.js.map +1 -0
  216. package/dist/src/cli/scaffold.d.ts +79 -0
  217. package/dist/src/cli/scaffold.js +137 -0
  218. package/dist/src/cli/scaffold.js.map +1 -0
  219. package/dist/src/cli/setup-adapter.d.ts +137 -0
  220. package/dist/src/cli/setup-adapter.js +509 -0
  221. package/dist/src/cli/setup-adapter.js.map +1 -0
  222. package/dist/src/cli/setup-channel.d.ts +117 -0
  223. package/dist/src/cli/setup-channel.js +635 -0
  224. package/dist/src/cli/setup-channel.js.map +1 -0
  225. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  226. package/dist/src/cli/setup-checkpoint.js +196 -0
  227. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  228. package/dist/src/cli/setup-common.d.ts +275 -0
  229. package/dist/src/cli/setup-common.js +376 -0
  230. package/dist/src/cli/setup-common.js.map +1 -0
  231. package/dist/src/cli/setup-flow.d.ts +287 -0
  232. package/dist/src/cli/setup-flow.js +476 -0
  233. package/dist/src/cli/setup-flow.js.map +1 -0
  234. package/dist/src/cli/setup-service.d.ts +96 -0
  235. package/dist/src/cli/setup-service.js +308 -0
  236. package/dist/src/cli/setup-service.js.map +1 -0
  237. package/dist/src/cli/setup.d.ts +202 -0
  238. package/dist/src/cli/setup.js +473 -0
  239. package/dist/src/cli/setup.js.map +1 -0
  240. package/dist/src/cli/style.d.ts +320 -0
  241. package/dist/src/cli/style.js +469 -0
  242. package/dist/src/cli/style.js.map +1 -0
  243. package/dist/src/cli/token.d.ts +39 -0
  244. package/dist/src/cli/token.js +274 -0
  245. package/dist/src/cli/token.js.map +1 -0
  246. package/dist/src/cli/up.d.ts +155 -0
  247. package/dist/src/cli/up.js +849 -0
  248. package/dist/src/cli/up.js.map +1 -0
  249. package/dist/src/cli/usage.d.ts +37 -0
  250. package/dist/src/cli/usage.js +91 -0
  251. package/dist/src/cli/usage.js.map +1 -0
  252. package/dist/src/cli/values.d.ts +40 -0
  253. package/dist/src/cli/values.js +189 -0
  254. package/dist/src/cli/values.js.map +1 -0
  255. package/dist/src/cli/vault.d.ts +59 -0
  256. package/dist/src/cli/vault.js +362 -0
  257. package/dist/src/cli/vault.js.map +1 -0
  258. package/dist/src/cli/verb-registry.d.ts +76 -0
  259. package/dist/src/cli/verb-registry.js +2341 -0
  260. package/dist/src/cli/verb-registry.js.map +1 -0
  261. package/dist/src/cli/wordmark.d.ts +31 -0
  262. package/dist/src/cli/wordmark.js +52 -0
  263. package/dist/src/cli/wordmark.js.map +1 -0
  264. package/dist/src/codex/doctor.d.ts +13 -0
  265. package/dist/src/codex/doctor.js +41 -0
  266. package/dist/src/codex/doctor.js.map +1 -0
  267. package/dist/src/codex/manifest.d.ts +49 -0
  268. package/dist/src/codex/manifest.js +103 -0
  269. package/dist/src/codex/manifest.js.map +1 -0
  270. package/dist/src/codex/templates.d.ts +41 -0
  271. package/dist/src/codex/templates.js +319 -0
  272. package/dist/src/codex/templates.js.map +1 -0
  273. package/dist/src/codex/trust.d.ts +19 -0
  274. package/dist/src/codex/trust.js +183 -0
  275. package/dist/src/codex/trust.js.map +1 -0
  276. package/dist/src/codex/workspace-plan.d.ts +131 -0
  277. package/dist/src/codex/workspace-plan.js +561 -0
  278. package/dist/src/codex/workspace-plan.js.map +1 -0
  279. package/dist/src/core/actor.d.ts +2 -0
  280. package/dist/src/core/actor.js +5 -0
  281. package/dist/src/core/actor.js.map +1 -0
  282. package/dist/src/core/advance-cycle.d.ts +170 -0
  283. package/dist/src/core/advance-cycle.js +200 -0
  284. package/dist/src/core/advance-cycle.js.map +1 -0
  285. package/dist/src/core/agents-md.d.ts +276 -0
  286. package/dist/src/core/agents-md.js +747 -0
  287. package/dist/src/core/agents-md.js.map +1 -0
  288. package/dist/src/core/apply-patch.d.ts +49 -0
  289. package/dist/src/core/apply-patch.js +266 -0
  290. package/dist/src/core/apply-patch.js.map +1 -0
  291. package/dist/src/core/attest.d.ts +420 -0
  292. package/dist/src/core/attest.js +589 -0
  293. package/dist/src/core/attest.js.map +1 -0
  294. package/dist/src/core/audit.d.ts +492 -0
  295. package/dist/src/core/audit.js +882 -0
  296. package/dist/src/core/audit.js.map +1 -0
  297. package/dist/src/core/budgets.d.ts +238 -0
  298. package/dist/src/core/budgets.js +449 -0
  299. package/dist/src/core/budgets.js.map +1 -0
  300. package/dist/src/core/checkpoint.d.ts +500 -0
  301. package/dist/src/core/checkpoint.js +738 -0
  302. package/dist/src/core/checkpoint.js.map +1 -0
  303. package/dist/src/core/child-env.d.ts +88 -0
  304. package/dist/src/core/child-env.js +86 -0
  305. package/dist/src/core/child-env.js.map +1 -0
  306. package/dist/src/core/clock.d.ts +52 -0
  307. package/dist/src/core/clock.js +43 -0
  308. package/dist/src/core/clock.js.map +1 -0
  309. package/dist/src/core/command-class.d.ts +543 -0
  310. package/dist/src/core/command-class.js +2356 -0
  311. package/dist/src/core/command-class.js.map +1 -0
  312. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  313. package/dist/src/core/coverage-sources/adapter.js +71 -0
  314. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  315. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  316. package/dist/src/core/coverage-sources/gh.js +136 -0
  317. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  318. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  319. package/dist/src/core/coverage-sources/git.js +269 -0
  320. package/dist/src/core/coverage-sources/git.js.map +1 -0
  321. package/dist/src/core/coverage.d.ts +217 -0
  322. package/dist/src/core/coverage.js +337 -0
  323. package/dist/src/core/coverage.js.map +1 -0
  324. package/dist/src/core/credential-spec.d.ts +72 -0
  325. package/dist/src/core/credential-spec.js +23 -0
  326. package/dist/src/core/credential-spec.js.map +1 -0
  327. package/dist/src/core/dark-session.d.ts +331 -0
  328. package/dist/src/core/dark-session.js +714 -0
  329. package/dist/src/core/dark-session.js.map +1 -0
  330. package/dist/src/core/decision-refusal.d.ts +185 -0
  331. package/dist/src/core/decision-refusal.js +265 -0
  332. package/dist/src/core/decision-refusal.js.map +1 -0
  333. package/dist/src/core/env-file.d.ts +450 -0
  334. package/dist/src/core/env-file.js +837 -0
  335. package/dist/src/core/env-file.js.map +1 -0
  336. package/dist/src/core/execute.d.ts +858 -0
  337. package/dist/src/core/execute.js +1271 -0
  338. package/dist/src/core/execute.js.map +1 -0
  339. package/dist/src/core/frontmatter.d.ts +78 -0
  340. package/dist/src/core/frontmatter.js +100 -0
  341. package/dist/src/core/frontmatter.js.map +1 -0
  342. package/dist/src/core/gate-window.d.ts +312 -0
  343. package/dist/src/core/gate-window.js +506 -0
  344. package/dist/src/core/gate-window.js.map +1 -0
  345. package/dist/src/core/gate.d.ts +1364 -0
  346. package/dist/src/core/gate.js +3002 -0
  347. package/dist/src/core/gate.js.map +1 -0
  348. package/dist/src/core/git-run.d.ts +73 -0
  349. package/dist/src/core/git-run.js +93 -0
  350. package/dist/src/core/git-run.js.map +1 -0
  351. package/dist/src/core/harness-version.d.ts +157 -0
  352. package/dist/src/core/harness-version.js +211 -0
  353. package/dist/src/core/harness-version.js.map +1 -0
  354. package/dist/src/core/harness-wait.d.ts +55 -0
  355. package/dist/src/core/harness-wait.js +58 -0
  356. package/dist/src/core/harness-wait.js.map +1 -0
  357. package/dist/src/core/head-retry.d.ts +107 -0
  358. package/dist/src/core/head-retry.js +121 -0
  359. package/dist/src/core/head-retry.js.map +1 -0
  360. package/dist/src/core/instance.d.ts +253 -0
  361. package/dist/src/core/instance.js +319 -0
  362. package/dist/src/core/instance.js.map +1 -0
  363. package/dist/src/core/intake-limits.d.ts +247 -0
  364. package/dist/src/core/intake-limits.js +350 -0
  365. package/dist/src/core/intake-limits.js.map +1 -0
  366. package/dist/src/core/jcs.d.ts +52 -0
  367. package/dist/src/core/jcs.js +132 -0
  368. package/dist/src/core/jcs.js.map +1 -0
  369. package/dist/src/core/journal.d.ts +144 -0
  370. package/dist/src/core/journal.js +200 -0
  371. package/dist/src/core/journal.js.map +1 -0
  372. package/dist/src/core/live-draw.d.ts +436 -0
  373. package/dist/src/core/live-draw.js +703 -0
  374. package/dist/src/core/live-draw.js.map +1 -0
  375. package/dist/src/core/log-reconcile.d.ts +89 -0
  376. package/dist/src/core/log-reconcile.js +136 -0
  377. package/dist/src/core/log-reconcile.js.map +1 -0
  378. package/dist/src/core/log-subscribe.d.ts +36 -0
  379. package/dist/src/core/log-subscribe.js +162 -0
  380. package/dist/src/core/log-subscribe.js.map +1 -0
  381. package/dist/src/core/log.d.ts +278 -0
  382. package/dist/src/core/log.js +546 -0
  383. package/dist/src/core/log.js.map +1 -0
  384. package/dist/src/core/loop.d.ts +274 -0
  385. package/dist/src/core/loop.js +487 -0
  386. package/dist/src/core/loop.js.map +1 -0
  387. package/dist/src/core/md-fence.d.ts +41 -0
  388. package/dist/src/core/md-fence.js +74 -0
  389. package/dist/src/core/md-fence.js.map +1 -0
  390. package/dist/src/core/money.d.ts +147 -0
  391. package/dist/src/core/money.js +195 -0
  392. package/dist/src/core/money.js.map +1 -0
  393. package/dist/src/core/payload-census.d.ts +74 -0
  394. package/dist/src/core/payload-census.js +146 -0
  395. package/dist/src/core/payload-census.js.map +1 -0
  396. package/dist/src/core/payload-store.d.ts +175 -0
  397. package/dist/src/core/payload-store.js +340 -0
  398. package/dist/src/core/payload-store.js.map +1 -0
  399. package/dist/src/core/payload.d.ts +71 -0
  400. package/dist/src/core/payload.js +80 -0
  401. package/dist/src/core/payload.js.map +1 -0
  402. package/dist/src/core/policy-diff.d.ts +292 -0
  403. package/dist/src/core/policy-diff.js +588 -0
  404. package/dist/src/core/policy-diff.js.map +1 -0
  405. package/dist/src/core/policy-expectations.d.ts +199 -0
  406. package/dist/src/core/policy-expectations.js +394 -0
  407. package/dist/src/core/policy-expectations.js.map +1 -0
  408. package/dist/src/core/policy-explain.d.ts +150 -0
  409. package/dist/src/core/policy-explain.js +258 -0
  410. package/dist/src/core/policy-explain.js.map +1 -0
  411. package/dist/src/core/policy-load.d.ts +527 -0
  412. package/dist/src/core/policy-load.js +536 -0
  413. package/dist/src/core/policy-load.js.map +1 -0
  414. package/dist/src/core/policy-match.d.ts +281 -0
  415. package/dist/src/core/policy-match.js +478 -0
  416. package/dist/src/core/policy-match.js.map +1 -0
  417. package/dist/src/core/policy-proposal.d.ts +265 -0
  418. package/dist/src/core/policy-proposal.js +458 -0
  419. package/dist/src/core/policy-proposal.js.map +1 -0
  420. package/dist/src/core/prompt-layout.d.ts +221 -0
  421. package/dist/src/core/prompt-layout.js +422 -0
  422. package/dist/src/core/prompt-layout.js.map +1 -0
  423. package/dist/src/core/protected-path-guard.d.ts +453 -0
  424. package/dist/src/core/protected-path-guard.js +1566 -0
  425. package/dist/src/core/protected-path-guard.js.map +1 -0
  426. package/dist/src/core/registration.d.ts +25 -0
  427. package/dist/src/core/registration.js +39 -0
  428. package/dist/src/core/registration.js.map +1 -0
  429. package/dist/src/core/reindex.d.ts +99 -0
  430. package/dist/src/core/reindex.js +336 -0
  431. package/dist/src/core/reindex.js.map +1 -0
  432. package/dist/src/core/sampler.d.ts +313 -0
  433. package/dist/src/core/sampler.js +388 -0
  434. package/dist/src/core/sampler.js.map +1 -0
  435. package/dist/src/core/sandbox.d.ts +290 -0
  436. package/dist/src/core/sandbox.js +424 -0
  437. package/dist/src/core/sandbox.js.map +1 -0
  438. package/dist/src/core/seal.d.ts +165 -0
  439. package/dist/src/core/seal.js +290 -0
  440. package/dist/src/core/seal.js.map +1 -0
  441. package/dist/src/core/state.d.ts +505 -0
  442. package/dist/src/core/state.js +1009 -0
  443. package/dist/src/core/state.js.map +1 -0
  444. package/dist/src/core/task-file.d.ts +185 -0
  445. package/dist/src/core/task-file.js +464 -0
  446. package/dist/src/core/task-file.js.map +1 -0
  447. package/dist/src/core/telegram-config.d.ts +93 -0
  448. package/dist/src/core/telegram-config.js +114 -0
  449. package/dist/src/core/telegram-config.js.map +1 -0
  450. package/dist/src/core/token.d.ts +409 -0
  451. package/dist/src/core/token.js +561 -0
  452. package/dist/src/core/token.js.map +1 -0
  453. package/dist/src/core/validate.d.ts +138 -0
  454. package/dist/src/core/validate.js +0 -0
  455. package/dist/src/core/validate.js.map +1 -0
  456. package/dist/src/core/values.d.ts +137 -0
  457. package/dist/src/core/values.js +153 -0
  458. package/dist/src/core/values.js.map +1 -0
  459. package/dist/src/core/vault.d.ts +291 -0
  460. package/dist/src/core/vault.js +612 -0
  461. package/dist/src/core/vault.js.map +1 -0
  462. package/dist/src/core/verified-snapshot.d.ts +204 -0
  463. package/dist/src/core/verified-snapshot.js +506 -0
  464. package/dist/src/core/verified-snapshot.js.map +1 -0
  465. package/dist/src/core/verify.d.ts +336 -0
  466. package/dist/src/core/verify.js +549 -0
  467. package/dist/src/core/verify.js.map +1 -0
  468. package/dist/src/core/version.d.ts +8 -0
  469. package/dist/src/core/version.js +9 -0
  470. package/dist/src/core/version.js.map +1 -0
  471. package/dist/src/core/wysiwys.d.ts +370 -0
  472. package/dist/src/core/wysiwys.js +728 -0
  473. package/dist/src/core/wysiwys.js.map +1 -0
  474. package/dist/src/daemon/advance-child.d.ts +39 -0
  475. package/dist/src/daemon/advance-child.js +78 -0
  476. package/dist/src/daemon/advance-child.js.map +1 -0
  477. package/dist/src/daemon/advance.d.ts +466 -0
  478. package/dist/src/daemon/advance.js +849 -0
  479. package/dist/src/daemon/advance.js.map +1 -0
  480. package/dist/src/daemon/audit.d.ts +87 -0
  481. package/dist/src/daemon/audit.js +90 -0
  482. package/dist/src/daemon/audit.js.map +1 -0
  483. package/dist/src/daemon/daemon.d.ts +1180 -0
  484. package/dist/src/daemon/daemon.js +1988 -0
  485. package/dist/src/daemon/daemon.js.map +1 -0
  486. package/dist/src/daemon/dark-session.d.ts +64 -0
  487. package/dist/src/daemon/dark-session.js +119 -0
  488. package/dist/src/daemon/dark-session.js.map +1 -0
  489. package/dist/src/daemon/draw-child.d.ts +36 -0
  490. package/dist/src/daemon/draw-child.js +132 -0
  491. package/dist/src/daemon/draw-child.js.map +1 -0
  492. package/dist/src/daemon/draw.d.ts +154 -0
  493. package/dist/src/daemon/draw.js +458 -0
  494. package/dist/src/daemon/draw.js.map +1 -0
  495. package/dist/src/daemon/git-evidence.d.ts +173 -0
  496. package/dist/src/daemon/git-evidence.js +345 -0
  497. package/dist/src/daemon/git-evidence.js.map +1 -0
  498. package/dist/src/daemon/projection.d.ts +180 -0
  499. package/dist/src/daemon/projection.js +233 -0
  500. package/dist/src/daemon/projection.js.map +1 -0
  501. package/dist/src/daemon/prune.d.ts +207 -0
  502. package/dist/src/daemon/prune.js +376 -0
  503. package/dist/src/daemon/prune.js.map +1 -0
  504. package/dist/src/mcp/http.d.ts +113 -0
  505. package/dist/src/mcp/http.js +343 -0
  506. package/dist/src/mcp/http.js.map +1 -0
  507. package/dist/src/mcp/server.d.ts +265 -0
  508. package/dist/src/mcp/server.js +602 -0
  509. package/dist/src/mcp/server.js.map +1 -0
  510. package/docs/adapter-api.md +106 -0
  511. package/docs/cli-reference.md +5716 -0
  512. package/docs/codex-enforced-session.md +30 -0
  513. package/package.json +53 -4
  514. package/schema/.gitkeep +0 -0
  515. package/schema/LICENSE +117 -0
  516. package/schema/codex-instance.schema.json +82 -0
  517. package/schema/envelope.schema.json +137 -0
  518. package/schema/event.schema.json +1811 -0
  519. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  520. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  521. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  522. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  523. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  524. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  525. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  526. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  527. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  528. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  529. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  530. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  531. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  532. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  533. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  534. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  535. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  536. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  537. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  538. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  539. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  540. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  541. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  542. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  543. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  544. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  545. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  546. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  547. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  548. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  549. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  550. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  551. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  552. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  553. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  554. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  555. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  556. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  557. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  558. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  559. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  560. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  561. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  562. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  563. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  564. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  565. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  566. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  567. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  568. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  569. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  570. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  571. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  572. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  573. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  574. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  575. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  576. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  577. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  578. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  579. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  580. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  581. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  582. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  583. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  584. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  585. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  586. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  587. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  588. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  589. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  590. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  591. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  592. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  593. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  594. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  595. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  596. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  597. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  598. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  599. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  600. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  601. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  602. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  603. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  604. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  605. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  606. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  607. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  608. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  609. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  610. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  611. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  612. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  613. package/schema/fixtures/event/valid/execution-started.json +14 -0
  614. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  615. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  616. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  617. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  618. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  619. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  620. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  621. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  622. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  623. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  624. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  625. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  626. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  627. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  628. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  629. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  630. package/schema/fixtures/event/valid/spec-example.json +15 -0
  631. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  632. package/schema/fixtures/event/valid/task-registered.json +14 -0
  633. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  634. package/schema/fixtures/hash/known-answer.json +74 -0
  635. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  636. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  637. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  638. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  639. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  640. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  641. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  642. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  643. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  644. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  645. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  646. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  647. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  648. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  649. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  650. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  651. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  652. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  653. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  654. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  655. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  656. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  657. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  658. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  659. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  660. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  661. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  662. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  663. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  664. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  665. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  666. package/schema/fixtures/policy/valid/canonical.json +47 -0
  667. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  668. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  669. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  670. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  671. package/schema/fixtures/policy/valid/human-only.json +9 -0
  672. package/schema/fixtures/policy/valid/minimal.json +6 -0
  673. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  674. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  675. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  676. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  677. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  678. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  679. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  680. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  681. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  682. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  683. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  684. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  685. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  686. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  687. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  688. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  689. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  690. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  691. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  692. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  693. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  694. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  695. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  696. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  697. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  698. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  699. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  700. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  701. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  702. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  703. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  704. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  705. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  706. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  707. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  708. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  709. package/schema/fixtures/values/invalid/version-string.json +1 -0
  710. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  711. package/schema/fixtures/values/valid/full.json +20 -0
  712. package/schema/fixtures/values/valid/minimal.json +1 -0
  713. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  714. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  715. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  716. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  717. package/schema/fixtures/values-md/valid/absent.md +50 -0
  718. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  719. package/schema/policy.schema.json +501 -0
  720. package/schema/sample-record.schema.json +26 -0
  721. package/schema/values.schema.json +55 -0
  722. package/templates/codex/README.md +9 -0
@@ -0,0 +1,2171 @@
1
+ /**
2
+ * `approval policy amend` (APRV-30) — the one verb that owns the whole
3
+ * amendment ceremony: diff, advise, confirm, attest, commit.
4
+ *
5
+ * ## The two incidents this verb exists to prevent
6
+ *
7
+ * **seq 2 of this repository's own log — the seven-minute amendment.** A
8
+ * policy edit was attested and superseded seven minutes later (seq 2 at
9
+ * 11:56:07Z, seq 3 at 12:03:35Z), because the
10
+ * edit broke a pinned dogfood assertion and nobody found out until the test
11
+ * suite ran. The operator attested bytes whose *consequences* had never been
12
+ * shown to them. `amend` shows the semantic diff and the load advisory BEFORE
13
+ * asking for the sign-off, so the failure that superseded seq 2 would have been
14
+ * on screen while the human was deciding — and with `--require-load` it would
15
+ * have refused to attest at all.
16
+ *
17
+ * **The unsigned interregnum — commit `f829e6c` and its attestation.** The
18
+ * policy-editing commit and the attestation that made the edit operative landed
19
+ * as two separate commits. In between, the repository carried an inoperative
20
+ * policy: `checkAttestation` said `hash-mismatch` and every gate operation
21
+ * refused. `amend` closes that window by making the attestation and the git
22
+ * commit one ceremony — it prints, or with `--commit` runs, the exact two-file
23
+ * `git add` + `git commit` that lands the policy edit and its attestation
24
+ * together, and it validates the `--commit` preconditions BEFORE it attests, so
25
+ * a refusal can never leave a half-finished amendment behind.
26
+ *
27
+ * ## The baseline problem, stated plainly (FLAGGED FOR HUMAN REVIEW)
28
+ *
29
+ * A semantic diff needs the previously-attested policy *text*. The log does not
30
+ * have it. An attestation records only the SHA-256 of the bytes — deliberately,
31
+ * since the log is meant to be exported and copied and a policy body in it
32
+ * would be a second source of truth. So the attested bytes are **not
33
+ * recoverable from the log**, and this verb does not pretend otherwise.
34
+ *
35
+ * What it does instead, at v0.1: when the policy file lives in a git
36
+ * repository, it recovers `HEAD:<path>` and hashes it. **Only if that blob's
37
+ * hash equals the attested hash** is it used as the diff baseline — the point
38
+ * being that we can then prove the text we are diffing against is exactly the
39
+ * text that was signed for. Anything else (not a git repo, no such blob, or a
40
+ * blob whose hash differs from the attestation) drops to **hash-only mode**: a
41
+ * loud notice that the semantic diff is unavailable, followed by the load
42
+ * advisory and the attestation, which still work. No `--baseline` flag is
43
+ * offered; a baseline the operator supplies by hand is a baseline nobody can
44
+ * verify, which is exactly the assurance this design refuses to fake.
45
+ *
46
+ * The limitation is real and worth a human's judgment: an amendment made
47
+ * outside git, or one made on top of an unattested working-tree edit, gets no
48
+ * semantic diff. Flagged rather than smoothed over.
49
+ *
50
+ * ## What this file does and does not decide
51
+ *
52
+ * As everywhere else in the CLI: the diff is `core/policy-diff.ts`, loading is
53
+ * `core/policy-load.ts`, matching is `core/policy-match.ts`, hashing and the
54
+ * append are `core/attest.ts`. This file resolves paths and identity, shells out
55
+ * to git, decides exit codes, and formats output.
56
+ */
57
+ import { spawnSync } from "node:child_process";
58
+ import { createHash } from "node:crypto";
59
+ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
60
+ import { tmpdir } from "node:os";
61
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePathSegments, sep } from "node:path";
62
+ import { HUMAN_ACTOR_ENV, appendAttestation, checkAttestation, policyFileHash, resolveHumanActor, } from "../core/attest.js";
63
+ import { compareChains } from "../core/log-reconcile.js";
64
+ import { diffPolicies, renderDiff, SPEC_NAMESPACES } from "../core/policy-diff.js";
65
+ import { checkPolicyExpectations, describeFailure, describePinChange, diffPinSources, expectationsFor, DOGFOOD_SUITE_BUILT, DOGFOOD_SUITE_SOURCE, EXPECTATIONS_MODULE, } from "../core/policy-expectations.js";
66
+ import { loadPolicy, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
67
+ import { proposalState, proposeAttestation, } from "../core/policy-proposal.js";
68
+ import { readVerifiedRecords } from "../core/state.js";
69
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
70
+ import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_TORN_TAIL, EXIT_USAGE, } from "./exit-codes.js";
71
+ import { POLICY_AMEND_HELP } from "./help.js";
72
+ import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
73
+ import { GIT_OUTPUT_LIMIT_BYTES, commitOnBase, fetchBase, showBlob } from "./git-scope.js";
74
+ import { createProgress, silentProgress } from "./progress.js";
75
+ import { readLineFromStdin } from "./prompt.js";
76
+ import { refusal as renderRefusal, relPath, runbook, shortHash, style, } from "./style.js";
77
+ import { usageErrorText } from "./usage.js";
78
+ const FLAGS = {
79
+ "--policy": "string",
80
+ "--dir": "string",
81
+ "--log": "string",
82
+ "--as": "string",
83
+ "--require-load": "boolean",
84
+ "--dry-run": "boolean",
85
+ "--commit": "boolean",
86
+ "--no-publish": "boolean",
87
+ "--branch": "string",
88
+ "--direct": "boolean",
89
+ "--yes": "boolean",
90
+ // APRV-109: the agent path's two knobs. `--wait` is how long this process
91
+ // holds the ceremony open for the approver's tap, and `--interval` how often
92
+ // it re-reads the log. Both are ignored under a human identity, where the
93
+ // human act is the confirmation this process already asks for.
94
+ "--wait": "string",
95
+ "--interval": "string",
96
+ "--note": "string",
97
+ "--json": "boolean",
98
+ "--help": "boolean",
99
+ "-h": "boolean",
100
+ };
101
+ /**
102
+ * How long the agent path waits for a tap when `--wait` is not given
103
+ * (APRV-109).
104
+ *
105
+ * Long enough that a phone left face-down through a meeting still collects the
106
+ * decision, short enough that a forgotten `amend` does not hold a worktree
107
+ * open overnight. A lapse attests nothing, so the cost of the timeout being
108
+ * too short is a re-run.
109
+ */
110
+ const DEFAULT_ATTESTATION_WAIT_MS = 15 * 60 * 1000;
111
+ /** How often the agent path re-reads the log while waiting. */
112
+ const DEFAULT_ATTESTATION_INTERVAL_MS = 2000;
113
+ /** An agent identity, the one `--as` form that routes to the channel path. */
114
+ const AGENT_ACTOR = /^agent:.+/u;
115
+ /** Synchronous sleep with no dependency and no busy-spin (as `cli/execute.ts`). */
116
+ function sleepSync(ms) {
117
+ if (ms <= 0)
118
+ return;
119
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
120
+ }
121
+ function detail(cause) {
122
+ return cause instanceof Error ? cause.message : String(cause);
123
+ }
124
+ function absolute(value, cwd) {
125
+ return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
126
+ }
127
+ function usageError(streams, json, message) {
128
+ if (json)
129
+ streams.err(`${JSON.stringify({ ok: false, error: { code: "usage", message } })}\n`);
130
+ else
131
+ streams.err(usageErrorText(message, POLICY_AMEND_HELP));
132
+ return EXIT_USAGE;
133
+ }
134
+ /**
135
+ * A refusal, on both surfaces.
136
+ *
137
+ * `message` is the FROZEN machine surface: it is what `--json` carries and what
138
+ * the tests pin. `human` (APRV-129) is an alternative rendering of the same
139
+ * facts for a terminal, the runbook shape, for the refusals a human has to act
140
+ * on step by step. Passing it changes nothing a machine reads.
141
+ */
142
+ function refuse(streams, json, code, message, exitCode, human,
143
+ /**
144
+ * The ceremony's own outcome, ADDITIVE (APRV-130). A refusal that arrives
145
+ * AFTER the attestation is a refusal of a sub-step, and a machine caller has
146
+ * to be able to see that split without parsing the message: `ceremony` and
147
+ * `publishing` ride alongside the frozen `{ok:false,error:{…}}`, never
148
+ * inside it.
149
+ */
150
+ extra) {
151
+ if (json) {
152
+ streams.err(`${JSON.stringify({ ok: false, error: { code, message }, ...extra })}\n`);
153
+ }
154
+ else if (human !== undefined)
155
+ streams.err(`${human}\n`);
156
+ else
157
+ streams.err(`approval: ${message}\n`);
158
+ return exitCode;
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // Policy path resolution (mirrors `cli/attest.ts`, deliberately)
162
+ // ---------------------------------------------------------------------------
163
+ /**
164
+ * Is this path a readable regular file? As in `policy attest` — and unlike
165
+ * `policy check` — an absent policy file is an I/O error and not an answer:
166
+ * there is no fail-closed reading of "amend a file that is not there".
167
+ */
168
+ function readableFile(path) {
169
+ let stats;
170
+ try {
171
+ stats = statSync(path);
172
+ }
173
+ catch (cause) {
174
+ return { ok: false, message: `policy ${path} could not be opened: ${detail(cause)}` };
175
+ }
176
+ if (stats.isDirectory()) {
177
+ return { ok: false, message: `policy ${path} is a directory, not a policy file` };
178
+ }
179
+ try {
180
+ accessSync(path, constants.R_OK);
181
+ }
182
+ catch (cause) {
183
+ return { ok: false, message: `policy ${path} is not readable: ${detail(cause)}` };
184
+ }
185
+ return { ok: true };
186
+ }
187
+ /**
188
+ * `--policy` wins outright; otherwise discovery walks `POLICY_FILENAMES` in
189
+ * `dir` and amends whichever file discovery would have loaded — the same
190
+ * precedence as `loadPolicy` and `policy attest`, so the amended file, the
191
+ * attested file, and the enforced file are never three different files.
192
+ */
193
+ function resolvePolicyPath(policyFlag, dir, cwd) {
194
+ if (policyFlag !== null) {
195
+ const path = absolute(policyFlag, cwd);
196
+ const check = readableFile(path);
197
+ return check.ok ? { ok: true, path } : { ok: false, message: check.message };
198
+ }
199
+ for (const filename of POLICY_FILENAMES) {
200
+ const candidate = join(dir, filename);
201
+ try {
202
+ statSync(candidate);
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ const check = readableFile(candidate);
208
+ return check.ok ? { ok: true, path: candidate } : { ok: false, message: check.message };
209
+ }
210
+ return {
211
+ ok: false,
212
+ message: `no policy file found in ${dir} (looked for ${POLICY_FILENAMES.join(", ")}); an amendment needs a file to hash`,
213
+ };
214
+ }
215
+ function git(args, cwd) {
216
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
217
+ if (result.error !== undefined || result.status === null) {
218
+ return { ok: false, stdout: "", stderr: detail(result.error ?? "git did not run") };
219
+ }
220
+ return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr };
221
+ }
222
+ /** The repository root containing `dir`, or `null` when there is none. */
223
+ function repoRoot(dir) {
224
+ const result = git(["rev-parse", "--show-toplevel"], dir);
225
+ if (!result.ok)
226
+ return null;
227
+ const root = result.stdout.trim();
228
+ return root.length === 0 ? null : root;
229
+ }
230
+ /** A repo-relative, forward-slashed path, as git spells it. */
231
+ function repoPath(root, path) {
232
+ return relative(root, path).split(sep).join("/");
233
+ }
234
+ /**
235
+ * The bytes of `HEAD:<relative>`, or `null` when git has no such blob.
236
+ *
237
+ * Read as a Buffer, never as text: the baseline is compared by SHA-256 against
238
+ * an attestation over exact bytes, and an encoding round-trip would silently
239
+ * change what is being compared.
240
+ *
241
+ * The explicit `maxBuffer` is the second half of the same lesson the anchor
242
+ * check learned the hard way: `spawnSync`'s one-mebibyte default KILLS the
243
+ * child, and a check that reads `null` as "HEAD has no such file" then reports
244
+ * a missing baseline for a file git is holding. This one reads the policy file,
245
+ * which is nowhere near the ceiling today — and neither was the log.
246
+ */
247
+ function showHead(root, relative_) {
248
+ const result = spawnSync("git", ["show", `HEAD:${relative_}`], {
249
+ cwd: root,
250
+ maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
251
+ });
252
+ if (result.error !== undefined || result.status !== 0)
253
+ return null;
254
+ return result.stdout;
255
+ }
256
+ /** The checked-out branch, or `null` on a detached HEAD. */
257
+ function currentBranch(root) {
258
+ const result = git(["symbolic-ref", "--quiet", "--short", "HEAD"], root);
259
+ if (!result.ok)
260
+ return null;
261
+ const name = result.stdout.trim();
262
+ return name.length === 0 ? null : name;
263
+ }
264
+ /**
265
+ * The remote's default branch, from `refs/remotes/origin/HEAD` when the clone
266
+ * recorded one, else from `gh`. Both are read-only lookups.
267
+ */
268
+ function defaultBranchOf(root) {
269
+ const symbolic = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
270
+ if (symbolic.ok) {
271
+ const name = symbolic.stdout.trim().replace(/^origin\//u, "");
272
+ if (name.length > 0)
273
+ return name;
274
+ }
275
+ const view = spawnSync("gh", ["repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"], {
276
+ cwd: root,
277
+ encoding: "utf8",
278
+ });
279
+ if (view.error === undefined && view.status === 0) {
280
+ const name = view.stdout.trim();
281
+ if (name.length > 0)
282
+ return name;
283
+ }
284
+ return null;
285
+ }
286
+ /** One `gh api <endpoint>` read, classified. `--silent` is NOT passed: the rulesets read needs the body. */
287
+ function ghApi(root, endpoint) {
288
+ const run = spawnSync("gh", ["api", endpoint], { cwd: root, encoding: "utf8" });
289
+ if (run.error !== undefined || run.status === null)
290
+ return { kind: "absent" };
291
+ if (run.status === 0)
292
+ return { kind: "ok", stdout: `${run.stdout}` };
293
+ const stderr = `${run.stderr}`;
294
+ if (/404|Branch not protected|Not Found/iu.test(stderr))
295
+ return { kind: "not-found" };
296
+ return { kind: "error", detail: stderr.trim().split("\n")[0] ?? "no detail" };
297
+ }
298
+ /**
299
+ * The rule types a rulesets answer lists, or `null` when the body is not a JSON
300
+ * array. An empty array is the honest "no rules" and answers `[]`.
301
+ */
302
+ function ruleTypes(body) {
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(body);
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ if (!Array.isArray(parsed))
311
+ return null;
312
+ return parsed.map((rule) => {
313
+ if (typeof rule === "object" && rule !== null && typeof rule.type === "string") {
314
+ return rule.type;
315
+ }
316
+ return "rule";
317
+ });
318
+ }
319
+ /**
320
+ * Ask GitHub whether the default branch is protected.
321
+ *
322
+ * Two read-only probes, because GitHub has two ways to protect a branch and
323
+ * answers each from its own endpoint (APRV-232). Classic branch protection
324
+ * lives at `repos/{owner}/{repo}/branches/<branch>/protection`, which answers
325
+ * 200 when it is set and 404 when it is not. Repository rulesets (the merge
326
+ * queue, required checks and the rest of what governs this project's own main)
327
+ * are invisible there: the classic endpoint answers 404 for a branch a ruleset
328
+ * governs, and a probe that stopped at that answer concluded "unprotected",
329
+ * pushed, and printed the remote's GH013 rejection at the human's one hands-on
330
+ * moment. The rules that apply to a branch are listed at
331
+ * `repos/{owner}/{repo}/rules/branches/<branch>`: a non-empty array is
332
+ * protected, an empty array (or a 404) is no rules.
333
+ *
334
+ * Resolution: either probe protected => protected. Classic 404 AND rulesets
335
+ * empty => unprotected. Anything else (gh absent, not a GitHub remote, an
336
+ * unauthenticated or under-scoped token, a body that is not JSON) is `unknown`.
337
+ * The classic probe answering protected ends the lookup; the rulesets endpoint
338
+ * is read only when classic could not prove protection.
339
+ */
340
+ function probeProtection(root) {
341
+ const branch = currentBranch(root);
342
+ const target = defaultBranchOf(root);
343
+ if (target === null) {
344
+ return {
345
+ protection: "unknown",
346
+ defaultBranch: null,
347
+ currentBranch: branch,
348
+ reason: "no default branch could be resolved (no origin/HEAD and no gh answer)",
349
+ };
350
+ }
351
+ const answer = (protection, reason) => ({
352
+ protection,
353
+ defaultBranch: target,
354
+ currentBranch: branch,
355
+ reason,
356
+ });
357
+ const classic = ghApi(root, `repos/{owner}/{repo}/branches/${target}/protection`);
358
+ if (classic.kind === "absent") {
359
+ return answer("unknown", "gh is not on PATH, so branch protection could not be read");
360
+ }
361
+ if (classic.kind === "ok") {
362
+ return answer("protected", `gh reports branch protection on ${target}`);
363
+ }
364
+ const rules = ghApi(root, `repos/{owner}/{repo}/rules/branches/${target}`);
365
+ const types = rules.kind === "ok" ? ruleTypes(rules.stdout) : null;
366
+ if (types !== null && types.length > 0) {
367
+ return answer("protected", `gh reports a ruleset on ${target} (${[...new Set(types)].join(", ")})`);
368
+ }
369
+ const noRules = rules.kind === "not-found" || (types !== null && types.length === 0);
370
+ if (classic.kind === "not-found" && noRules) {
371
+ return answer("unprotected", `gh reports no branch protection and no ruleset on ${target}`);
372
+ }
373
+ const classicDetail = classic.kind === "not-found" ? "no classic branch protection" : `classic protection unreadable (${classic.detail})`;
374
+ const rulesDetail = rules.kind === "absent"
375
+ ? "gh did not run"
376
+ : rules.kind === "error"
377
+ ? `rulesets unreadable (${rules.detail})`
378
+ : rules.kind === "ok" && types === null
379
+ ? "rulesets answer was not a JSON array"
380
+ : "no ruleset";
381
+ return answer("unknown", `gh could not settle protection on ${target}: ${classicDetail}; ${rulesDetail}`);
382
+ }
383
+ /** Does this repository have an `origin` to push to? */
384
+ function hasOrigin(root) {
385
+ return git(["remote", "get-url", "origin"], root).ok;
386
+ }
387
+ /**
388
+ * Everything git said about a failed push, on one line (APRV-111).
389
+ *
390
+ * A rejection's useful text is spread over four lines — the remote's own
391
+ * message, the `! [remote rejected]` line, and git's summary — and the refusal
392
+ * that carries it is a single message string. They are joined rather than
393
+ * trimmed to the first line, because "which ref, rejected by what" lives in
394
+ * different lines depending on who did the rejecting.
395
+ */
396
+ function pushFailureText(run) {
397
+ const lines = commandOutputLines(run.stderr, run.stdout);
398
+ return lines.length === 0 ? "git printed nothing" : lines.join(" | ");
399
+ }
400
+ /**
401
+ * The same output, kept as LINES (APRV-129).
402
+ *
403
+ * The joined form above exists because a `--json` message is one string. A
404
+ * terminal has no such constraint, and the remote's own four lines are exactly
405
+ * the part the reader needs to see as the remote wrote them, indented under the
406
+ * headline rather than folded into a sentence.
407
+ */
408
+ function commandOutputLines(...texts) {
409
+ return texts
410
+ .join("\n")
411
+ .split("\n")
412
+ .map((line) => line.trim())
413
+ .filter((line) => line.length > 0);
414
+ }
415
+ /**
416
+ * Why the pull request is merged with a merge commit, in one line (APRV-129).
417
+ *
418
+ * It used to be an inline essay in the middle of the recovery commands. The
419
+ * reasoning did not get shorter; it moved to the reference, and what stays here
420
+ * is the rule and where to read about it.
421
+ */
422
+ const MERGE_COMMIT_LINE = "why a MERGE COMMIT: the policy edit and its attestation stay one commit on main (docs/cli-reference.md, `policy amend`)";
423
+ /**
424
+ * How a local branch gets back onto its remote, safely (APRV-129).
425
+ *
426
+ * This line replaces a `git reset --hard origin/<branch>` that the recovery
427
+ * used to end on. With an uncommitted working log, a hard reset rewinds
428
+ * `events.jsonl` underneath the daemon that is appending to it: the fork
429
+ * mechanism, printed as advice. APRV-125 turned the safe sequence into a verb,
430
+ * so what this points at is a command now rather than a runbook.
431
+ */
432
+ const LOG_SAFE_PULL_LINE = "then `approval log sync` rather than a pull: it holds the append lock, snapshots the log, fast-forwards and reconciles the chain (a hard reset would rewind the working log under the daemon)";
433
+ /** Is `gh` runnable at all? Used to decide whether the PR is opened or printed. */
434
+ function ghAvailable(root) {
435
+ const probe = spawnSync("gh", ["--version"], { cwd: root, encoding: "utf8" });
436
+ return probe.error === undefined && probe.status === 0;
437
+ }
438
+ /** `gh`, run the way {@link git} runs git: never throws, always answers. */
439
+ function gh(args, cwd) {
440
+ const result = spawnSync("gh", args, { cwd, encoding: "utf8" });
441
+ if (result.error !== undefined || result.status === null) {
442
+ return { ok: false, stdout: "", stderr: detail(result.error ?? "gh did not run") };
443
+ }
444
+ return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr };
445
+ }
446
+ /** The last URL `gh` printed, which is where `gh pr create` puts the PR. */
447
+ function lastUrl(text) {
448
+ const urls = text
449
+ .split("\n")
450
+ .map((line) => line.trim())
451
+ .filter((line) => line.startsWith("http"));
452
+ return urls[urls.length - 1] ?? null;
453
+ }
454
+ /**
455
+ * How a pull request is named to a human: `#7` when the URL carries a number,
456
+ * the URL itself otherwise. "PR #7 opened" is the sentence the operator repeats
457
+ * back; a bare URL is not.
458
+ */
459
+ function prLabel(url) {
460
+ if (url === null)
461
+ return "the pull request";
462
+ const number = /\/pull\/(\d+)/u.exec(url)?.[1];
463
+ return number === undefined ? url : `PR #${number}`;
464
+ }
465
+ /** The pull request title. It names the seq, so the PR is findable from the log. */
466
+ function prTitle(summary, seq) {
467
+ return `Policy: ${summary} (attested seq ${seq})`;
468
+ }
469
+ /**
470
+ * The pull request body: the one-commit rule, and the merge instruction.
471
+ *
472
+ * One line on purpose. It is printed inside a `gh pr create --body "…"` command
473
+ * the human may copy, and a body with embedded newlines does not survive that
474
+ * copy intact.
475
+ */
476
+ function prBody(seq) {
477
+ return (`This branch carries exactly one commit: the policy edit and the attestation (seq ${seq}) that names its hash. ` +
478
+ "They have to stay together on main, because a main that carries the policy without its attestation is a main where every gate operation refuses. " +
479
+ "Merge with a MERGE COMMIT so the policy edit and its attestation stay one commit on main. " +
480
+ "A squash or a rebase would also keep the two files together; a merge commit is the convention here, because it puts the attested commit itself on main with the hash the attestation names.");
481
+ }
482
+ /**
483
+ * Recover the last-attested policy text, or say honestly that we cannot.
484
+ *
485
+ * The only accepted baseline is a `HEAD` blob whose SHA-256 equals the attested
486
+ * hash. See the module header: an unverifiable baseline would produce a diff
487
+ * that looks authoritative and is not.
488
+ */
489
+ function recoverBaseline(policyPath, attestedSha256) {
490
+ const unavailable = (reason) => ({
491
+ baseline: { mode: "unavailable", reason },
492
+ load: null,
493
+ bytes: null,
494
+ scratch: null,
495
+ });
496
+ if (attestedSha256 === null) {
497
+ return unavailable("the policy has never been attested, so there is no previous state to diff against");
498
+ }
499
+ const root = repoRoot(dirname(policyPath));
500
+ if (root === null) {
501
+ return unavailable("the policy file is not inside a git repository, and the attested BYTES are not recoverable from the log (an attestation records only their SHA-256)");
502
+ }
503
+ const blob = showHead(root, repoPath(root, policyPath));
504
+ if (blob === null) {
505
+ return unavailable(`git has no HEAD:${repoPath(root, policyPath)} blob to recover`);
506
+ }
507
+ const blobSha256 = createHash("sha256").update(blob).digest("hex");
508
+ if (blobSha256 !== attestedSha256) {
509
+ return unavailable(`HEAD:${repoPath(root, policyPath)} hashes ${blobSha256}, which is not the attested ${attestedSha256}; a baseline nobody can verify is not a baseline`);
510
+ }
511
+ const scratchDir = mkdtempSync(join(tmpdir(), "approval-amend-"));
512
+ const scratch = join(scratchDir, basename(policyPath));
513
+ writeFileSync(scratch, blob);
514
+ return {
515
+ baseline: { mode: "git-head", reason: null },
516
+ load: loadPolicy({ file: scratch }),
517
+ bytes: blob,
518
+ scratch: scratchDir,
519
+ };
520
+ }
521
+ /**
522
+ * Did the pins file move between `rev` and the working tree (APRV-274)?
523
+ *
524
+ * The seq 23351 ceremony is the reason this exists. The pins in
525
+ * `src/core/policy-expectations.ts` are part of an amendment's contract: CI's
526
+ * dogfood suite resolves the amended policy against them, and it reads both out
527
+ * of the same commit. The verb used to refuse a commit carrying anything but
528
+ * the policy and the log, so the pins had to be unstaged before the ceremony
529
+ * and cherry-picked onto the amendment branch after the push, which is four
530
+ * hand steps and two red CI runs for one policy edit.
531
+ *
532
+ * `rev` is the commit the amendment is being BUILT ON, not `HEAD`: the commit is
533
+ * assembled on the remote's tip, so the remote's tip is the thing this file is
534
+ * moving away from. Where a ceremony has no base yet (the report-only and
535
+ * `--dry-run` paths, which build nothing) `HEAD` stands in for it.
536
+ *
537
+ * `null` is "the pins are not part of this ceremony", for all three of its
538
+ * reasons: the policy is not one these pins govern, the file is not there, and
539
+ * the file is byte-for-byte what `rev` carries.
540
+ */
541
+ function pinsChangeIn(root, policyPath, rev) {
542
+ if (expectationsFor(policyPath) === null)
543
+ return null;
544
+ const path = join(root, EXPECTATIONS_MODULE);
545
+ let working;
546
+ try {
547
+ working = readFileSync(path, "utf8");
548
+ }
549
+ catch {
550
+ return null;
551
+ }
552
+ const based = showBlob(root, rev, EXPECTATIONS_MODULE);
553
+ const before = based === null ? "" : based.toString("utf8");
554
+ if (before === working)
555
+ return null;
556
+ return { arg: EXPECTATIONS_MODULE, path, changes: diffPinSources(before, working) };
557
+ }
558
+ /**
559
+ * How long the ceremony waits for the dogfood suite before giving up on it.
560
+ *
561
+ * Generous, because the cost of being wrong in each direction is not
562
+ * symmetrical: a suite that needed one more second reports `dogfood-suite-failed`
563
+ * and the operator re-runs a ceremony that has attested nothing, while a
564
+ * ceremony that gave up early and called it green is the outcome this whole
565
+ * check exists to prevent. The suite reads one policy file and resolves a few
566
+ * dozen classes; it does not go near this ceiling.
567
+ */
568
+ const DOGFOOD_SUITE_TIMEOUT_MS = 5 * 60 * 1000;
569
+ /** At most this many failing test names ride in a refusal; the rest are counted. */
570
+ const DOGFOOD_FAILURES_SHOWN = 5;
571
+ /**
572
+ * The names of the tests a TAP stream reported as failing.
573
+ *
574
+ * `not ok <n> - <name>`, at any indent, because a failing subtest is a failing
575
+ * test and its name is the one that says what broke. A trailing TAP directive
576
+ * (`# SKIP`, `# TODO`) is not part of the name. Duplicates collapse: one test
577
+ * can be reported at its own level and again in its parent's summary, and a
578
+ * refusal naming the same test twice reads as two failures. A name that is an
579
+ * absolute path is the runner's own FILE-level line, which some versions emit
580
+ * beside the failing test and which tells an operator nothing they did not
581
+ * already know from the code they are reading.
582
+ */
583
+ function failedTapTests(output) {
584
+ const names = new Set();
585
+ for (const line of output.split("\n")) {
586
+ const matched = /^\s*not ok\s+\d+\s*-?\s*(.*)$/u.exec(line);
587
+ if (matched === null)
588
+ continue;
589
+ const name = (matched[1] ?? "").replace(/\s+#\s.*$/u, "").trim();
590
+ if (name.length > 0 && !name.startsWith("/"))
591
+ names.add(name);
592
+ }
593
+ return [...names];
594
+ }
595
+ /**
596
+ * How many tests a TAP stream says it ran, or `null` when it did not say.
597
+ *
598
+ * The counterpart to {@link failedTapTests} and the reason a green exit is not
599
+ * taken at face value: a runner that ran NOTHING also exits 0, and a ceremony
600
+ * that read that as a passing suite would be reporting the strongest possible
601
+ * evidence from the weakest possible run.
602
+ */
603
+ function tapTestCount(output) {
604
+ const matched = /^#\s+tests\s+(\d+)\s*$/mu.exec(output);
605
+ return matched === null ? null : Number(matched[1]);
606
+ }
607
+ /** The failing tests as one clause, capped so a refusal stays readable. */
608
+ function summarizeFailures(tests) {
609
+ if (tests.length === 0)
610
+ return "the suite exited non-zero and named no test";
611
+ const shown = tests.slice(0, DOGFOOD_FAILURES_SHOWN);
612
+ const rest = tests.length - shown.length;
613
+ return `${shown.join("; ")}${rest > 0 ? ` (and ${String(rest)} more)` : ""}`;
614
+ }
615
+ /**
616
+ * Run this repository's dogfood suite against the amended policy (APRV-274).
617
+ *
618
+ * It is run rather than reimplemented: `tests/dogfood.test.ts` reads the live
619
+ * `APPROVAL.md` off disk and imports the BUILT pins, so running the built suite
620
+ * from the repository root asks exactly the question CI asks, of exactly the
621
+ * bytes this ceremony is about to attest. A second copy of those assertions
622
+ * inside the verb would be a second thing to keep in step with the first.
623
+ *
624
+ * Fail closed in four directions. A build output that is missing beside a
625
+ * present source is refused rather than skipped, because the suite would
626
+ * otherwise read the PREVIOUS build's pins and answer for an edit nobody made.
627
+ * A suite that could not be spawned, or that ran past its timeout, is refused
628
+ * for the same reason: nothing was established. A suite that exits 0 having
629
+ * reported no tests is refused too, because a green exit over an empty run is
630
+ * the strongest-looking evidence this check could report and the weakest there
631
+ * is. And a repository with no dogfood suite in source is skipped outright,
632
+ * which is what keeps a shipped CLI from running this repository's tests inside
633
+ * somebody else's checkout.
634
+ *
635
+ * `NODE_TEST_CONTEXT` is stripped from the child's environment, and that line is
636
+ * load-bearing rather than tidy. `node:test` responds to that variable by
637
+ * declining to run files recursively: it prints a warning, runs nothing, and
638
+ * EXITS 0. The variable is set for anything the test runner itself spawned, so
639
+ * a ceremony run from inside a test (which is how this code is exercised) read a
640
+ * red suite as green and published the whole amendment. The empty-run guard
641
+ * above does not catch that case on its own, because the runner still counts the
642
+ * file it declined to run as one passing test.
643
+ */
644
+ function runDogfoodSuite(root) {
645
+ if (!existsSync(join(root, DOGFOOD_SUITE_SOURCE)))
646
+ return { kind: "skipped" };
647
+ const built = join(root, DOGFOOD_SUITE_BUILT);
648
+ if (!existsSync(built))
649
+ return { kind: "not-built" };
650
+ const env = { ...process.env };
651
+ // See the header: with this set, `node:test` runs nothing and exits 0.
652
+ delete env["NODE_TEST_CONTEXT"];
653
+ const run = spawnSync(process.execPath, ["--test", "--test-reporter=tap", built], {
654
+ cwd: root,
655
+ encoding: "utf8",
656
+ env,
657
+ timeout: DOGFOOD_SUITE_TIMEOUT_MS,
658
+ maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
659
+ });
660
+ if (run.error !== undefined || run.status === null) {
661
+ return {
662
+ kind: "unrunnable",
663
+ detail: run.error === undefined
664
+ ? `the suite produced no exit status (it may have run past its ${String(DOGFOOD_SUITE_TIMEOUT_MS / 1000)}s limit)`
665
+ : run.error.message,
666
+ };
667
+ }
668
+ const output = `${run.stdout}\n${run.stderr}`;
669
+ if (run.status === 0) {
670
+ const count = tapTestCount(output);
671
+ if (count === null || count === 0) {
672
+ return {
673
+ kind: "unrunnable",
674
+ detail: count === null
675
+ ? "the suite exited 0 and reported no test count at all"
676
+ : "the suite exited 0 having run 0 tests",
677
+ };
678
+ }
679
+ return { kind: "passed" };
680
+ }
681
+ return { kind: "failed", tests: failedTapTests(output) };
682
+ }
683
+ /**
684
+ * A `dogfood-suite-failed` refusal, on both surfaces.
685
+ *
686
+ * One function for the three arms so the machine message and the runbook can
687
+ * never drift into telling an operator two different stories about the same
688
+ * outcome. Every arm ends the same way: nothing was attested.
689
+ */
690
+ function dogfoodRefusal(suite, st) {
691
+ const rerun = `node --test ${DOGFOOD_SUITE_BUILT}`;
692
+ const shared = {
693
+ state: [
694
+ "nothing was attested: the policy edit is still only a working-tree change",
695
+ "nothing was committed and nothing was pushed",
696
+ ],
697
+ footer: [
698
+ "this is the suite CI runs: failing it here costs a minute, failing it there costs a red pull request carrying an attestation",
699
+ ],
700
+ };
701
+ if (suite.kind === "not-built") {
702
+ return {
703
+ message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} is not built: there is no ${DOGFOOD_SUITE_BUILT} to run, so the pins a ceremony would check are the ones the last build compiled`,
704
+ human: runbook(st, "dogfood-suite-failed", "the dogfood suite is not built", {
705
+ ...shared,
706
+ steps: [
707
+ { command: "npm run build", note: "the ceremony runs the BUILT suite and the built pins" },
708
+ { command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
709
+ ],
710
+ }),
711
+ };
712
+ }
713
+ if (suite.kind === "unrunnable") {
714
+ return {
715
+ message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} could not be run (${suite.detail}), so nothing about the amended policy was established`,
716
+ human: runbook(st, "dogfood-suite-failed", "the dogfood suite could not be run", {
717
+ ...shared,
718
+ quote: [suite.detail],
719
+ steps: [
720
+ { command: rerun, note: "run it yourself and see what it says" },
721
+ { command: "approval policy amend --commit", note: "re-run once it runs" },
722
+ ],
723
+ }),
724
+ };
725
+ }
726
+ return {
727
+ message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} is RED against the amended policy: ${summarizeFailures(suite.tests)}`,
728
+ human: runbook(st, "dogfood-suite-failed", "the dogfood suite is red against the amended policy", {
729
+ ...shared,
730
+ state: [...shared.state, ...suite.tests.slice(0, DOGFOOD_FAILURES_SHOWN)],
731
+ steps: [
732
+ { command: rerun, note: "the same run, with the whole output" },
733
+ {
734
+ command: `$EDITOR ${EXPECTATIONS_MODULE}`,
735
+ note: "when it is the pins that have to move with this amendment",
736
+ },
737
+ { command: "npm run build", note: "the ceremony runs the BUILT suite and the built pins" },
738
+ { command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
739
+ ],
740
+ }),
741
+ };
742
+ }
743
+ /**
744
+ * The one-line summary that becomes the commit subject.
745
+ *
746
+ * The pins ride in it on the same footing as the class resolutions (APRV-274):
747
+ * they are part of the amendment's contract, they are in the commit, and a
748
+ * subject that named only the policy would describe a commit carrying more than
749
+ * it said. A pins file that moved without moving any pin (a comment, a note) is
750
+ * still named, because it is still in the commit.
751
+ */
752
+ function summarize(policyPath, diff, pins) {
753
+ const name = basename(policyPath);
754
+ const pinPart = pins === null
755
+ ? null
756
+ : pins.changes.length === 0
757
+ ? "pins file"
758
+ : `${pins.changes.length} pin(s)`;
759
+ if (diff === null) {
760
+ return `amend ${name} (semantic diff unavailable${pinPart === null ? "" : `, ${pinPart}`})`;
761
+ }
762
+ const parts = [];
763
+ if (diff.classes.length > 0)
764
+ parts.push(`${diff.classes.length} class resolution(s)`);
765
+ if (diff.approvers.length > 0)
766
+ parts.push(`${diff.approvers.length} approver change(s)`);
767
+ if (diff.defaults.length > 0)
768
+ parts.push(`${diff.defaults.length} default(s)`);
769
+ if (diff.budgets.length > 0)
770
+ parts.push(`${diff.budgets.length} limit(s)`);
771
+ if (diff.vocabulary.length > 0)
772
+ parts.push(`${diff.vocabulary.length} policy key(s)`);
773
+ if (pinPart !== null)
774
+ parts.push(pinPart);
775
+ if (parts.length === 0)
776
+ return `amend ${name} (no semantic change)`;
777
+ return `amend ${name}: ${parts.join(", ")}`;
778
+ }
779
+ /**
780
+ * Is there a channel that could carry an attestation prompt to a human?
781
+ *
782
+ * FAIL CLOSED, and this is the check that makes the agent path safe to offer at
783
+ * all. A proposal appended into a repository with no configured channel is a
784
+ * question nobody will ever be asked, and the verb would then sit through its
785
+ * whole `--wait` before reporting a timeout that was decidable at the start.
786
+ * Worse, the proposal would stay in the log looking like an outstanding ask.
787
+ *
788
+ * A policy that does not LOAD is the same answer for a stronger reason: the
789
+ * channel table is in the policy, so an unloadable policy is one whose channel
790
+ * configuration is unknown, and "unknown" resolves to the stricter path here
791
+ * exactly as it does everywhere else.
792
+ */
793
+ function channelConfigured(load) {
794
+ if (!load.ok)
795
+ return false;
796
+ const channels = load.policy.channels;
797
+ return channels !== undefined && Object.keys(channels).length > 0;
798
+ }
799
+ /**
800
+ * Ask a human to attest the prepared bytes, and wait for the answer.
801
+ *
802
+ * The whole of what APRV-109 adds to this verb. It appends a `policy.proposed`
803
+ * through `core/policy-proposal.ts` — which computes the hash, the semantic
804
+ * diff and the load advisory from the bytes, and refuses `diff-too-large`
805
+ * rather than truncating — and then polls the VERIFIED log until the proposal
806
+ * reaches a terminal state.
807
+ *
808
+ * Only one of those states continues the ceremony. `attested` returns the seq of
809
+ * the `policy.updated` the tap appended, and the caller's git half proceeds
810
+ * unchanged, citing that seq in the commit exactly as it cites a terminal
811
+ * attestation's today. `declined`, `expired`, `superseded` and a lapsed `--wait`
812
+ * all attest nothing and commit nothing: the policy edit stays in the working
813
+ * tree, as unattested as it was before the verb ran.
814
+ *
815
+ * This process never appends the attestation and never holds a human identity.
816
+ * The tap does both, in the channel listener, under the human identity that
817
+ * listener is configured with — the same identity, from the same configuration,
818
+ * that every grant already lands under (SPEC.md §11, unchanged).
819
+ */
820
+ function collectAttestation(input) {
821
+ const { streams, json, st, logPath, policyPath, cwd } = input;
822
+ if (!channelConfigured(input.liveLoad)) {
823
+ return {
824
+ ok: false,
825
+ code: "no-channel",
826
+ exitCode: EXIT_USAGE,
827
+ message: input.liveLoad.ok
828
+ ? `no channel is configured in ${basename(policyPath)}, so an attestation prompt has nowhere to go; nothing was proposed and nothing was attested. Configure a channel, or attest at a terminal with --as human:<id>`
829
+ : `the policy does not load (${input.liveLoad.code}), so which channel would carry the attestation prompt is unknown; nothing was proposed and nothing was attested. Fix the policy, or attest at a terminal with --as human:<id>`,
830
+ };
831
+ }
832
+ const waitUntil = new Date(Date.now() + input.waitMs).toISOString();
833
+ const proposed = proposeAttestation(logPath, {
834
+ policyPath,
835
+ baseline: input.baseline,
836
+ waitUntil,
837
+ ...(input.note === null ? {} : { note: input.note }),
838
+ }, input.actor);
839
+ if (!proposed.ok) {
840
+ // A gate refusal, so exit 1 rather than 4: the command was well-formed and
841
+ // the runtime said no. `diff-too-large` is the one a caller acts on — read
842
+ // the diff at a terminal — and it rides in the message with its own code.
843
+ return {
844
+ ok: false,
845
+ code: "propose-failed",
846
+ exitCode: proposed.code === "append-failed" ? EXIT_IO : EXIT_INTEGRITY,
847
+ message: `${proposed.code}: ${proposed.message}`,
848
+ };
849
+ }
850
+ const proposedSeq = proposed.record.seq;
851
+ if (!json) {
852
+ streams.out(`${st.glyph("ok")} proposed seq ${String(proposedSeq)} — an approver has been asked to attest ${relPath(policyPath, cwd)}\n`);
853
+ for (const line of st
854
+ .table([
855
+ { left: "sha256", right: shortHash(proposed.sha256) },
856
+ { left: "changes", right: proposed.diff.headline },
857
+ { left: "loads", right: proposed.load.ok ? "clean" : `NO (${proposed.load.code ?? "?"})` },
858
+ { left: "waiting until", right: waitUntil },
859
+ ])
860
+ .split("\n")) {
861
+ streams.out(` ${line}\n`);
862
+ }
863
+ streams.out("\n");
864
+ }
865
+ const deadline = Date.now() + input.waitMs;
866
+ for (;;) {
867
+ const read = readVerifiedRecords(logPath);
868
+ if (!read.ok) {
869
+ return {
870
+ ok: false,
871
+ code: read.code === "log-torn-tail" ? "log-torn-tail" : "log-unreadable",
872
+ exitCode: read.code === "log-torn-tail" ? EXIT_TORN_TAIL : EXIT_IO,
873
+ message: `${read.message}; the attestation prompt at seq ${String(proposedSeq)} is unanswered and nothing was attested`,
874
+ };
875
+ }
876
+ const derived = proposalState(read.records, proposedSeq, new Date().toISOString());
877
+ const state = derived?.state ?? "open";
878
+ if (state === "attested") {
879
+ // The tap's own record, found by the hash it names. `proposalState` proved
880
+ // one exists; this recovers its seq, which is what the commit cites.
881
+ const attestation = read.records.find((entry) => entry.seq > proposedSeq &&
882
+ entry.event === "policy.updated" &&
883
+ typeof entry.payload === "object" &&
884
+ entry.payload !== null &&
885
+ entry.payload["sha256"] === proposed.sha256);
886
+ if (attestation === undefined) {
887
+ return {
888
+ ok: false,
889
+ code: "log-unreadable",
890
+ exitCode: EXIT_IO,
891
+ message: `the attestation prompt at seq ${String(proposedSeq)} derives as attested and no policy.updated naming ${proposed.sha256} could be found; nothing was committed`,
892
+ };
893
+ }
894
+ return {
895
+ ok: true,
896
+ seq: attestation.seq,
897
+ proposedSeq,
898
+ sha256: proposed.sha256,
899
+ diff: proposed.diff,
900
+ load: proposed.load,
901
+ };
902
+ }
903
+ if (state === "declined") {
904
+ return {
905
+ ok: false,
906
+ code: "attestation-declined",
907
+ exitCode: EXIT_INTEGRITY,
908
+ message: `the approver DECLINED the attestation prompt at seq ${String(proposedSeq)}; nothing was attested and nothing was committed. The policy edit is still in the working tree, and the policy in force is the one that was in force before this ran`,
909
+ };
910
+ }
911
+ if (state === "superseded") {
912
+ return {
913
+ ok: false,
914
+ code: "attestation-timeout",
915
+ exitCode: EXIT_INTEGRITY,
916
+ message: `the attestation prompt at seq ${String(proposedSeq)} was SUPERSEDED by a later proposal for the same policy; nothing was attested here. Re-run the amendment against the bytes now on disk`,
917
+ };
918
+ }
919
+ if (state === "expired" || Date.now() >= deadline) {
920
+ return {
921
+ ok: false,
922
+ code: "attestation-timeout",
923
+ exitCode: EXIT_INTEGRITY,
924
+ message: `no answer arrived for the attestation prompt at seq ${String(proposedSeq)} before its deadline (${waitUntil}); nothing was attested and nothing was committed. The prompt retires itself: it leaves every channel queue by derivation, so no stale question is left in front of the approver`,
925
+ };
926
+ }
927
+ sleepSync(Math.min(input.intervalMs, Math.max(0, deadline - Date.now())));
928
+ }
929
+ }
930
+ /** `approval policy amend …` — the whole ceremony, in one verb. */
931
+ export function commandPolicyAmend(argv, streams, cwd) {
932
+ const json = argv.includes("--json");
933
+ // Asked BEFORE anything is printed, which is what makes `--json` an absolute
934
+ // veto on colour for this process (see `style.ts`'s header).
935
+ const st = style({ json });
936
+ const parsed = parseFlags(argv, FLAGS);
937
+ if (!parsed.ok)
938
+ return usageError(streams, json, parsed.message);
939
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
940
+ streams.out(`${POLICY_AMEND_HELP}\n`);
941
+ return EXIT_OK;
942
+ }
943
+ const extra = parsed.positionals[0];
944
+ if (extra !== undefined) {
945
+ return usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`);
946
+ }
947
+ const dryRun = boolFlag(parsed.flags, "--dry-run");
948
+ const requireLoad = boolFlag(parsed.flags, "--require-load");
949
+ const wantCommit = boolFlag(parsed.flags, "--commit");
950
+ // APRV-130: the ceremony publishes by default (push, and on a protected main
951
+ // branch + push + PR). `--no-publish` is the operator who wants it to stop at
952
+ // the commit, which is what `--commit` did before the publishing half existed.
953
+ const noPublish = boolFlag(parsed.flags, "--no-publish");
954
+ const assumeYes = boolFlag(parsed.flags, "--yes");
955
+ const branchFlag = stringFlag(parsed.flags, "--branch");
956
+ const forceDirect = boolFlag(parsed.flags, "--direct");
957
+ if (branchFlag !== null && forceDirect) {
958
+ return usageError(streams, json, "--branch and --direct ask for opposite ceremonies; pass one of them, or neither and let the protection probe decide");
959
+ }
960
+ if (branchFlag !== null && branchFlag.trim().length === 0) {
961
+ return usageError(streams, json, "--branch expects a branch name");
962
+ }
963
+ // Identity first, before a byte is read. Asking a human to read a diff and
964
+ // only then telling them their sign-off cannot be attributed wastes the one
965
+ // resource this system spends.
966
+ //
967
+ // APRV-109 widens WHO may run the verb without widening who may attest. Under
968
+ // a human identity everything below is byte-for-byte what it was: the diff,
969
+ // the advisory, the terminal confirmation, `appendAttestation`. Under an
970
+ // AGENT identity the same preparation runs and then stops at the one act an
971
+ // agent must not perform — instead of attesting, it appends a `policy.proposed`
972
+ // and waits for a human's tap to append the attestation under the human
973
+ // identity the channel listener holds. The agent never holds that identity and
974
+ // never writes a `policy.updated`; `core/policy-proposal.ts` refuses it in
975
+ // code and `schema/event.schema.json` refuses it at the write boundary.
976
+ const asFlag = stringFlag(parsed.flags, "--as");
977
+ const agentActor = asFlag !== null && AGENT_ACTOR.test(asFlag) ? asFlag : null;
978
+ const actor = agentActor ?? resolveHumanActor(asFlag === null ? {} : { actor: asFlag });
979
+ if (actor === null) {
980
+ return usageError(streams, json, asFlag === null
981
+ ? `no identity: set ${HUMAN_ACTOR_ENV}=human:<id>, or pass --as human:<id> to attest here or --as agent:<id> to ask an approver to attest through a channel`
982
+ : `--as expects human:<id> or agent:<id>, got ${JSON.stringify(asFlag)}; an amendment is attested, and under an agent identity the attestation is collected as a tap rather than performed here`);
983
+ }
984
+ // APRV-109. The wait knobs are refused outright under a human identity rather
985
+ // than quietly ignored: an operator who passed `--wait` believes they asked
986
+ // for the channel ceremony, and a verb that attested on the spot instead would
987
+ // be answering a question they did not ask.
988
+ const waitText = stringFlag(parsed.flags, "--wait");
989
+ const intervalText = stringFlag(parsed.flags, "--interval");
990
+ const proposalNote = stringFlag(parsed.flags, "--note");
991
+ if (agentActor === null && (waitText !== null || intervalText !== null)) {
992
+ return usageError(streams, json, "--wait and --interval belong to the channel ceremony, which runs under --as agent:<id>; under a human identity the amendment is attested here and there is no tap to wait for");
993
+ }
994
+ const waitMs = waitText === null ? DEFAULT_ATTESTATION_WAIT_MS : parseDuration(waitText);
995
+ if (waitMs === null) {
996
+ return usageError(streams, json, `--wait expects a duration like 30s, 10m, 6h, got ${JSON.stringify(waitText)}`);
997
+ }
998
+ const intervalMs = intervalText === null ? DEFAULT_ATTESTATION_INTERVAL_MS : parseDuration(intervalText);
999
+ if (intervalMs === null) {
1000
+ return usageError(streams, json, `--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
1001
+ }
1002
+ const dirFlag = stringFlag(parsed.flags, "--dir");
1003
+ const dir = dirFlag === null ? cwd : absolute(dirFlag, cwd);
1004
+ const policy = resolvePolicyPath(stringFlag(parsed.flags, "--policy"), dir, cwd);
1005
+ if (!policy.ok)
1006
+ return refuse(streams, json, "io", policy.message, EXIT_IO);
1007
+ const policyPath = policy.path;
1008
+ let liveSha256;
1009
+ try {
1010
+ liveSha256 = policyFileHash(policyPath);
1011
+ }
1012
+ catch (cause) {
1013
+ return refuse(streams, json, "io", `policy ${policyPath} could not be read: ${detail(cause)}`, EXIT_IO);
1014
+ }
1015
+ const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, cwd);
1016
+ // (a-pre) The thirty-three seconds of silence, ended (APRV-167).
1017
+ //
1018
+ // Everything from here to the `Policy` block below is work the operator could
1019
+ // not see: a full chain re-verification, then a baseline recovery that shells
1020
+ // out to git. The verb said nothing until all of it was done, which read as a
1021
+ // hang — one ceremony was abandoned mid-run over it and left this repository's
1022
+ // gate fail-closed for every agent session until the next attempt.
1023
+ //
1024
+ // SILENT UNDER `--json`, and this is not a stylistic choice. This verb's
1025
+ // machine surface is not stdout alone: a refusal under `--json` emits its
1026
+ // error OBJECT on stderr (see `refuse`), and every caller parses that stream
1027
+ // whole. Narration mixed into it would be a parse error in every machine
1028
+ // consumer of a refusal — the progress meter would have broken the thing it
1029
+ // was added beside. A human is the only reader who benefits from these lines,
1030
+ // and `--json` is exactly the flag that says there is no human.
1031
+ const progress = json ? silentProgress : createProgress(streams);
1032
+ progress.phase("verifying the log chain before anything is read from it");
1033
+ const read = readVerifiedRecords(logPath, {
1034
+ onProgress: ({ done, total }) => {
1035
+ progress.step(done, total);
1036
+ },
1037
+ });
1038
+ progress.done();
1039
+ if (!read.ok) {
1040
+ const exitCode = read.code === "log-torn-tail"
1041
+ ? EXIT_TORN_TAIL
1042
+ : read.code === "log-corrupt"
1043
+ ? EXIT_INTEGRITY
1044
+ : EXIT_IO;
1045
+ return refuse(streams, json, read.code, read.message, exitCode);
1046
+ }
1047
+ const status = checkAttestation(read.records, policyPath);
1048
+ const attested = status.status === "attested"
1049
+ ? { sha256: status.sha256, seq: status.seq }
1050
+ : status.status === "hash-mismatch"
1051
+ ? { sha256: status.attestedSha256, seq: status.seq }
1052
+ : null;
1053
+ // (a) Nothing to amend. A no-op ceremony is a SUCCESS, not an error: an
1054
+ // operator (or a script) that runs `amend` on an already-attested policy has
1055
+ // established exactly what they wanted to establish.
1056
+ if (status.status === "attested") {
1057
+ if (json) {
1058
+ emitReport(streams, {
1059
+ policyPath,
1060
+ liveSha256,
1061
+ attested,
1062
+ baseline: { mode: "unavailable", reason: "the live policy already matches its attestation" },
1063
+ diff: null,
1064
+ load: null,
1065
+ attestation: null,
1066
+ git: null,
1067
+ noop: true,
1068
+ dryRun,
1069
+ aborted: false,
1070
+ });
1071
+ }
1072
+ else {
1073
+ streams.out(`nothing to amend: ${relPath(policyPath, cwd)} already matches its attestation at seq ${status.seq} (sha256 ${shortHash(liveSha256)})\n`);
1074
+ }
1075
+ return EXIT_OK;
1076
+ }
1077
+ // (b) The baseline, and only a verifiable one. See the module header.
1078
+ progress.phase("recovering the attested baseline and diffing it against the live policy");
1079
+ const recovered = recoverBaseline(policyPath, attested?.sha256 ?? null);
1080
+ const liveLoad = loadPolicy({ file: policyPath });
1081
+ const diff = recovered.load === null
1082
+ ? null
1083
+ : diffPolicies(recovered.load, liveLoad, SPEC_NAMESPACES);
1084
+ if (recovered.scratch !== null)
1085
+ rmSync(recovered.scratch, { recursive: true, force: true });
1086
+ // Closed here, so the report below starts on a line of its own on a terminal
1087
+ // and after the last phase line everywhere else.
1088
+ progress.done();
1089
+ // (e-pre) Which ceremony this is: the direct one (commit on the branch you
1090
+ // are standing on and push it) or the branch one (branch, commit, push, PR).
1091
+ //
1092
+ // PRECEDENCE, stated once and documented in the help: --branch <name> forces
1093
+ // the branch flow and names the branch; --direct forces the direct flow; the
1094
+ // two together are a usage error. With neither, the protection probe decides,
1095
+ // and it chooses the branch flow only when the default branch is protected
1096
+ // AND that default branch is the one currently checked out. An `unknown`
1097
+ // probe (no gh, no GitHub remote, no network) is the direct flow, which is
1098
+ // what this verb did before protection was detected at all.
1099
+ const amendRoot = repoRoot(dirname(policyPath));
1100
+ const probe = amendRoot === null
1101
+ ? {
1102
+ protection: "unknown",
1103
+ defaultBranch: null,
1104
+ currentBranch: null,
1105
+ reason: "the policy file is not inside a git repository",
1106
+ }
1107
+ : probeProtection(amendRoot);
1108
+ const onProtectedDefault = probe.protection === "protected" &&
1109
+ probe.currentBranch !== null &&
1110
+ probe.currentBranch === probe.defaultBranch;
1111
+ const useBranch = branchFlag !== null || (!forceDirect && onProtectedDefault);
1112
+ const branchName = (seq) => branchFlag ?? `policy-amend-${seq}`;
1113
+ // The direct flow's push is about to hit a protected branch. Say so before
1114
+ // the human types it, rather than after GitHub says it.
1115
+ const pushWarning = !useBranch && onProtectedDefault
1116
+ ? `${probe.defaultBranch ?? "the default branch"} is protected: this push will be rejected; use --branch`
1117
+ : null;
1118
+ // --commit's preconditions are checked BEFORE anything is written. Refusing
1119
+ // after the attestation would recreate the very interregnum this verb exists
1120
+ // to close: an attested policy with no commit carrying it.
1121
+ let commitPlan = null;
1122
+ /**
1123
+ * The remote tip this ceremony's commit will be parented on (APRV-203).
1124
+ *
1125
+ * Captured HERE, before the attestation, and used unchanged afterwards: a
1126
+ * ceremony that re-read the remote after the tap could build its commit on a
1127
+ * base nobody checked.
1128
+ */
1129
+ let commitBase = null;
1130
+ /**
1131
+ * The pins file's part in this ceremony (APRV-274), settled before the
1132
+ * attestation like everything else the commit depends on.
1133
+ */
1134
+ let pinsChange = null;
1135
+ if (wantCommit && !dryRun) {
1136
+ const plan = planCommit(policyPath, logPath, useBranch ? { branch: branchFlag } : null);
1137
+ if (!plan.ok) {
1138
+ return refuse(streams, json, "commit-preconditions", plan.message, EXIT_USAGE);
1139
+ }
1140
+ commitPlan = plan.plan;
1141
+ const prepared = prepareBase({ root: commitPlan.root, policyArg: commitPlan.policyArg, logPath, progress }, probe, attested?.sha256 ?? null);
1142
+ if (!prepared.ok) {
1143
+ return refuse(streams, json, prepared.code, prepared.message, EXIT_IO, prepared.human);
1144
+ }
1145
+ commitBase = prepared.base;
1146
+ // APRV-274: the pins are read against the commit this amendment is BUILT
1147
+ // on, so a pins file that moved rides in the amendment commit and one that
1148
+ // did not is left exactly as the base carries it.
1149
+ pinsChange = pinsChangeIn(commitPlan.root, policyPath, commitBase.sha);
1150
+ // The dogfood pins, run against the AMENDED file before anything is
1151
+ // attested or pushed (APRV-203). A policy edit whose pins nobody updated
1152
+ // used to be found by CI, hours later, on a pull request that was already
1153
+ // open and already carrying an attestation.
1154
+ const expectations = expectationsFor(policyPath);
1155
+ if (expectations !== null) {
1156
+ progress.phase(`running the policy suite against the amended file (${String(expectations.length)} pinned resolutions)`);
1157
+ const checked = checkPolicyExpectations(liveLoad, expectations);
1158
+ progress.done();
1159
+ if (!checked.ok) {
1160
+ // APRV-296: every failure here is a SAFETY class that moved — the pins
1161
+ // name only classes whose loosening is a regression — so the remedy is
1162
+ // a decision about the policy, not a line to paste. The refusal that
1163
+ // printed a pin line went with the `unpinned` failure kind: a declared
1164
+ // class nothing pins is accepted now, and a policy declaring a new
1165
+ // supervised or autonomous class is no longer also a code change.
1166
+ return refuse(streams, json, "policy-suite-failed", `the amended policy does not match its pins: ${checked.failures
1167
+ .map(describeFailure)
1168
+ .join("; ")}. Nothing was attested, committed or pushed`, EXIT_USAGE, runbook(st, "policy-suite-failed", "the amended policy does not match its pins", {
1169
+ state: [
1170
+ "nothing was attested: the policy edit is still only a working-tree change",
1171
+ "nothing was committed and nothing was pushed",
1172
+ ...checked.failures.map(describeFailure),
1173
+ ],
1174
+ steps: [
1175
+ {
1176
+ command: `$EDITOR APPROVAL.md`,
1177
+ note: "a pinned class moved: the pin says loosening it is a regression, so decide the policy first",
1178
+ },
1179
+ {
1180
+ command: `$EDITOR ${EXPECTATIONS_MODULE}`,
1181
+ note: "or move the pin, when the amendment means what it says and the pin is what is stale",
1182
+ },
1183
+ { command: "npm run build", note: "the ceremony reads the compiled pins" },
1184
+ { command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
1185
+ ],
1186
+ footer: [
1187
+ "each pin's note says what loosening that class would cost; the refusal above prints it beside the class",
1188
+ "this is the check CI runs: failing it here costs a minute, failing it there costs a red pull request carrying an attestation",
1189
+ ],
1190
+ }));
1191
+ }
1192
+ // APRV-274: and then the whole suite, which is more than the pins. The
1193
+ // seq 23351 ceremony passed the pin check and went red on CI over a
1194
+ // dogfood test about the values block, a test the pin check does not run
1195
+ // and could not have run. Reading `APPROVAL.md` off disk and the pins out
1196
+ // of `dist/`, the built suite asks the ceremony's own question of the
1197
+ // ceremony's own bytes.
1198
+ progress.phase(`running the dogfood suite against the amended file (${DOGFOOD_SUITE_SOURCE})`);
1199
+ const suite = runDogfoodSuite(commitPlan.root);
1200
+ progress.done();
1201
+ if (suite.kind !== "passed" && suite.kind !== "skipped") {
1202
+ const refusal = dogfoodRefusal(suite, st);
1203
+ return refuse(streams, json, "dogfood-suite-failed", `${refusal.message}. Nothing was attested, committed or pushed`, EXIT_USAGE, refusal.human, suite.kind === "failed" ? { dogfood: { suite: DOGFOOD_SUITE_SOURCE, failed: suite.tests } } : undefined);
1204
+ }
1205
+ }
1206
+ }
1207
+ // The report-only and `--dry-run` paths build no commit, so they have no base
1208
+ // to read the pins against; HEAD stands in for one, which is what the printed
1209
+ // `git add` would be run against by hand anyway (APRV-274).
1210
+ if (commitPlan === null && amendRoot !== null) {
1211
+ pinsChange = pinsChangeIn(amendRoot, policyPath, "HEAD");
1212
+ }
1213
+ /**
1214
+ * The same command, with the two long absolute paths written the way the
1215
+ * operator would type them.
1216
+ *
1217
+ * This is a HUMAN transform and nothing else: `--json`'s `git.commands` keeps
1218
+ * the absolute forms, because a machine reading that array has no cwd to
1219
+ * resolve against. `git` itself resolves a relative pathspec against the
1220
+ * process's cwd, so the printed line is still the line that works.
1221
+ */
1222
+ const humanCommand = (command) => {
1223
+ const shortened = command
1224
+ .split(policyPath)
1225
+ .join(relPath(policyPath, cwd))
1226
+ .split(logPath)
1227
+ .join(relPath(logPath, cwd));
1228
+ return pinsChange === null
1229
+ ? shortened
1230
+ : shortened.split(pinsChange.path).join(relPath(pinsChange.path, cwd));
1231
+ };
1232
+ /** A `Label` heading with its body indented under it, then a blank line. */
1233
+ const section = (label, body) => {
1234
+ if (body.length === 0)
1235
+ return;
1236
+ streams.out(`${st.heading(label)}\n`);
1237
+ for (const entry of body) {
1238
+ for (const line of entry.split("\n"))
1239
+ streams.out(line === "" ? "\n" : ` ${line}\n`);
1240
+ }
1241
+ streams.out("\n");
1242
+ };
1243
+ const summary = summarize(policyPath, diff, pinsChange);
1244
+ // APRV-274: the pins file is a member of the `git add` exactly when it moved,
1245
+ // so a copied command lands the same three (or two) files the verb would.
1246
+ const ceremonyFiles = [policyPath, logPath, ...(pinsChange === null ? [] : [pinsChange.path])];
1247
+ const commitCommands = (seq) => [
1248
+ `git add ${ceremonyFiles.join(" ")}`,
1249
+ `git commit -m ${JSON.stringify(`Policy: ${summary} (attested seq ${seq})`)}`,
1250
+ ];
1251
+ const gitCommands = (seq) => {
1252
+ // APRV-203: `--commit` runs none of these; it assembles the commit on the
1253
+ // remote's tip without a checkout. These are the HAND procedure, and they
1254
+ // start where `--commit` starts: at the remote, so the branch is not built
1255
+ // on a local trunk that has fallen behind.
1256
+ if (useBranch) {
1257
+ return [
1258
+ "git fetch origin",
1259
+ `git checkout -b ${branchName(seq)} origin/${probe.defaultBranch ?? "main"}`,
1260
+ ...commitCommands(seq),
1261
+ `git push -u origin ${branchName(seq)}`,
1262
+ `gh pr create --title ${JSON.stringify(prTitle(summary, seq))} --body ${JSON.stringify(prBody(seq))}`,
1263
+ ];
1264
+ }
1265
+ return amendRoot === null
1266
+ ? commitCommands(seq)
1267
+ : [...commitCommands(seq), `git push origin ${probe.currentBranch ?? "HEAD"}`];
1268
+ };
1269
+ /**
1270
+ * The paragraph a first-time operator reads: two sentences of why, then the
1271
+ * commands for their situation. It is printed whenever the verb did not run
1272
+ * the commands itself.
1273
+ */
1274
+ const whyOneCommit = () => {
1275
+ const lines = [
1276
+ st.muted("The policy bytes and the attestation that names their hash have to land in the same commit."),
1277
+ st.muted("If they land separately, then for as long as the gap lasts the branch carries a policy no attestation covers, and every gate operation refuses until the second commit arrives."),
1278
+ "",
1279
+ ];
1280
+ if (useBranch) {
1281
+ lines.push(`${probe.protection === "protected" ? `${probe.defaultBranch ?? "the default branch"} is protected, so the commit goes onto a branch and reaches main through a pull request` : "This amendment goes onto a branch and reaches main through a pull request"}. Run these, in order:`);
1282
+ }
1283
+ else {
1284
+ if (pushWarning !== null)
1285
+ lines.push(`${st.fail("WARNING:")} ${pushWarning}`);
1286
+ lines.push("Run these, in order:");
1287
+ }
1288
+ return lines;
1289
+ };
1290
+ // (c) + (d): the report. Human output only; --json emits one object at the end.
1291
+ if (!json) {
1292
+ // `Policy` / `Changes` / `Load`, with the changed resolutions as the visual
1293
+ // centre (APRV-93). The two 64-hex digests that made the old first screen
1294
+ // unreadable are twelve characters each here; the full values are one
1295
+ // `--json` away, and that is the copy a machine should be comparing anyway.
1296
+ const identity = [
1297
+ { left: "file", right: relPath(policyPath, cwd) },
1298
+ { left: "live", right: shortHash(liveSha256) },
1299
+ {
1300
+ left: "attested",
1301
+ right: attested === null
1302
+ ? "never"
1303
+ : `${shortHash(attested.sha256)} ${st.muted(`(seq ${attested.seq})`)}`,
1304
+ },
1305
+ ];
1306
+ section("Policy", [st.table(identity)]);
1307
+ // APRV-274: the pin deltas print BESIDE the class deltas, in the same
1308
+ // section, because they are one amendment. A pin that moved changes what CI
1309
+ // asserts about the policy in this very commit, and a reader deciding
1310
+ // whether to sign has to see both halves at once.
1311
+ const pinSection = pinsChange === null
1312
+ ? []
1313
+ : [
1314
+ "",
1315
+ `${st.key("pins")} ${EXPECTATIONS_MODULE} moves with this amendment:`,
1316
+ ...(pinsChange.changes.length === 0
1317
+ ? [st.muted(" no pinned resolution moved (a note or a comment did)")]
1318
+ : pinsChange.changes.map((change) => ` ${describePinChange(change)}`)),
1319
+ ];
1320
+ section("Changes", [
1321
+ ...(diff === null
1322
+ ? [
1323
+ `${st.warn("HASH-ONLY MODE:")} no semantic diff. ${recovered.baseline.reason ?? ""}`,
1324
+ st.muted("The load advisory below and the attestation still apply; what changed in MEANING is not shown, so read the file diff yourself."),
1325
+ ]
1326
+ : renderDiff(diff)),
1327
+ ...pinSection,
1328
+ ]);
1329
+ section("Load", liveLoad.ok
1330
+ ? [`${st.glyph("ok")} loads clean`]
1331
+ : [
1332
+ // APRV-102: glyph, CODE, message — the order every other refusal in
1333
+ // this CLI uses. `DOES NOT LOAD` sat where the machine-readable code
1334
+ // belongs and pushed the code into a parenthesis, so the one token a
1335
+ // reader greps for was the one thing not in the scannable column.
1336
+ renderRefusal(st, liveLoad.code, `the policy does not load: ${liveLoad.message}`),
1337
+ st.muted("Attesting it is allowed (attestation records bytes, not correctness) but it will FAIL CLOSED to all-manual for every class. This is the shape of the seq 2 incident."),
1338
+ ]);
1339
+ }
1340
+ // (d) --require-load: refuse before the confirmation and before the append.
1341
+ if (!liveLoad.ok && requireLoad) {
1342
+ return refuse(streams, json, "load-failed", `--require-load: the policy does not load (${liveLoad.code}): ${liveLoad.message}; nothing was attested and the log is unchanged`, EXIT_INTEGRITY);
1343
+ }
1344
+ /**
1345
+ * The `pins` sub-object of the JSON report (APRV-274), or `null` when the
1346
+ * pins are no part of this ceremony.
1347
+ *
1348
+ * `null` is the answer for all three of its reasons: these pins do not govern
1349
+ * this policy, there is no pins module, the file is what the base carries. A
1350
+ * machine caller that wants to know WHICH reason reads `git.commands`, where
1351
+ * the `git add` names the files the commit carries.
1352
+ */
1353
+ const pinsReport = () => pinsChange === null ? null : { module: EXPECTATIONS_MODULE, changes: pinsChange.changes };
1354
+ /** The `git` sub-object of the JSON report. Every key is always present. */
1355
+ const gitReport = (over) => ({
1356
+ repo: amendRoot !== null,
1357
+ protection: probe.protection,
1358
+ protectionReason: probe.reason,
1359
+ defaultBranch: probe.defaultBranch,
1360
+ currentBranch: probe.currentBranch,
1361
+ flow: useBranch ? "branch" : "direct",
1362
+ warning: pushWarning,
1363
+ ...over,
1364
+ });
1365
+ // (e) Confirmation. --dry-run never asks, because it never writes.
1366
+ if (dryRun) {
1367
+ if (json) {
1368
+ emitReport(streams, {
1369
+ policyPath,
1370
+ liveSha256,
1371
+ attested,
1372
+ baseline: recovered.baseline,
1373
+ diff,
1374
+ load: loadSummary(liveLoad),
1375
+ attestation: null,
1376
+ git: gitReport({
1377
+ commands: gitCommands("<seq>"),
1378
+ committed: false,
1379
+ pushed: false,
1380
+ prUrl: null,
1381
+ output: null,
1382
+ branch: useBranch ? branchName("<seq>") : null,
1383
+ }),
1384
+ noop: false,
1385
+ dryRun: true,
1386
+ aborted: false,
1387
+ pins: pinsReport(),
1388
+ });
1389
+ }
1390
+ else {
1391
+ section("Would run", [
1392
+ `${st.warn("--dry-run:")} nothing was attested, nothing was written. The ceremony would run:`,
1393
+ "",
1394
+ ...whyOneCommit(),
1395
+ "",
1396
+ ...gitCommands("<seq>").map((command) => ` ${st.value(humanCommand(command))}`),
1397
+ ...(useBranch
1398
+ ? [
1399
+ "",
1400
+ "Merge that pull request with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.",
1401
+ ]
1402
+ : []),
1403
+ ]);
1404
+ }
1405
+ return EXIT_OK;
1406
+ }
1407
+ // (e2) The terminal confirmation belongs to the human path only. Under an
1408
+ // agent identity the confirmation IS the tap, and asking this process's stdin
1409
+ // for one would be asking the party under oversight to confirm its own
1410
+ // amendment.
1411
+ if (agentActor === null && !assumeYes) {
1412
+ if (json || process.stdin.isTTY !== true) {
1413
+ return usageError(streams, json, "amend needs a confirmation it cannot ask for: stdin is not a terminal (or --json was given). Re-run with --yes to confirm non-interactively, or --dry-run to see the report without writing anything");
1414
+ }
1415
+ streams.out(`\nattest these bytes and record the amendment? [y/N] `);
1416
+ const answer = (readLineFromStdin() ?? "").trim().toLowerCase();
1417
+ if (answer !== "y" && answer !== "yes") {
1418
+ streams.out("aborted: nothing was attested and nothing was written\n");
1419
+ return EXIT_OK;
1420
+ }
1421
+ }
1422
+ // (f) The attestation itself. One of two doors onto the same act: the human
1423
+ // path performs it here, the agent path asks for it and waits.
1424
+ let seq;
1425
+ let collected = null;
1426
+ if (agentActor === null) {
1427
+ const result = appendAttestation(logPath, policyPath, actor);
1428
+ if (!result.ok) {
1429
+ const exitCode = result.error.code === "corrupt-tail" ? EXIT_TORN_TAIL : EXIT_IO;
1430
+ return refuse(streams, json, "append-failed", result.error.message, exitCode);
1431
+ }
1432
+ seq = result.record.seq;
1433
+ }
1434
+ else {
1435
+ const asked = collectAttestation({
1436
+ streams,
1437
+ json,
1438
+ st,
1439
+ logPath,
1440
+ policyPath,
1441
+ actor,
1442
+ baseline: recovered.bytes,
1443
+ note: proposalNote,
1444
+ liveLoad,
1445
+ waitMs,
1446
+ intervalMs,
1447
+ cwd,
1448
+ });
1449
+ if (!asked.ok) {
1450
+ return refuse(streams, json, asked.code, asked.message, asked.exitCode, asked.human);
1451
+ }
1452
+ collected = asked;
1453
+ seq = asked.seq;
1454
+ }
1455
+ // (f2) SUCCESS FIRST (APRV-130).
1456
+ //
1457
+ // The incident: a re-tighten ceremony attested correctly — the one act only a
1458
+ // human can perform, done — and the terminal opened with the word REJECTED,
1459
+ // because the convenience push that follows had been refused by branch
1460
+ // protection. The reader was told their signature had failed when what had
1461
+ // failed was a `git push`.
1462
+ //
1463
+ // So the achievement is printed HERE, the moment it is true, before a single
1464
+ // git command runs. Everything after it is logistics: it can fail, it is
1465
+ // reported where it fails, and it prints beneath a line that already says the
1466
+ // policy is operative. A failure word may headline a SUB-STEP; it may never
1467
+ // headline a ceremony whose attestation landed.
1468
+ if (!json) {
1469
+ streams.out(`${st.glyph("ok")} attested seq ${String(seq)} — the policy is operative\n`);
1470
+ for (const line of st
1471
+ .table([
1472
+ { left: "file", right: relPath(policyPath, cwd) },
1473
+ { left: "sha256", right: shortHash(liveSha256) },
1474
+ // APRV-109: on the agent path the attestation was collected as a tap, so
1475
+ // the prompt it answered is named here. The seq the commit cites is the
1476
+ // ATTESTATION's, as it has always been.
1477
+ ...(collected === null
1478
+ ? []
1479
+ : [{ left: "attested by tap on", right: `policy.proposed seq ${String(collected.proposedSeq)}` }]),
1480
+ ])
1481
+ .split("\n")) {
1482
+ streams.out(` ${line}\n`);
1483
+ }
1484
+ streams.out("\n");
1485
+ }
1486
+ // (g) The git ceremony: the two files, together, or the commands to do it.
1487
+ const commands = gitCommands(String(seq));
1488
+ const branch = useBranch ? branchName(String(seq)) : null;
1489
+ let committed = false;
1490
+ let pushed = false;
1491
+ let prUrl = null;
1492
+ let output = null;
1493
+ /**
1494
+ * What the publishing half did, for the machine and for the terminal
1495
+ * (APRV-130).
1496
+ *
1497
+ * `attested` is deliberately NOT a key of this object and not a rename of the
1498
+ * report's existing top-level `attested` (which is, and stays, the PREVIOUS
1499
+ * attestation this amendment moved from). The new boolean lives on
1500
+ * `ceremony`, so both facts keep their names.
1501
+ */
1502
+ const publishing = {
1503
+ attempted: false,
1504
+ complete: false,
1505
+ via: "none",
1506
+ branch: null,
1507
+ pushed: false,
1508
+ prUrl: null,
1509
+ autoMerge: "not-attempted",
1510
+ steps: [],
1511
+ stoppedAt: null,
1512
+ reason: null,
1513
+ };
1514
+ /** The `Publishing` section, accumulated as the steps run and flushed once. */
1515
+ const publishLines = [];
1516
+ let publishFlushed = false;
1517
+ const note = (line) => {
1518
+ if (!json)
1519
+ publishLines.push(line);
1520
+ };
1521
+ const quoteUnder = (run) => {
1522
+ for (const line of commandOutputLines(run.stderr, run.stdout))
1523
+ note(` ${st.muted(line)}`);
1524
+ };
1525
+ const flushPublishing = () => {
1526
+ if (json || publishFlushed || publishLines.length === 0)
1527
+ return;
1528
+ publishFlushed = true;
1529
+ section("Publishing", publishLines);
1530
+ };
1531
+ /** The additive `--json` half of the same, for every exit after the append. */
1532
+ const ceremonyJson = () => ({
1533
+ ceremony: { attested: true, seq },
1534
+ publishing,
1535
+ });
1536
+ /**
1537
+ * A `git-failed` refusal, as a runbook (APRV-129).
1538
+ *
1539
+ * The machine message is unchanged. What the terminal gets instead is the
1540
+ * state (the attestation happened; the commit did not) and the commands that
1541
+ * are STILL OWED, one per line — which is what "run the printed commands by
1542
+ * hand" was asking for without ever printing them next to the failure.
1543
+ */
1544
+ const gitFailed = (what, failure, message, remaining) => refuse(streams, json, "git-failed", message, EXIT_IO, runbook(st, "git-failed", `\`${what}\` failed; the attestation is already appended`, {
1545
+ quote: commandOutputLines(failure),
1546
+ state: [
1547
+ `attestation appended at seq ${seq}: it is in the log, on disk`,
1548
+ "NOT committed: the policy edit and its attestation are working-tree changes",
1549
+ "NOT on origin: origin still carries the previous policy",
1550
+ ],
1551
+ steps: remaining.map((command) => ({ command: humanCommand(command) })),
1552
+ footer: [
1553
+ "these two files land in ONE commit: a main carrying the policy without its attestation refuses every gate operation",
1554
+ ],
1555
+ }), ceremonyJson());
1556
+ /**
1557
+ * The publishing half stopped at one of its own steps (APRV-130).
1558
+ *
1559
+ * `owed` is the WHOLE recovery, in order, and `index` the step that failed:
1560
+ * the runbook is rendered from there, so the reader is never handed a command
1561
+ * this verb already ran successfully. That slice is the entire relationship
1562
+ * between the automatic path and the APRV-129 runbook — the runbook is what
1563
+ * automation degrades INTO, at the exact point it ran out.
1564
+ */
1565
+ const stalled = (code, headline, failure, message, state, owed, index, footer) => {
1566
+ note(`${st.glyph("fail")} ${headline}`);
1567
+ quoteUnder(failure);
1568
+ note(st.muted("the automatic path stopped here; what is still owed is printed below"));
1569
+ publishing.stoppedAt = owed[index]?.command ?? null;
1570
+ publishing.reason = commandOutputLines(failure.stderr, failure.stdout)[0] ?? null;
1571
+ flushPublishing();
1572
+ return refuse(streams, json, code, message, EXIT_IO, runbook(st, code, headline, {
1573
+ quote: commandOutputLines(failure.stderr, failure.stdout),
1574
+ state,
1575
+ steps: owed.slice(index),
1576
+ footer,
1577
+ }), ceremonyJson());
1578
+ };
1579
+ /**
1580
+ * `gh pr merge --auto`, which is allowed to say no.
1581
+ *
1582
+ * A merge queue, a repository with auto-merge disabled, a PR that is already
1583
+ * mergeable: `--auto` refuses all three, and none of them is a failure of the
1584
+ * ceremony. The pull request is open either way, so a refusal reports the PR
1585
+ * and stops — still inside the success framing.
1586
+ */
1587
+ const armAutoMerge = (root, target) => {
1588
+ const merge = gh(["pr", "merge", target, "--merge", "--auto"], root);
1589
+ publishing.steps.push({ command: `gh pr merge ${target} --merge --auto`, ok: merge.ok });
1590
+ if (merge.ok) {
1591
+ publishing.autoMerge = "armed";
1592
+ note(`${st.glyph("ok")} auto-merge armed: ${prLabel(publishing.prUrl)} lands on ${probe.defaultBranch ?? "the default branch"} as a merge commit when CI is green`);
1593
+ return;
1594
+ }
1595
+ publishing.autoMerge = "refused";
1596
+ const why = commandOutputLines(merge.stderr, merge.stdout)[0];
1597
+ note(`${prLabel(publishing.prUrl)} is open — merge it with a MERGE COMMIT when CI is green. auto-merge was not armed${why === undefined ? "" : `: ${why}`}`);
1598
+ };
1599
+ if (commitPlan !== null && commitBase !== null) {
1600
+ // APRV-203. The commit is ASSEMBLED, never checked out: a scratch index is
1601
+ // filled from the remote's tree, the two ceremony files are laid over it
1602
+ // from the working tree, and the result is parented on the remote. HEAD does
1603
+ // not move, the operator's index is not touched, and the working tree ends
1604
+ // the ceremony carrying exactly the policy edit it started with.
1605
+ const message = `Policy: ${summary} (attested seq ${seq})`;
1606
+ /** `origin/main abc123`, or `HEAD abc123` where there is no remote. */
1607
+ const baseLabel = `${commitBase.remote === null ? "HEAD" : `${commitBase.remote}/${commitBase.branch}`} ${commitBase.sha.slice(0, 12)}`;
1608
+ progress.phase(`building the amendment commit on ${baseLabel} (nothing is checked out)`);
1609
+ const built = commitOnBase(commitPlan.root, {
1610
+ base: commitBase.sha,
1611
+ // APRV-274: the pins join the tree exactly when they moved away from the
1612
+ // base. Where they did not, the base's own copy stands, which is what
1613
+ // keeps this ceremony from reverting a pins edit somebody else landed.
1614
+ paths: [
1615
+ commitPlan.policyArg,
1616
+ commitPlan.logArg,
1617
+ ...(pinsChange === null ? [] : [pinsChange.arg]),
1618
+ ],
1619
+ message,
1620
+ });
1621
+ progress.done();
1622
+ if (!built.ok) {
1623
+ return gitFailed(built.step, built.quote.join(" | "), `the attestation was appended at seq ${seq}, but ${built.message}; the checkout is untouched, so run the printed commands by hand`, commands);
1624
+ }
1625
+ if (built.unchanged) {
1626
+ return gitFailed("git write-tree", `${baseLabel} already carries these exact bytes`, `the attestation was appended at seq ${seq}, but the amendment tree is identical to ${baseLabel}: there is nothing to commit`, commands);
1627
+ }
1628
+ const commitSha = built.sha;
1629
+ /**
1630
+ * The direct flow, when this checkout was already sitting on the base.
1631
+ *
1632
+ * Moving the branch ref here is exactly what `git commit` would have done:
1633
+ * the tree of the new commit is the base tree plus the two files the working
1634
+ * tree already carries, so the status afterwards is clean. It is done ONLY
1635
+ * when HEAD is the base — a checkout that had fallen behind is left where it
1636
+ * is, because moving it would rewrite the working tree, and rewriting the
1637
+ * working tree around a live log is the whole thing this verb never does.
1638
+ */
1639
+ const headSha = git(["rev-parse", "HEAD"], commitPlan.root).stdout.trim();
1640
+ const inPlace = branch === null &&
1641
+ probe.currentBranch !== null &&
1642
+ headSha === commitBase.sha &&
1643
+ git(["update-ref", `refs/heads/${probe.currentBranch}`, commitSha, headSha], commitPlan.root).ok;
1644
+ if (inPlace) {
1645
+ // The index has to follow the ref, or every one of the two files reads as
1646
+ // a staged modification of a commit that already contains it. `read-tree`
1647
+ // without `-u` writes the index and never the working tree, which is the
1648
+ // half of `git commit` the ref move did not do.
1649
+ git(["read-tree", commitSha], commitPlan.root);
1650
+ }
1651
+ // Otherwise the commit is anchored on a ref of its own before anything is
1652
+ // pushed, so a rejected push leaves an object a human can still find and
1653
+ // push. The branch flow anchors on ITS branch, because that is the name the
1654
+ // recovery instructions use; the direct flow anchors under `refs/approval/`,
1655
+ // where it claims no branch name that the recovery might later need.
1656
+ const fallbackRef = `refs/approval/amend/${String(seq)}`;
1657
+ let anchor = fallbackRef;
1658
+ if (!inPlace) {
1659
+ if (branch !== null && git(["branch", branch, commitSha], commitPlan.root).ok) {
1660
+ anchor = branch;
1661
+ }
1662
+ else {
1663
+ git(["update-ref", fallbackRef, commitSha], commitPlan.root);
1664
+ }
1665
+ }
1666
+ committed = true;
1667
+ output = inPlace
1668
+ ? `${commitSha.slice(0, 12)} on ${probe.currentBranch ?? "HEAD"} (built on ${baseLabel})`
1669
+ : `${commitSha.slice(0, 12)} on ${baseLabel} (held at ${anchor}; your checkout was not moved)`;
1670
+ /** `gh pr create …` as the operator would type it, for both flows. */
1671
+ const prCreateCommand = (head) => `gh pr create --title ${JSON.stringify(prTitle(summary, String(seq)))} --body ${JSON.stringify(prBody(String(seq)))} --head ${head}${probe.defaultBranch === null ? "" : ` --base ${probe.defaultBranch}`}`;
1672
+ const prCreateArgs = (head) => [
1673
+ "pr",
1674
+ "create",
1675
+ "--title",
1676
+ prTitle(summary, String(seq)),
1677
+ "--body",
1678
+ prBody(String(seq)),
1679
+ "--head",
1680
+ head,
1681
+ ...(probe.defaultBranch === null ? [] : ["--base", probe.defaultBranch]),
1682
+ ];
1683
+ if (noPublish) {
1684
+ // The old stop-after-commit ceremony, on request. Nothing is pushed, so
1685
+ // the push and (on the branch flow) the pull request are printed as owed.
1686
+ publishing.via = useBranch ? "branch" : "direct";
1687
+ publishing.branch = branch;
1688
+ publishing.reason = "--no-publish: the ceremony stopped at the commit";
1689
+ }
1690
+ else if (branch !== null) {
1691
+ // The branch flow does not stop at the commit: the commit is only useful
1692
+ // on a protected main once it is on a branch, pushed, and carried by a PR.
1693
+ publishing.attempted = true;
1694
+ publishing.via = "branch";
1695
+ publishing.branch = branch;
1696
+ progress.phase(`pushing ${commitSha.slice(0, 12)} to origin ${branch}`);
1697
+ const push = git(["push", "origin", `${commitSha}:refs/heads/${branch}`], commitPlan.root);
1698
+ progress.done();
1699
+ publishing.steps.push({ command: `git push -u origin ${branch}`, ok: push.ok });
1700
+ if (!push.ok) {
1701
+ const prCreate = `gh pr create --title ${JSON.stringify(prTitle(summary, String(seq)))} --body ${JSON.stringify(prBody(String(seq)))}`;
1702
+ return stalled("push-rejected", `the remote REJECTED \`git push origin ${branch}\``, push, `the attestation was appended at seq ${seq} and committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, but \`git push origin ${commitSha.slice(0, 12)}:refs/heads/${branch}\` was REJECTED: ${pushFailureText(push)}. STATE: the amendment commit exists LOCALLY on ${branch} and nowhere else; origin still carries the previous policy. Next: \`git push -u origin ${branch} && ${prCreate}\`, and merge that pull request with a merge commit`, [
1703
+ `attestation appended at seq ${seq}: it is in the log, on disk`,
1704
+ `committed LOCALLY on ${branch} (${commitSha.slice(0, 12)}, parented on ${commitBase.remote}/${commitBase.branch}), and nowhere else`,
1705
+ "your checkout was never moved: same branch, same working tree",
1706
+ "NOT on origin: origin still carries the previous policy",
1707
+ ], [
1708
+ { command: `git push -u origin ${branch}`, note: "once the remote will take it" },
1709
+ { command: prCreate },
1710
+ { command: `gh pr merge ${branch} --merge`, note: "or merge it in the web UI" },
1711
+ ], 0, [MERGE_COMMIT_LINE]);
1712
+ }
1713
+ pushed = true;
1714
+ publishing.pushed = true;
1715
+ output = `${output}\n${`${push.stdout}${push.stderr}`.trim()}`.trim();
1716
+ if (ghAvailable(commitPlan.root)) {
1717
+ const pr = gh(prCreateArgs(branch), commitPlan.root);
1718
+ publishing.steps.push({ command: prCreateCommand(branch), ok: pr.ok });
1719
+ if (!pr.ok) {
1720
+ const ghFailure = pr.stderr.trim() || pr.stdout.trim() || "gh did not run";
1721
+ return stalled("pr-failed", "`gh pr create` failed; the branch is already on origin", pr, `the attestation was appended at seq ${seq}, committed on ${branch} and pushed, but \`gh pr create\` failed: ${ghFailure}; open the pull request by hand and merge it with a merge commit`, [
1722
+ `attestation appended at seq ${seq}: it is in the log, on disk`,
1723
+ `committed on ${branch} and PUSHED: origin has the branch`,
1724
+ "no pull request: origin's default branch still carries the previous policy",
1725
+ ], [
1726
+ { command: prCreateCommand(branch), note: "retry, or open it in the web UI" },
1727
+ { command: `gh pr merge ${branch} --merge`, note: "or merge it in the web UI" },
1728
+ ], 0, [MERGE_COMMIT_LINE]);
1729
+ }
1730
+ prUrl = lastUrl(pr.stdout);
1731
+ publishing.prUrl = prUrl;
1732
+ publishing.complete = true;
1733
+ // APRV-130: the ceremony offers to finish the last step too.
1734
+ armAutoMerge(commitPlan.root, branch);
1735
+ }
1736
+ }
1737
+ else {
1738
+ // APRV-111. The direct flow used to stop at the commit while PRINTING the
1739
+ // push line among the commands it had just run, so a push that never
1740
+ // happened — and, on the day this was found, a push that GitHub's branch
1741
+ // protection would have rejected — read as a finished ceremony. The commit
1742
+ // sat ahead of origin, unpushed and unnoticed.
1743
+ //
1744
+ // APRV-130. The rejection used to end the verb, handing the operator four
1745
+ // commands to type. Those four commands are non-destructive and entirely
1746
+ // mechanical, so the verb RUNS them: a branch off the commit that already
1747
+ // exists, a push of that branch, a pull request, and an attempt at
1748
+ // auto-merge. What is NOT automated is the APRV-111 constraint, unchanged:
1749
+ // `git branch` copies a ref, so the operator's checked-out branch stays
1750
+ // exactly where they left it, on the commit they just signed for.
1751
+ const target = probe.currentBranch;
1752
+ if (target !== null && hasOrigin(commitPlan.root)) {
1753
+ publishing.attempted = true;
1754
+ publishing.via = "direct";
1755
+ progress.phase(`pushing ${commitSha.slice(0, 12)} to origin ${target}`);
1756
+ const push = git(["push", "origin", `${commitSha}:refs/heads/${target}`], commitPlan.root);
1757
+ progress.done();
1758
+ publishing.steps.push({ command: `git push origin ${target}`, ok: push.ok });
1759
+ if (push.ok) {
1760
+ pushed = true;
1761
+ publishing.pushed = true;
1762
+ publishing.complete = true;
1763
+ output = `${output}\n${`${push.stdout}${push.stderr}`.trim()}`.trim();
1764
+ // APRV-203: the commit was assembled on the remote's tip and pushed
1765
+ // there, so the operator's own branch does not carry it yet. Say so,
1766
+ // rather than letting them find out from a `git status` later.
1767
+ if (!inPlace) {
1768
+ note(`${st.glyph("ok")} pushed to origin ${target}; your ${target} does not carry the commit yet — \`approval log sync\` brings it down safely`);
1769
+ }
1770
+ }
1771
+ else {
1772
+ // ---- the ceremony finishes its own job ----
1773
+ const recovery = branchName(String(seq));
1774
+ publishing.via = "recovery";
1775
+ publishing.branch = recovery;
1776
+ const owed = [
1777
+ {
1778
+ command: `git branch ${recovery} ${commitSha.slice(0, 12)}`,
1779
+ note: "the assembled commit, on a branch",
1780
+ },
1781
+ { command: `git push -u origin ${recovery}` },
1782
+ { command: prCreateCommand(recovery) },
1783
+ { command: `gh pr merge ${recovery} --merge`, note: "or merge it in the web UI" },
1784
+ ];
1785
+ /** The state lines every stop on this path shares, plus its own. */
1786
+ const state = (last) => [
1787
+ `attestation appended at seq ${seq}: it is in the log, on disk`,
1788
+ `committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, held LOCALLY on ${recovery}`,
1789
+ `your checkout was never moved: still on ${target}, working tree as you left it`,
1790
+ `${target} is protected, whatever the probe reported: the remote just refused`,
1791
+ last,
1792
+ ];
1793
+ const preamble = `the attestation was appended at seq ${seq} and committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, but \`git push origin ${target}\` was REJECTED by the remote: ${pushFailureText(push)}. ${target} is protected (whatever the protection probe reported: the remote just refused the push), so this ceremony published through branch ${recovery} instead`;
1794
+ note(`${st.warn(`${target} is protected:`)} the direct push was refused, so this amendment publishes through branch ${recovery}`);
1795
+ quoteUnder(push);
1796
+ // 1. The branch. A ref copy at the assembled commit (APRV-203: at the
1797
+ // COMMIT rather than at HEAD, which never carried it). A name
1798
+ // already taken is still a refusal: this verb does not overwrite
1799
+ // somebody else's branch to finish its own ceremony.
1800
+ const branched = git(["branch", recovery, commitSha], commitPlan.root);
1801
+ publishing.steps.push({ command: `git branch ${recovery}`, ok: branched.ok });
1802
+ if (!branched.ok) {
1803
+ return stalled("push-rejected", `\`git branch ${recovery}\` failed, so the recovery branch does not exist`, branched, `${preamble}, and \`git branch ${recovery}\` failed: ${pushFailureText(branched)}. STATE: the amendment is committed LOCALLY on ${target} and is NOT on origin, so origin still carries the previous policy and your ${target} is one commit ahead of it. Next: \`git branch ${recovery} && git push -u origin ${recovery} && ${prCreateCommand(recovery)}\`. Merge it with a merge commit, then run \`approval log sync\` rather than a pull`, state("NOT on origin: origin still carries the previous policy"), owed, 0, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
1804
+ }
1805
+ note(`${st.glyph("ok")} branch ${recovery} created — your checkout stays on ${target}`);
1806
+ // 2. The push of that branch.
1807
+ const branchPush = git(["push", "-u", "origin", recovery], commitPlan.root);
1808
+ publishing.steps.push({ command: `git push -u origin ${recovery}`, ok: branchPush.ok });
1809
+ if (!branchPush.ok) {
1810
+ return stalled("push-rejected", `the remote REJECTED \`git push -u origin ${recovery}\``, branchPush, `${preamble}; \`git push -u origin ${recovery}\` was REJECTED too: ${pushFailureText(branchPush)}. STATE: the amendment is committed LOCALLY on ${target} and is NOT on origin, so origin still carries the previous policy and your ${target} is one commit ahead of it. Next: \`git push -u origin ${recovery} && ${prCreateCommand(recovery)}\`. Merge it with a merge commit, then run \`approval log sync\` rather than a pull`, state("NOT on origin: origin still carries the previous policy"), owed, 1, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
1811
+ }
1812
+ publishing.pushed = true;
1813
+ note(`${st.glyph("ok")} pushed ${recovery} to origin`);
1814
+ // 3. The pull request. `gh` absent is a failure of THIS step and
1815
+ // nothing more: the branch is on origin either way, so the runbook
1816
+ // resumes from here rather than from the beginning.
1817
+ if (!ghAvailable(commitPlan.root)) {
1818
+ return stalled("pr-failed", "gh is not available, so the pull request was not opened", { ok: false, stdout: "", stderr: "gh is not on PATH" }, `${preamble}, and ${recovery} is on origin, but \`gh\` is not available so the pull request was not opened: open it by hand and merge it with a merge commit. STATE: the amendment is committed LOCALLY on ${target} and is on origin as ${recovery}; origin's ${probe.defaultBranch ?? "default branch"} still carries the previous policy. Next: \`${prCreateCommand(recovery)}\`; and \`approval log sync\` rather than a pull afterwards`, state(`on origin as ${recovery}: no pull request carries it yet`), owed, 2, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
1819
+ }
1820
+ const pr = gh(prCreateArgs(recovery), commitPlan.root);
1821
+ publishing.steps.push({ command: prCreateCommand(recovery), ok: pr.ok });
1822
+ if (!pr.ok) {
1823
+ return stalled("pr-failed", "`gh pr create` failed; the branch is already on origin", pr, `${preamble}, and ${recovery} is on origin, but \`gh pr create\` failed: ${pushFailureText(pr)}; open the pull request by hand and merge it with a merge commit. STATE: the amendment is committed LOCALLY on ${target} and is on origin as ${recovery}; origin's ${probe.defaultBranch ?? "default branch"} still carries the previous policy. Next: \`${prCreateCommand(recovery)}\`; and \`approval log sync\` rather than a pull afterwards`, state(`on origin as ${recovery}: no pull request carries it yet`), owed, 2, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
1824
+ }
1825
+ prUrl = lastUrl(pr.stdout);
1826
+ publishing.prUrl = prUrl;
1827
+ publishing.complete = true;
1828
+ note(`${st.glyph("ok")} ${prLabel(prUrl)} opened${prUrl === null ? "" : `: ${prUrl}`}`);
1829
+ // 4. Auto-merge, which is allowed to refuse.
1830
+ armAutoMerge(commitPlan.root, recovery);
1831
+ }
1832
+ }
1833
+ }
1834
+ }
1835
+ if (json) {
1836
+ emitReport(streams, {
1837
+ policyPath,
1838
+ liveSha256,
1839
+ attested,
1840
+ baseline: recovered.baseline,
1841
+ diff,
1842
+ load: loadSummary(liveLoad),
1843
+ attestation: { seq, sha256: liveSha256 },
1844
+ git: gitReport({ commands, committed, pushed, prUrl, output, branch }),
1845
+ noop: false,
1846
+ dryRun: false,
1847
+ aborted: false,
1848
+ pins: pinsReport(),
1849
+ ceremony: { attested: true, seq },
1850
+ publishing,
1851
+ ...(collected === null
1852
+ ? {}
1853
+ : {
1854
+ proposal: {
1855
+ seq: collected.proposedSeq,
1856
+ sha256: collected.sha256,
1857
+ diff: collected.diff,
1858
+ load: collected.load,
1859
+ },
1860
+ }),
1861
+ });
1862
+ }
1863
+ else {
1864
+ if (committed) {
1865
+ // APRV-274: the headline names what the commit actually carries. A reader
1866
+ // who is told "the policy and the log" and finds a third file in the diff
1867
+ // has been told something false about the one commit that must not lie.
1868
+ const carried = pinsChange === null ? "the policy and the log" : "the policy, the log and the pins";
1869
+ const done = [
1870
+ branch === null
1871
+ ? `${st.glyph("ok")} committed ${carried} together:`
1872
+ : `${st.glyph("ok")} committed ${carried} together on ${branch}:`,
1873
+ "",
1874
+ ];
1875
+ // APRV-111: only the commands that actually RAN are listed under
1876
+ // "committed". A push the verb skipped (no origin, or a detached HEAD)
1877
+ // used to be printed here as though it had run, which is how an unpushed
1878
+ // amendment came to look like a finished one. A push that ran and FAILED
1879
+ // never reaches this branch at all: it is a refusal above.
1880
+ const remaining = [];
1881
+ for (const command of commands) {
1882
+ // The PR command is printed as a to-do when gh could not run it, or
1883
+ // (APRV-130) when --no-publish stopped the ceremony before it.
1884
+ if (command.startsWith("gh pr create") && branch !== null) {
1885
+ if (noPublish)
1886
+ remaining.push(command);
1887
+ if (prUrl === null)
1888
+ continue;
1889
+ }
1890
+ if (command.startsWith("git push") && !pushed) {
1891
+ // APRV-130: the direct push that the RECOVERY answered is not owed to
1892
+ // anybody. It ran, the remote refused it, and the Publishing section
1893
+ // below says so and says what was done instead. Printing it here as
1894
+ // "still to run" would send the operator to re-run a rejected push.
1895
+ if (publishing.via !== "recovery")
1896
+ remaining.push(command);
1897
+ continue;
1898
+ }
1899
+ done.push(` ${st.value(humanCommand(command))}`);
1900
+ }
1901
+ if (output !== null && output.length > 0)
1902
+ done.push("", ...output.split("\n"));
1903
+ if (remaining.length > 0) {
1904
+ done.push("", noPublish
1905
+ ? `${st.warn("--no-publish:")} the commit is local only, and origin still carries the previous policy. Still to run:`
1906
+ : `${st.warn("NOT pushed:")} the commit is local only, and origin still carries the previous policy. Still to run:`, "", ...remaining.map((command) => ` ${st.value(humanCommand(command))}`));
1907
+ }
1908
+ if (branch !== null && !noPublish) {
1909
+ done.push("");
1910
+ if (prUrl !== null) {
1911
+ done.push(`${st.key("pull request:")} ${st.value(prUrl)}`);
1912
+ done.push("Merge it with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.");
1913
+ }
1914
+ else {
1915
+ done.push("gh is not available, so the pull request was not opened. Open it yourself, and merge it with a MERGE COMMIT so the policy edit and its attestation stay one commit on main:", "");
1916
+ for (const command of commands) {
1917
+ if (command.startsWith("gh pr create"))
1918
+ done.push(` ${st.value(humanCommand(command))}`);
1919
+ }
1920
+ }
1921
+ }
1922
+ section("Committed", done);
1923
+ }
1924
+ else {
1925
+ section("Now run", [
1926
+ ...whyOneCommit(),
1927
+ "",
1928
+ ...commands.map((command) => ` ${st.value(humanCommand(command))}`),
1929
+ ...(useBranch
1930
+ ? [
1931
+ "",
1932
+ "Then merge that pull request with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.",
1933
+ ]
1934
+ : []),
1935
+ ]);
1936
+ }
1937
+ // Logistics, beneath the achievement and beneath the commit: what the
1938
+ // publishing half did, step by step, when it had to do anything unusual.
1939
+ flushPublishing();
1940
+ }
1941
+ return EXIT_OK;
1942
+ }
1943
+ /**
1944
+ * `--commit`'s preconditions.
1945
+ *
1946
+ * The amendment commit carries **exactly these files**: the policy, the log,
1947
+ * and (APRV-274) the pins in `src/core/policy-expectations.ts` when they moved.
1948
+ * A staged change to anything else is refused rather than swept in, because a
1949
+ * commit that quietly carried an unrelated staged edit would make "this commit
1950
+ * is the amendment" false, and that sentence is the whole reason the commit
1951
+ * exists. Unstaged and untracked changes elsewhere are left alone: they are not
1952
+ * going into this commit.
1953
+ *
1954
+ * The pins joined that set rather than widening it. They are part of an
1955
+ * amendment's contract (CI's dogfood suite resolves the amended policy against
1956
+ * them, and it reads both from the same commit), so a rule that admitted the
1957
+ * policy and the log and refused the pins was a rule that split one amendment
1958
+ * across two commits and a hand-run cherry-pick.
1959
+ */
1960
+ function planCommit(policyPath, logPath,
1961
+ /**
1962
+ * The branch flow's preconditions, checked here for the same reason: an
1963
+ * `origin` that does not exist, or a branch name already taken, would fail
1964
+ * AFTER the attestation and leave the operator holding a half-run ceremony.
1965
+ * `null` is the direct flow. `branch: null` inside it is the branch flow with
1966
+ * a generated name, which contains the seq and so cannot be checked before
1967
+ * the append happens.
1968
+ */
1969
+ branchFlow) {
1970
+ const root = repoRoot(dirname(policyPath));
1971
+ if (root === null) {
1972
+ return {
1973
+ ok: false,
1974
+ message: `--commit needs a git repository and ${policyPath} is not inside one; nothing was attested`,
1975
+ };
1976
+ }
1977
+ const policyArg = repoPath(root, policyPath);
1978
+ const logArg = repoPath(root, logPath);
1979
+ // APRV-274. `null` where these pins do not govern this policy, and where the
1980
+ // repository simply has no pins module: in both cases the ceremony's file set
1981
+ // is the two it always was.
1982
+ const pinsArg = expectationsFor(policyPath) !== null && existsSync(join(root, EXPECTATIONS_MODULE))
1983
+ ? EXPECTATIONS_MODULE
1984
+ : null;
1985
+ if (policyArg.startsWith("../") || logArg.startsWith("../")) {
1986
+ return {
1987
+ ok: false,
1988
+ message: `--commit needs the policy (${policyPath}) and the log (${logPath}) inside the same repository (${root}); nothing was attested`,
1989
+ };
1990
+ }
1991
+ const status = git(["status", "--porcelain"], root);
1992
+ if (!status.ok) {
1993
+ return { ok: false, message: `--commit could not read git status: ${status.stderr.trim()}` };
1994
+ }
1995
+ const strays = [];
1996
+ for (const line of status.stdout.split("\n")) {
1997
+ if (line.trim().length === 0)
1998
+ continue;
1999
+ const index = line[0] ?? " ";
2000
+ // Only the INDEX column matters: an unstaged or untracked file elsewhere is
2001
+ // not going into this commit, and refusing over it would make the verb
2002
+ // unusable in any working repository.
2003
+ if (index === " " || index === "?")
2004
+ continue;
2005
+ const path = line.slice(3).trim();
2006
+ if (path === policyArg || path === logArg || path === pinsArg)
2007
+ continue;
2008
+ strays.push(path);
2009
+ }
2010
+ if (strays.length > 0) {
2011
+ const carried = pinsArg === null ? "the policy and the log" : `the policy, the log and ${pinsArg}`;
2012
+ return {
2013
+ ok: false,
2014
+ message: `--commit refuses: the index carries ${strays.length} staged change(s) beyond ${carried} (${strays.join(", ")}). The amendment commit carries EXACTLY those files, so that "this commit is the amendment" stays true. Unstage them, or drop --commit and run the printed commands yourself. Nothing was attested`,
2015
+ };
2016
+ }
2017
+ if (branchFlow !== null) {
2018
+ const remote = git(["remote", "get-url", "origin"], root);
2019
+ if (!remote.ok) {
2020
+ return {
2021
+ ok: false,
2022
+ message: `--commit on a branch needs an "origin" remote to push to, and ${root} has none (${remote.stderr.trim()}); pass --direct to commit in place, or add the remote. Nothing was attested`,
2023
+ };
2024
+ }
2025
+ if (branchFlow.branch !== null) {
2026
+ const exists = git(["rev-parse", "--verify", "--quiet", `refs/heads/${branchFlow.branch}`], root);
2027
+ if (exists.ok) {
2028
+ return {
2029
+ ok: false,
2030
+ message: `--branch ${branchFlow.branch} already exists in ${root}; the amendment branch is created fresh so it carries exactly one commit. Pick another name. Nothing was attested`,
2031
+ };
2032
+ }
2033
+ }
2034
+ }
2035
+ return { ok: true, plan: { root, policyArg, logArg, pinsArg } };
2036
+ }
2037
+ /**
2038
+ * Fetch the remote and establish that this ceremony may be based on it (APRV-203).
2039
+ *
2040
+ * The ceremony used to commit on whatever branch the operator was standing on,
2041
+ * which made the operator responsible for that branch being current. On
2042
+ * 2026-09-01 it was not: origin's main had moved, the amendment commit went onto
2043
+ * the stale local tip, and the pull request carried a parent missing everything
2044
+ * main had merged since — including the test pins the previous ceremony landed.
2045
+ * CI went red and the branch had to be rebased by hand. So the verb fetches, and
2046
+ * it bases its commit on the remote.
2047
+ *
2048
+ * Two things are checked before that base is accepted, and both are refusals
2049
+ * with their own codes rather than reconciliations:
2050
+ *
2051
+ * - the remote's POLICY must be the attested bytes this edit was made against.
2052
+ * If the remote carries a policy this working tree never saw, the edit in the
2053
+ * working tree is an edit to a different document.
2054
+ * - the remote's LOG must be a prefix of the working log. The amendment commit
2055
+ * carries the log, so a remote log this working log does not contain would be
2056
+ * reverted by it, and two chains do not merge.
2057
+ *
2058
+ * A local branch AHEAD of the remote is deliberately not checked at all: the
2059
+ * commit is parented on the remote either way, so those commits are simply not
2060
+ * part of the ceremony.
2061
+ */
2062
+ function prepareBase(ctx, probe, attestedSha256) {
2063
+ const remote = "origin";
2064
+ // A repository with no remote has nothing to fetch and nothing to diverge
2065
+ // from: its own HEAD is the base, which is what this ceremony always used.
2066
+ if (!hasOrigin(ctx.root)) {
2067
+ const head = git(["rev-parse", "HEAD"], ctx.root);
2068
+ const sha = head.stdout.trim();
2069
+ if (!head.ok || sha.length === 0) {
2070
+ return {
2071
+ ok: false,
2072
+ code: "fetch-failed",
2073
+ message: `${ctx.root} has no "origin" remote and no HEAD commit to base this amendment on; nothing was attested. Make the first commit, or add the remote`,
2074
+ };
2075
+ }
2076
+ return { ok: true, base: { remote: null, branch: probe.currentBranch ?? "HEAD", sha } };
2077
+ }
2078
+ const branch = probe.defaultBranch ?? probe.currentBranch;
2079
+ if (branch === null) {
2080
+ return {
2081
+ ok: false,
2082
+ code: "fetch-failed",
2083
+ message: `no branch could be resolved to base this amendment on (${probe.reason}); nothing was attested. Check out the trunk in this checkout, or add an "origin" remote`,
2084
+ };
2085
+ }
2086
+ ctx.progress.phase(`fetching ${remote}/${branch}: the amendment is based on the remote, not on this checkout`);
2087
+ const fetched = fetchBase(ctx.root, remote, branch);
2088
+ ctx.progress.done();
2089
+ if (!fetched.ok) {
2090
+ return {
2091
+ ok: false,
2092
+ code: "fetch-failed",
2093
+ message: `${fetched.message}. The amendment commit is based on ${remote}/${branch}, so the ceremony cannot proceed without knowing where that is; nothing was attested. Fix the remote (network, credentials, or no origin at all) and run this again`,
2094
+ };
2095
+ }
2096
+ const sha = fetched.sha;
2097
+ ctx.progress.phase(`verifying that ${remote}/${branch} ${sha.slice(0, 12)} is this edit's base`);
2098
+ const remotePolicy = showBlob(ctx.root, sha, ctx.policyArg);
2099
+ const remoteSha256 = remotePolicy === null ? null : createHash("sha256").update(remotePolicy).digest("hex");
2100
+ if (attestedSha256 !== null && remoteSha256 !== attestedSha256) {
2101
+ ctx.progress.done();
2102
+ return {
2103
+ ok: false,
2104
+ code: "base-policy-diverged",
2105
+ message: `${remote}/${branch} carries a policy this amendment was not written against: ${remoteSha256 === null
2106
+ ? `it has no ${ctx.policyArg} at all`
2107
+ : `${remote}/${branch}:${ctx.policyArg} hashes ${remoteSha256}`}, and the attested baseline this edit is a diff from is ${attestedSha256}. Somebody amended the policy since this edit began, so committing it would revert their amendment. Nothing was attested. Bring this checkout up to ${remote}/${branch} and re-apply the edit on top of it`,
2108
+ };
2109
+ }
2110
+ const remoteLog = showBlob(ctx.root, sha, repoPath(ctx.root, ctx.logPath));
2111
+ const compared = compareChains({ label: `the working log ${ctx.logPath}`, text: readLogText(ctx.logPath) }, {
2112
+ label: `${remote}/${branch}:${repoPath(ctx.root, ctx.logPath)}`,
2113
+ text: remoteLog === null ? "" : remoteLog.toString("utf8"),
2114
+ });
2115
+ ctx.progress.done();
2116
+ if (!compared.ok) {
2117
+ return { ok: false, code: "base-log-diverged", message: `${compared.message}; nothing was attested` };
2118
+ }
2119
+ if (compared.drift.relation === "diverged" || compared.drift.relation === "behind") {
2120
+ const drift = compared.drift;
2121
+ return {
2122
+ ok: false,
2123
+ code: "base-log-diverged",
2124
+ message: drift.relation === "diverged"
2125
+ ? `the working log and ${remote}/${branch}'s log part at seq ${String(drift.firstDivergentSeq)}: two chains, not one. The amendment commit carries the log, and hash chains do not merge, so nothing was attested. Run \`approval doctor\` for the log-drift report; which of the two is the log is a human decision`
2126
+ : `${remote}/${branch} carries log records this checkout does not (its head is seq ${String(drift.committedHead?.seq ?? 0)}, the working head is seq ${String(drift.workingHead?.seq ?? 0)}). An amendment commit built on the remote would carry this shorter log over the longer one, dropping records. Nothing was attested. Run \`approval log sync\` first, then run this again`,
2127
+ };
2128
+ }
2129
+ return { ok: true, base: { remote, branch, sha } };
2130
+ }
2131
+ /** The working log's text, or the empty string when there is no file yet. */
2132
+ function readLogText(path) {
2133
+ try {
2134
+ return readFileSync(path, "utf8");
2135
+ }
2136
+ catch {
2137
+ return "";
2138
+ }
2139
+ }
2140
+ function loadSummary(load) {
2141
+ return load.ok
2142
+ ? { ok: true, code: null, message: null }
2143
+ : { ok: false, code: load.code, message: load.message };
2144
+ }
2145
+ function emitReport(streams, report) {
2146
+ streams.out(`${JSON.stringify({
2147
+ ok: true,
2148
+ noop: report.noop,
2149
+ dryRun: report.dryRun,
2150
+ aborted: report.aborted,
2151
+ policy: report.policyPath,
2152
+ liveSha256: report.liveSha256,
2153
+ attested: report.attested,
2154
+ baseline: report.baseline,
2155
+ diff: report.diff,
2156
+ load: report.load,
2157
+ attestation: report.attestation,
2158
+ git: report.git,
2159
+ // APRV-274, additive and always present: which pins moved with this
2160
+ // amendment, `null` when the pins are no part of it.
2161
+ pins: report.pins ?? null,
2162
+ // Additive (APRV-130), and always present: a machine caller reads the
2163
+ // ceremony's own outcome without inferring it from `attestation`.
2164
+ ceremony: report.ceremony ?? { attested: report.attestation !== null, seq: null },
2165
+ publishing: report.publishing ?? null,
2166
+ // APRV-109, additive and always present: `null` says the attestation was
2167
+ // performed at this terminal, an object says it was collected as a tap.
2168
+ proposal: report.proposal ?? null,
2169
+ })}\n`);
2170
+ }
2171
+ //# sourceMappingURL=amend.js.map