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,2063 @@
1
+ /**
2
+ * `approval channel telegram listen | health` — the runtime half of the
3
+ * Telegram channel (SPEC.md §10.3, APRV-26).
4
+ *
5
+ * As everywhere else in this CLI, **no logic lives here**. The rendering and
6
+ * the Bot API are `channels/telegram.ts`; turning a button press into an event
7
+ * is `channels/contract.ts`'s `recordChannelDecision`, which calls the
8
+ * human-only `decide()` in `core/gate.ts`. This file resolves configuration,
9
+ * builds the pending queue, wires the two together, and chooses an exit code.
10
+ *
11
+ * Three things it does that the channel deliberately cannot:
12
+ *
13
+ * 1. **It reads the environment.** `APPROVAL_TG_TOKEN` and `APPROVAL_TG_CHAT`
14
+ * are read here and passed to the channel as values (SPEC.md §5.1: policy
15
+ * carries the env-var *names*, never the secrets). Nothing in `channels/`
16
+ * touches `process.env`.
17
+ * 2. **It declares who is approving.** The decision is recorded against the
18
+ * human actor from `--as` / `APPROVAL_HUMAN`, never against anything the
19
+ * callback carried. SPEC.md §11: identity in v0.1 is config-declared, the
20
+ * trust boundary is the local machine, and everyone who can reach the
21
+ * configured chat can approve as that actor. This is stated in `--help`
22
+ * because an operator has to be able to see it without reading the source.
23
+ * 3. **It holds the token.** A grant mints a single-use execution token;
24
+ * `recordChannelDecision` returns it to *this* handler, which prints it on
25
+ * **stdout** and never hands it back to the channel. It is never sent to
26
+ * Telegram — see the module doc of `channels/telegram.ts` for why a chat
27
+ * transcript is not a credential store, and for the flag on that decision.
28
+ *
29
+ * ## Payload material — the store, and why `--payloads` still exists
30
+ *
31
+ * SPEC.md §6.2 records a `payload_hash` in the log and never the bytes, and
32
+ * §10.4 requires a channel to present the full payload for a manual action. So
33
+ * the bytes must come from somewhere the runtime can reach. Since APRV-28 that
34
+ * somewhere is the payload store beside the log (`.approval/payloads/`, written
35
+ * by `approval request --payload`), and a listener ordinarily needs no payload
36
+ * flag at all. `--payloads` remains an override for bytes an operator holds
37
+ * elsewhere: a JSON file mapping action key to that action's payload value,
38
+ * consulted before the store. The tagger
39
+ * (`channels/tagging.ts`) re-hashes whatever it is given and refuses anything
40
+ * that does not match the recorded binding, so a wrong or stale file cannot put
41
+ * different bytes in front of an approver than the token will execute — it
42
+ * produces a visible skip instead. Requests whose material is missing are
43
+ * reported on stderr and NOT delivered: a manual request rendered without its
44
+ * payload would be exactly the §10.4 violation the contract refuses.
45
+ *
46
+ * ## Dispatch: where it lives, and why it lives here (APRV-55) — flagged
47
+ *
48
+ * SPEC.md §10.2 lists "dispatches channel notifications" among the daemon's
49
+ * jobs. At v0.1 the reference runtime performs that dispatch **in this
50
+ * listener**, on every poll cycle, and the placement is deliberate:
51
+ *
52
+ * 1. The listener already holds the channel connection (the bot token, the
53
+ * chat id) and the approver identity. The daemon holds neither, and giving
54
+ * it either would put a credential and a human identity into a process
55
+ * whose job is to read files and append events.
56
+ * 2. The daemon is the sole writer of the log; dispatch appends nothing. Moving
57
+ * a read-and-send out of the daemon costs the single-writer stance nothing,
58
+ * because dispatch was never a write.
59
+ * 3. A network round-trip inside the daemon's tick couples the projection loop
60
+ * to Telegram's availability. A slow Bot API would delay TTL expiry and
61
+ * write-back, which are the daemon's actual obligations.
62
+ *
63
+ * So this is an implementation placement, not a change to the daemon's stated
64
+ * role: a later build MAY move dispatch into the daemon (or a supervisor) with
65
+ * no change to the log or to any event shape. SPEC.md §10.3 records the same.
66
+ *
67
+ * ### The cycle
68
+ *
69
+ * {@link dispatchPending} runs before every `getUpdates` — the startup send and
70
+ * every later cycle are the same call with the same state, the startup one
71
+ * merely finding an empty delivered set. Each call **re-derives** the pending
72
+ * queue from the verified log ({@link buildPendingQueue}), so which requests
73
+ * are pending is always the log's answer and never this process's memory. A
74
+ * request appended while the listener is running is therefore delivered on the
75
+ * next cycle, without a restart; a request that was decided or whose TTL lapsed
76
+ * simply stops appearing in the derivation and is never sent.
77
+ *
78
+ * What *is* remembered, and only in {@link DispatchState} for this process's
79
+ * lifetime, is which action keys this listener has already put on the phone.
80
+ * Losing that memory (a restart, a crash) re-sends everything still pending:
81
+ * a duplicate on the phone, never silence. That direction is the whole design
82
+ * (SPEC.md §10.3: channels hold no state that is a source of truth).
83
+ *
84
+ * APRV-196 made that re-send legible rather than rarer. The first batch a
85
+ * process sends is preceded by one banner naming how many are coming, the
86
+ * copies already in the chat keep working (`actionRefOf` in
87
+ * `channels/telegram.ts` resolves their buttons to the same request), and the
88
+ * bookkeeping above is pruned as requests settle and age out instead of growing
89
+ * for the life of a listener that `approval up` keeps running for weeks.
90
+ *
91
+ * ### Send failures
92
+ *
93
+ * A key that fails to send stays undelivered, so the next cycle retries it.
94
+ * There is **no attempt limit**: giving up would turn a transient outage into a
95
+ * pending request no human ever sees, which is the one failure this project
96
+ * exists to prevent. The retry rate is bounded by the poll cycle itself (the
97
+ * long-poll timeout, or the channel's doubling backoff after a poll error), and
98
+ * the stderr warnings are throttled after {@link DISPATCH_LOUD_ATTEMPTS}
99
+ * consecutive failures for one key so a long outage cannot bury the terminal.
100
+ * The one exception is the **startup** dispatch, which still exits non-zero on
101
+ * a send failure: an operator who has just mistyped a chat id or a token should
102
+ * learn it immediately rather than watch a listener retry forever.
103
+ */
104
+ import { readFileSync, statSync } from "node:fs";
105
+ import { isAbsolute, resolve as resolvePathSegments } from "node:path";
106
+ import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
107
+ import { assembleBatch } from "../channels/batch.js";
108
+ import { recordChannelDecision, } from "../channels/contract.js";
109
+ import { ageText, buildPendingQueue, } from "../channels/tagging.js";
110
+ import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
111
+ import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
112
+ import { openReviewCards } from "./audit-card.js";
113
+ import { reviewSample } from "../core/audit.js";
114
+ import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
115
+ import { telegramDeliveryFor } from "../core/telegram-config.js";
116
+ import { loadPolicy } from "../core/policy-load.js";
117
+ import { promptLayoutFor } from "../core/prompt-layout.js";
118
+ import { passphraseEnvFor } from "../core/vault.js";
119
+ import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
120
+ import { payloadOf, readVerifiedRecords, requestState } from "../core/state.js";
121
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
122
+ import { GLOSS_TIMEOUT_MS } from "./gloss.js";
123
+ import { attachGloss, glossAbsenceLine } from "./gloss-attach.js";
124
+ import { glossRunnerFromOptions, parseGlossOptions, } from "./gloss-options.js";
125
+ import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
126
+ import { TELEGRAM_HEALTH_HELP, TELEGRAM_HELP, TELEGRAM_LISTEN_HELP, } from "./help.js";
127
+ import { DEFAULT_LOG_PATH, preflightLog, resolvePath } from "./paths.js";
128
+ import { style, tokenPanel, TOKEN_NOTICE_TELEGRAM } from "./style.js";
129
+ import { usageErrorText } from "./usage.js";
130
+ const LISTEN_FLAGS = {
131
+ "--log": "string",
132
+ "--policy": "string",
133
+ "--dir": "string",
134
+ "--as": "string",
135
+ "--payloads": "string",
136
+ "--api-base": "string",
137
+ "--poll-timeout": "string",
138
+ "--once": "boolean",
139
+ "--gloss": "boolean",
140
+ "--no-gloss": "boolean",
141
+ "--gloss-provider": "string",
142
+ "--gloss-model": "string",
143
+ "--json": "boolean",
144
+ "--help": "boolean",
145
+ "-h": "boolean",
146
+ };
147
+ /**
148
+ * The gloss runner this listener will use, as a spreadable fragment (APRV-197).
149
+ *
150
+ * ON unless `--no-gloss`. Two flags rather than one because the pair reads
151
+ * honestly next to `channel cli`, where the default is the other way round:
152
+ * `--gloss` is accepted here (and is simply the default restated) so that one
153
+ * command line works on both verbs, and `--no-gloss` wins a tie, because the
154
+ * flag that removes a language model from the path should never lose one.
155
+ *
156
+ * A fragment rather than a value so that "no runner" is the ABSENCE of the
157
+ * key. {@link ListenSetup.gloss} being optional is what lets every
158
+ * programmatic caller of `dispatchPending` spawn nothing without saying so.
159
+ *
160
+ * `passphraseEnv` is the name this policy's `vault.passphrase_env` gives, and
161
+ * the only thing the APRV-207 scrub needs from a policy: the subprocess is
162
+ * spawned starved either way, and naming the variable covers the deployment
163
+ * that renamed it out from under the credential prefixes.
164
+ */
165
+ export function glossWiring(flags, passphraseEnv = null, factories = {}) {
166
+ const selected = parseGlossOptions(flags, true);
167
+ if (!selected.ok)
168
+ return {};
169
+ return glossWiringFor(selected.options, passphraseEnv, factories);
170
+ }
171
+ function glossWiringFor(selection, passphraseEnv, factories = {}) {
172
+ const gloss = glossRunnerFromOptions(selection, { ...factories, passphraseEnv });
173
+ return gloss === undefined ? {} : { gloss };
174
+ }
175
+ function usageError(streams, json, message, helpText) {
176
+ if (json)
177
+ streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
178
+ else
179
+ streams.err(usageErrorText(message, helpText));
180
+ return EXIT_USAGE;
181
+ }
182
+ function ioError(streams, json, message) {
183
+ if (json)
184
+ streams.err(`${JSON.stringify({ error: { code: "io", message } })}\n`);
185
+ else
186
+ streams.err(`approval: ${message}\n`);
187
+ return EXIT_IO;
188
+ }
189
+ function integrityError(streams, json, message) {
190
+ if (json)
191
+ streams.err(`${JSON.stringify({ error: { code: "integrity", message } })}\n`);
192
+ else
193
+ streams.err(`approval: ${message}\n`);
194
+ return EXIT_INTEGRITY;
195
+ }
196
+ function absolute(value, cwd) {
197
+ return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
198
+ }
199
+ /** A non-empty environment value, or `null`. Whitespace-only counts as unset. */
200
+ function env(name) {
201
+ const value = process.env[name];
202
+ return value === undefined || value.trim().length === 0 ? null : value.trim();
203
+ }
204
+ function payloadSource(resolved) {
205
+ if (resolved === null)
206
+ return { ok: true, source: undefined };
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(readFileSync(resolved, "utf8"));
210
+ }
211
+ catch (cause) {
212
+ return {
213
+ ok: false,
214
+ message: `--payloads ${resolved} could not be read as JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
215
+ };
216
+ }
217
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
218
+ return {
219
+ ok: false,
220
+ message: `--payloads ${resolved} must hold a JSON object mapping action key to that action's payload value`,
221
+ };
222
+ }
223
+ const table = parsed;
224
+ return { ok: true, source: (actionKey) => table[actionKey] };
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // Preparation, shared with the ambient runtime (APRV-110)
228
+ // ---------------------------------------------------------------------------
229
+ /**
230
+ * Why a listener could not be built. A closed union, because more than one
231
+ * caller now branches on it: the verb turns each into an exit code, and
232
+ * `approval up` turns each into a part it will not start (SPEC.md §11.1
233
+ * invariant 6 — a refusal is machine-readable and distinct).
234
+ */
235
+ export const LISTEN_REFUSAL_CODES = [
236
+ /** A credential variable the policy names is unset or empty. */
237
+ "not-configured",
238
+ /** No `human:<id>` was declared, so nothing could be recorded against one. */
239
+ "no-identity",
240
+ /** `--poll-timeout` was not a whole number of seconds. */
241
+ "poll-timeout",
242
+ /** The log could not be read (or its directory does not exist). */
243
+ "log-unreadable",
244
+ /** `--payloads` did not hold a JSON object of action key -> payload. */
245
+ "payloads-unreadable",
246
+ ];
247
+ /**
248
+ * Everything that can fail without touching the network, in order.
249
+ *
250
+ * Deliberately sequential and deliberately synchronous: an operator who typed
251
+ * the wrong thing learns it before a bot message is sent, and the async half
252
+ * below can then assume its configuration is whole.
253
+ *
254
+ * It PRINTS NOTHING and CHOOSES NO EXIT CODE (APRV-110). The verb below turns
255
+ * each refusal into the usage or I/O error it always was; `approval up` turns
256
+ * the same refusal into a channel it declines to start, reported in doctor's
257
+ * vocabulary while the other parts carry on. Two callers, one set of checks,
258
+ * one set of sentences — which is the only way the two surfaces can agree about
259
+ * what "telegram is not configured" means.
260
+ */
261
+ export function prepareListen(request) {
262
+ // Configuration is environment-only: policy names the variables, never the
263
+ // values (SPEC.md §5.1), and there is no flag that would put a bot token in
264
+ // a shell history or a process listing. A policy that fails to load names
265
+ // nothing and the reference defaults apply — the load is fail-closed for
266
+ // autonomy and budgets, and a variable name is not a permission.
267
+ const policyLoad = loadPolicy(request.policy);
268
+ const tokenEnv = telegramTokenEnvFor(policyLoad);
269
+ const chatEnv = telegramChatEnvFor(policyLoad);
270
+ const token = env(tokenEnv);
271
+ const chatId = env(chatEnv);
272
+ if (token === null || chatId === null) {
273
+ const missing = [
274
+ token === null ? tokenEnv : null,
275
+ chatId === null ? chatEnv : null,
276
+ ].filter((name) => name !== null);
277
+ return {
278
+ ok: false,
279
+ code: "not-configured",
280
+ message: `telegram is not configured: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset or empty (both ${tokenEnv} and ${chatEnv} are required; APPROVAL.md carries only their names)`,
281
+ };
282
+ }
283
+ const actor = resolveHumanActor(request.as === null ? {} : { actor: request.as });
284
+ if (actor === null) {
285
+ return {
286
+ ok: false,
287
+ code: "no-identity",
288
+ message: request.as === null
289
+ ? `no human identity: set ${HUMAN_ACTOR_ENV}=human:<id> or pass --as human:<id>. Every decision this listener records is recorded against it, and nothing here authenticates it`
290
+ : `--as expects a human identity matching human:<id>, got ${JSON.stringify(request.as)}; approvals are human-only`,
291
+ };
292
+ }
293
+ const pollFlag = request.pollTimeout;
294
+ if (pollFlag !== null && !/^\d+$/u.test(pollFlag)) {
295
+ return {
296
+ ok: false,
297
+ code: "poll-timeout",
298
+ message: `--poll-timeout expects a whole number of seconds, got ${JSON.stringify(pollFlag)}`,
299
+ };
300
+ }
301
+ const check = preflightLog(request.logPath);
302
+ if (!check.ok)
303
+ return { ok: false, code: "log-unreadable", message: check.message };
304
+ const payloads = payloadSource(request.payloads);
305
+ if (!payloads.ok) {
306
+ return { ok: false, code: "payloads-unreadable", message: payloads.message };
307
+ }
308
+ const config = {
309
+ token,
310
+ chatId,
311
+ ...(request.apiBase === null ? {} : { apiBase: request.apiBase }),
312
+ ...(pollFlag === null ? {} : { pollTimeoutSeconds: Number.parseInt(pollFlag, 10) }),
313
+ log: request.log,
314
+ // APRV-135. The policy is already loaded above for the variable names; the
315
+ // TTL rides along so the listener can forget delivery bookkeeping no
316
+ // callback can still be honoured against. The channel reads no policy file
317
+ // of its own, and a policy that failed to load declares no TTL, which makes
318
+ // the sweep narrower rather than wider.
319
+ approvalTtlMs: policyLoad.ok ? policyLoad.durations.approvalTtlMs : null,
320
+ // APRV-196. The channel answers a tap on a copy it is not holding open by
321
+ // asking what the LOG says, which is the only thing that knows. Wired here
322
+ // because the log path lives here and nothing under `channels/` reads one.
323
+ describeAction: describeActionFor(request.logPath),
324
+ // APRV-218. Which rows the prompt shows, from `channels.telegram.prompt`,
325
+ // off the same load the credential NAMES and the TTL came from: one read of
326
+ // the policy file answers every question this preparation asks of it. A
327
+ // policy that failed to load declares no layout and gets the slimmed
328
+ // default, because a layout is not a permission and an unrelated typo in a
329
+ // class rule must not silently redecorate a phone screen.
330
+ layout: promptLayoutFor(policyLoad, "telegram"),
331
+ };
332
+ return {
333
+ ok: true,
334
+ setup: {
335
+ channel: new TelegramChannel(config),
336
+ logPath: request.logPath,
337
+ actor,
338
+ json: request.json,
339
+ once: request.once,
340
+ // APRV-216. Read from the policy that was already loaded above for the
341
+ // credential NAMES, so one load answers every question this preparation
342
+ // asks of the policy file, and a policy that failed to load leaves the
343
+ // default (paced) in force rather than a mode nobody chose.
344
+ delivery: telegramDeliveryFor(policyLoad),
345
+ gateOptions: { policy: request.policy },
346
+ tagOptions: {
347
+ policy: request.policy,
348
+ ...(payloads.source === undefined ? {} : { payload: payloads.source }),
349
+ },
350
+ ...(request.gloss === undefined ? {} : { gloss: request.gloss }),
351
+ // APRV-257. The tap's whole configuration: the log to sign, the policy to
352
+ // read the cadence and the keys from, and where the private half may come
353
+ // from — which is the vault beside this log unless an operator said
354
+ // otherwise. No key is read here: custody is resolved at TAP time, so a
355
+ // prompt sitting on a phone holds no key material anywhere.
356
+ checkpoint: {
357
+ logPath: request.logPath,
358
+ policy: request.policy,
359
+ keyFile: null,
360
+ vault: null,
361
+ },
362
+ },
363
+ };
364
+ }
365
+ /**
366
+ * What to tell a human who tapped a button for an action this listener is not
367
+ * holding open (APRV-196).
368
+ *
369
+ * The one place a stale tap gets a real answer instead of a shrug. It reads the
370
+ * VERIFIED log (SPEC.md §11.1(1): a sentence a human reads about what the log
371
+ * says is derived from a log that verified, or it is not derived at all) and
372
+ * answers from `requestState`, the same derivation the gate and the pending
373
+ * queue use. Nothing here decides anything, nothing is appended, and nothing is
374
+ * remembered between calls: an unreadable log answers `null`, which the channel
375
+ * renders as its "not open here" toast.
376
+ *
377
+ * The argument is an action REFERENCE and never a key. The string came off the
378
+ * network, so this hashes the keys the log actually carries and looks for a
379
+ * match; a caller cannot make it describe a request by naming one, and a ref
380
+ * matching nothing simply answers `null`.
381
+ *
382
+ * The walk is over `approval.requested` records, which is the set of things
383
+ * that could ever have had a button. Run only on a stale tap, which is rare by
384
+ * construction.
385
+ */
386
+ export function describeActionFor(logPath) {
387
+ return (actionRef) => {
388
+ const read = readVerifiedRecords(logPath);
389
+ if (!read.ok)
390
+ return null;
391
+ const now = new Date().toISOString();
392
+ for (const record of read.records) {
393
+ if (record.event !== "approval.requested")
394
+ continue;
395
+ const key = record.action_key ?? payloadOf(record)["action_key"];
396
+ if (typeof key !== "string" || actionRefOf(key) !== actionRef)
397
+ continue;
398
+ const derived = requestState(read.records, key, now, null);
399
+ switch (derived.state) {
400
+ case "granted":
401
+ case "rejected":
402
+ case "revoked":
403
+ return `Already ${derived.state} — the recorded answer stands, and nothing was recorded for this tap.`;
404
+ case "expired":
405
+ return "Expired — the approval window closed before an answer arrived; nothing was recorded.";
406
+ case "withdrawn":
407
+ return "Withdrawn — the requester took this back and is no longer waiting; nothing was recorded.";
408
+ case "requested":
409
+ return "Still pending — this copy's buttons are not live here. Tap the newest copy of this request in this chat.";
410
+ default:
411
+ return null;
412
+ }
413
+ }
414
+ return null;
415
+ };
416
+ }
417
+ /** The verb's own front matter: flags in, a prepared listener or an exit code out. */
418
+ function setUp(argv, streams, cwd) {
419
+ const json = argv.includes("--json");
420
+ const parsed = parseFlags(argv, LISTEN_FLAGS);
421
+ if (!parsed.ok) {
422
+ return { kind: "handled", code: usageError(streams, json, parsed.message, TELEGRAM_LISTEN_HELP) };
423
+ }
424
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
425
+ streams.out(`${TELEGRAM_LISTEN_HELP}\n`);
426
+ return { kind: "handled", code: EXIT_OK };
427
+ }
428
+ const extra = parsed.positionals[0];
429
+ if (extra !== undefined) {
430
+ return {
431
+ kind: "handled",
432
+ code: usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`, TELEGRAM_LISTEN_HELP),
433
+ };
434
+ }
435
+ const flags = parsed.flags;
436
+ const selectedGloss = parseGlossOptions(flags, true);
437
+ if (!selectedGloss.ok) {
438
+ return {
439
+ kind: "handled",
440
+ code: usageError(streams, json, selectedGloss.message, TELEGRAM_LISTEN_HELP),
441
+ };
442
+ }
443
+ // Resolved here rather than after the log preflight because the policy is
444
+ // what NAMES the credential variables, and a message about a missing
445
+ // variable must name the one this policy actually asked for.
446
+ const policyFlag = stringFlag(flags, "--policy");
447
+ const dirFlag = stringFlag(flags, "--dir");
448
+ const policy = policyFlag !== null
449
+ ? { file: absolute(policyFlag, cwd) }
450
+ : { dir: dirFlag === null ? cwd : absolute(dirFlag, cwd) };
451
+ const payloadsFlag = stringFlag(flags, "--payloads");
452
+ const prepared = prepareListen({
453
+ logPath: resolvePath(stringFlag(flags, "--log"), DEFAULT_LOG_PATH, cwd),
454
+ policy,
455
+ as: stringFlag(flags, "--as"),
456
+ payloads: payloadsFlag === null ? null : absolute(payloadsFlag, cwd),
457
+ apiBase: stringFlag(flags, "--api-base"),
458
+ pollTimeout: stringFlag(flags, "--poll-timeout"),
459
+ once: boolFlag(flags, "--once"),
460
+ json,
461
+ log: (message) => streams.err(`${message}\n`),
462
+ // APRV-144, on by default, `--no-gloss` to turn it off (APRV-197).
463
+ //
464
+ // The listener is the surface the gloss was asked for: the phone is where
465
+ // an approver reads a request they did not watch being made, with none of
466
+ // the terminal's context around it. The measured 10-15 seconds a gloss
467
+ // costs (see GLOSS_TIMEOUT_MS) is spent inside a dispatch cycle that is
468
+ // already waiting on the network, and it blocks nobody — which is exactly
469
+ // why the terminal walker makes the opposite choice and asks only under
470
+ // `--gloss`: there, a person is sitting in front of the pause.
471
+ //
472
+ // The verb is still the only place a runner is wired: `dispatchPending`
473
+ // defaults to none, so no programmatic driver spawns a subprocess by
474
+ // importing it. Tests that drive THIS function pass `--no-gloss` or set a
475
+ // stub, which is what {@link listenGlossRunner} is for.
476
+ ...glossWiringFor(selectedGloss.options, passphraseEnvFor(loadPolicy(policy)), {
477
+ diagnostic: (reason) => streams.err(`approval: Codex gloss unavailable (${reason}); continuing without it\n`),
478
+ }),
479
+ });
480
+ if (!prepared.ok) {
481
+ // The mapping the verb has always used: a mistyped command line or a
482
+ // missing variable is usage; a path that could not be read is I/O.
483
+ const code = prepared.code === "log-unreadable" || prepared.code === "payloads-unreadable"
484
+ ? ioError(streams, json, prepared.message)
485
+ : usageError(streams, json, prepared.message, TELEGRAM_LISTEN_HELP);
486
+ return { kind: "handled", code };
487
+ }
488
+ return { kind: "run", setup: prepared.setup };
489
+ }
490
+ // ---------------------------------------------------------------------------
491
+ // Dispatch (APRV-55) — one cycle's worth of "put pending requests on the phone"
492
+ // ---------------------------------------------------------------------------
493
+ /**
494
+ * Consecutive failures for one action key after which stderr warnings thin out.
495
+ *
496
+ * Not an attempt limit: the send is retried on every cycle forever (see the
497
+ * module doc). Only the complaining is throttled, to every tenth attempt.
498
+ */
499
+ export const DISPATCH_LOUD_ATTEMPTS = 3;
500
+ /**
501
+ * How long an unannotated delivery stays in the bookkeeping before it is
502
+ * dropped (APRV-196). Twenty-four hours, matching the channel's own
503
+ * `TELEGRAM_DEFAULT_RETENTION_MS`.
504
+ *
505
+ * It is a floor on forgetting and not a deadline for anything: a request that
506
+ * is still pending is never dropped however old it is, because the pending
507
+ * queue is checked first. What this bounds is the memory a long-lived listener
508
+ * holds for questions the log has finished with.
509
+ */
510
+ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
511
+ /**
512
+ * The line that introduces the first batch a listener process sends (APRV-196).
513
+ *
514
+ * **Why a banner and not an edit of the earlier copies.** The incident was a
515
+ * restart re-sending five pending requests with no warning, on top of five
516
+ * copies whose buttons had quietly stopped working. Editing those earlier
517
+ * copies to say "superseded" would read better — and it is not a design that
518
+ * can be relied on, because it requires this process to know their message ids,
519
+ * which a restart by definition does not: SPEC.md §10.3 forbids channel state
520
+ * that is a source of truth, and a crash loses a cache whether or not one is
521
+ * allowed. A design that only works when the crash was gentle is a design that
522
+ * fails on the day it is needed. So the banner is unconditional, and the
523
+ * earlier copies are made harmless instead of tidy: their buttons resolve by
524
+ * action reference to the request this process has just re-delivered
525
+ * (`actionRefOf` in `channels/telegram.ts`), so a human who taps the copy they
526
+ * can see decides the request they meant.
527
+ *
528
+ * It says "started" rather than "restarted" because a listener cannot tell the
529
+ * two apart, having deliberately kept nothing that would let it, and a first
530
+ * start that claimed to be a restart would be this channel's own text lying
531
+ * about the system's history.
532
+ */
533
+ export function bannerLines(pending) {
534
+ const plural = pending === 1 ? "request" : "requests";
535
+ return [
536
+ `LISTENER STARTED — re-sending ${pending} pending ${plural}.`,
537
+ `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}. If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`,
538
+ "Which requests are pending is read from the log on every cycle, never from this chat.",
539
+ ];
540
+ }
541
+ // ---------------------------------------------------------------------------
542
+ // Paced delivery (APRV-216) — one question at a time
543
+ // ---------------------------------------------------------------------------
544
+ /**
545
+ * The summary line that precedes a paced send, and the body of `/queue`.
546
+ *
547
+ * One message, and everything in it is arithmetic on the verified log at the
548
+ * instant it is written: how many requests are pending, how long the oldest has
549
+ * waited, and which classes they are. Nothing is remembered between calls, so
550
+ * two summaries a minute apart can disagree only because the log moved.
551
+ *
552
+ * The class tally is the part worth the space. The count alone says how much
553
+ * work is waiting; the classes say what KIND of work, which is what tells an
554
+ * approver whether the queue is six identical `network.call`s they can walk
555
+ * through or one `policy.edit` they should read carefully.
556
+ */
557
+ export function summaryLines(requests, now) {
558
+ if (requests.length === 0)
559
+ return ["Nothing pending — the queue is empty."];
560
+ const nowMs = Date.parse(now);
561
+ const oldest = requests[0];
562
+ const oldestMs = Date.parse(oldest.requested_ts.value);
563
+ const age = Number.isNaN(nowMs) || Number.isNaN(oldestMs) ? "unknown age" : ageText(nowMs - oldestMs);
564
+ const tally = new Map();
565
+ for (const request of requests) {
566
+ const cls = request.class.value;
567
+ tally.set(cls, (tally.get(cls) ?? 0) + 1);
568
+ }
569
+ const classes = [...tally.entries()]
570
+ .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
571
+ .join(", ");
572
+ return [
573
+ `${String(requests.length)} pending — oldest ${age} — ${classes}`,
574
+ ];
575
+ }
576
+ /**
577
+ * The marker `/queue` puts on the request this listener has selected (APRV-256).
578
+ *
579
+ * It says two things and claims no third. "Selected" is this process's memory of
580
+ * which request it is holding; "card sent earlier" is the delivery bookkeeping
581
+ * recording that a send once succeeded. Neither is evidence that the card is
582
+ * still in the chat: Telegram reports a successful send, never a message's
583
+ * continued existence, and a card can be deleted, buried under a thousand later
584
+ * messages, or lost with the chat history on a reinstall. The old marker,
585
+ * "shown now", asserted present visibility from a past delivery, which is the
586
+ * bug this constant exists to keep fixed.
587
+ */
588
+ const SELECTED_MARKER = " — selected — card sent earlier";
589
+ /**
590
+ * What `/queue` says about itself, immediately under the summary (APRV-256).
591
+ *
592
+ * `/queue` is a summary reply and carries no buttons, so an approver reading it
593
+ * on a phone must not be left hunting this message for controls that were never
594
+ * on it. Where the controls DO live is stated without a direction: a card is
595
+ * somewhere in the chat's history, and "above" was only ever true for the
596
+ * approver who asked while looking straight at it.
597
+ */
598
+ const QUEUE_IS_A_LIST = "This is a list of what the log is holding. It has no decision buttons: a request is decided on its own approval card, wherever that card sits in this chat.";
599
+ /**
600
+ * `/queue`'s reply: the summary, then one numbered line per pending request.
601
+ *
602
+ * Derived, like the summary, from the verified log at reply time and not from
603
+ * anything this process is holding: the numbering is positional and names no
604
+ * button, so a stale copy of this list cannot be used to decide anything. The
605
+ * marker says which one this listener has selected and once delivered, because
606
+ * the question `/queue` is usually asked to answer is "what else is there
607
+ * besides the one I am looking at" — and, since APRV-256, its unhappy twin,
608
+ * "where is the one I am supposed to be looking at".
609
+ *
610
+ * The footer answers that second question the only honest way available to a
611
+ * process whose knowledge of the chat ends at "a send returned success": it
612
+ * says what was sent, says it cannot tell whether the card survived, and then
613
+ * spends its remaining words on recovery rather than reassurance.
614
+ */
615
+ export function queueLines(requests, now, shown) {
616
+ const lines = summaryLines(requests, now);
617
+ if (requests.length === 0)
618
+ return lines;
619
+ const nowMs = Date.parse(now);
620
+ const current = new Set(shown);
621
+ let selected = 0;
622
+ lines.push(QUEUE_IS_A_LIST);
623
+ requests.forEach((request, index) => {
624
+ const key = request.action_key.value;
625
+ const requestedMs = Date.parse(request.requested_ts.value);
626
+ const age = Number.isNaN(nowMs) || Number.isNaN(requestedMs)
627
+ ? "unknown age"
628
+ : ageText(nowMs - requestedMs);
629
+ const task = request.task.value ?? "no task";
630
+ const isSelected = current.has(key);
631
+ if (isSelected)
632
+ selected += 1;
633
+ lines.push(`${String(index + 1)}. ${key} — ${task} — ${request.class.value} — ${age}${isSelected ? SELECTED_MARKER : ""}`);
634
+ });
635
+ if (selected === 0) {
636
+ // Nothing selected is an ordinary state, not a fault: a decided or passed
637
+ // over request leaves the listener holding nothing until the next cycle
638
+ // picks the next one up. Saying so is what stops the reader searching the
639
+ // chat for a card this process never claimed to have sent.
640
+ lines.push("Nothing is selected right now, so no approval card has been sent for any of these. The next one goes out with its buttons on an upcoming listener cycle.");
641
+ return lines;
642
+ }
643
+ // More than one key is marked when the selection is a digest group, which
644
+ // Telegram receives as ONE card covering the set. Hence "a single approval
645
+ // card" in the plural branch and no positional word in either: the reply may
646
+ // be chunked across several messages, so "above" is not this function's to
647
+ // promise even about its own lines.
648
+ const holding = selected === 1
649
+ ? "The request marked selected is the one this listener is holding, and an approval card for it was sent to this chat earlier. The buttons on that card decide it."
650
+ : `The ${String(selected)} requests marked selected are what this listener is holding as one digest, and a single approval card for them was sent to this chat earlier. The buttons on that card decide them.`;
651
+ lines.push(`${holding} This listener cannot tell whether that card is still here.`, "If you cannot find the card, /skip is the recovery: it puts the request at the back of the order and lets the next one through. Nothing is decided by typing it, the request stays pending in the log, and a fresh card is sent on a later listener cycle once the requests ahead of it have had their turn (a cycle can run a little long while a gloss is being written).", "/next gives up your place instead: this listener moves past the request and stops offering it, and no new card is sent for it. It is not a way to ask for the card again.");
652
+ return lines;
653
+ }
654
+ /**
655
+ * The summary line that precedes a review card, and `/queue`'s review footer.
656
+ *
657
+ * Arithmetic on the verified log at the instant it is written, exactly as
658
+ * {@link summaryLines} is: how many samples are awaiting review, how old the
659
+ * oldest is, and which classes they are. The last clause is the one that stops
660
+ * a reader treating this like the pending queue: nothing here is waiting on
661
+ * them, because all of it has already happened.
662
+ */
663
+ export function reviewSummaryLines(cards, now) {
664
+ if (cards.length === 0)
665
+ return ["Nothing awaiting review."];
666
+ const nowMs = Date.parse(now);
667
+ const ages = cards
668
+ .map((card) => nowMs - Date.parse(card.ranAtTs))
669
+ .filter((age) => !Number.isNaN(age) && age >= 0);
670
+ const oldest = ages.length === 0 ? null : Math.max(...ages);
671
+ const tally = new Map();
672
+ for (const card of cards) {
673
+ const cls = card.fields.class.value;
674
+ tally.set(cls, (tally.get(cls) ?? 0) + 1);
675
+ }
676
+ const classes = [...tally.entries()]
677
+ .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
678
+ .join(", ");
679
+ return [
680
+ `${String(cards.length)} awaiting review — oldest ran ${oldest === null ? "at an unknown time" : ageText(oldest)} — ${classes}`,
681
+ "These already ran. Nothing is waiting on you and no card here authorizes anything; a review records what a person thought of work that is already done.",
682
+ ];
683
+ }
684
+ /**
685
+ * Note when a key was delivered, for the retention sweep (APRV-196).
686
+ *
687
+ * The cycle's own `now` rather than a clock read, for the reason every other
688
+ * instant in this file is a parameter: the tests drive dispatch at chosen
689
+ * instants, and a sweep judged against `Date.now()` would be untestable and
690
+ * would disagree with the TTL arithmetic beside it.
691
+ */
692
+ function remember(state, actionKey, now) {
693
+ const ms = Date.parse(now);
694
+ if (!Number.isNaN(ms))
695
+ state.sentAtMs.set(actionKey, ms);
696
+ }
697
+ /** Drop every trace of one action key from the bookkeeping (APRV-196). */
698
+ function forget(state, actionKey) {
699
+ state.delivered.delete(actionKey);
700
+ state.sentAtMs.delete(actionKey);
701
+ state.attempts.delete(actionKey);
702
+ state.annotated.delete(actionKey);
703
+ for (const token of state.warned) {
704
+ if (token.startsWith(`${actionKey}:`))
705
+ state.warned.delete(token);
706
+ }
707
+ }
708
+ export function newDispatchState() {
709
+ return {
710
+ delivered: new Map(),
711
+ sentAtMs: new Map(),
712
+ banner: { sent: false },
713
+ attempts: new Map(),
714
+ warned: new Set(),
715
+ annotated: new Set(),
716
+ paced: { order: [], current: null, summarySent: false, announced: 0 },
717
+ checkpoint: { offered: false, offeredSince: null },
718
+ review: {
719
+ order: [],
720
+ current: null,
721
+ delivered: new Map(),
722
+ summarySent: false,
723
+ announced: 0,
724
+ logSize: null,
725
+ },
726
+ };
727
+ }
728
+ /** The event that records each terminal state, for finding the settling record. */
729
+ const TERMINAL_EVENT = {
730
+ granted: "approval.granted",
731
+ rejected: "approval.rejected",
732
+ revoked: "approval.revoked",
733
+ expired: "approval.expired",
734
+ withdrawn: "approval.withdrawn",
735
+ };
736
+ /**
737
+ * Which of this process's deliveries the log says are settled (APRV-113,
738
+ * generalizing APRV-106's withdrawal-only pass).
739
+ *
740
+ * This is the cross-surface half of the feature. A request answered at the CLI
741
+ * or on the web queue, revoked afterwards, or expired by the daemon, leaves a
742
+ * chat prompt that this process delivered and that nothing else will ever
743
+ * correct — so every cycle asks the verified log what became of each message it
744
+ * sent, and annotates the ones that are over.
745
+ *
746
+ * Reads only VERIFIED records (SPEC.md §11.1(1)). A channel edit is not an
747
+ * enforcement decision, but it is a statement to a human about what the log
748
+ * says, and reading the log unverified to make one would be the same defect in
749
+ * a smaller hat.
750
+ *
751
+ * Never throws: an unreadable or unverifiable log yields an empty list, and the
752
+ * cycle's own `queueError` path already reports that failure. Annotating from a
753
+ * log this process could not verify would be worse than leaving the message.
754
+ */
755
+ function terminalDeliveries(setup, state, now) {
756
+ if (state.delivered.size === 0)
757
+ return [];
758
+ const read = readVerifiedRecords(setup.logPath);
759
+ if (!read.ok)
760
+ return [];
761
+ const settled = [];
762
+ for (const [actionKey, deliveryId] of state.delivered) {
763
+ // APRV-109. An attestation prompt has no `approval.requested` behind it, so
764
+ // `requestState` can say nothing about it and the message would stay armed
765
+ // forever — the exact stale prompt APRV-106 added this pass to retire. Its
766
+ // own derivation answers instead, and the four terminal proposal states map
767
+ // onto headlines this channel already has: an attested proposal reads
768
+ // `granted`, a declined one `rejected`, a superseded one `withdrawn`
769
+ // (a newer proposal is the live question now), and a lapsed one `expired`.
770
+ if (isAttestationActionKey(actionKey)) {
771
+ const proposal = proposalRecords(read.records).find((entry) => entry.action_key === actionKey);
772
+ const derived = proposal === undefined ? null : proposalState(read.records, proposal.seq, now);
773
+ if (derived === null || derived.state === "open")
774
+ continue;
775
+ const outcome = derived.state === "attested"
776
+ ? "granted"
777
+ : derived.state === "declined"
778
+ ? "rejected"
779
+ : derived.state === "superseded"
780
+ ? "withdrawn"
781
+ : "expired";
782
+ const answer = read.records.find((entry) => entry.seq > derived.seq &&
783
+ (entry.event === "policy.updated" || entry.event === "policy.declined") &&
784
+ payloadOf(entry)["sha256"] === derived.sha256);
785
+ settled.push({
786
+ actionKey,
787
+ deliveryId,
788
+ outcome,
789
+ detail: derived.state === "superseded"
790
+ ? [`a later amendment of the same policy replaced this prompt · nothing to do`]
791
+ : derived.state === "expired"
792
+ ? [`no answer arrived before the proposer's deadline · nothing was attested`]
793
+ : answer === undefined
794
+ ? [`recorded at ${utcClock(now)}`]
795
+ : [decidedLine(answer.actor, answer.ts, answer.seq)],
796
+ });
797
+ continue;
798
+ }
799
+ // `ttlMs: null` is correct here rather than lazy, and it is the reason this
800
+ // pass annotates the daemon's `approval.expired` but not a TTL that has
801
+ // merely lapsed by arithmetic: an annotation states what the LOG says, and
802
+ // a lazily-expired request has no record saying anything yet. Loading the
803
+ // policy to compute a deadline would also make a cosmetic edit depend on a
804
+ // file read that can fail. The armed message left behind is refused at the
805
+ // gate, and gets its annotation on the cycle after the daemon writes.
806
+ const derivation = requestState(read.records, actionKey, now, null);
807
+ if (!isTelegramTerminalState(derivation.state))
808
+ continue;
809
+ const outcome = derivation.state;
810
+ const record = read.records.find((entry) => entry.seq === derivation.decisionSeq && entry.event === TERMINAL_EVENT[outcome]);
811
+ const payload = record === undefined ? {} : payloadOf(record);
812
+ const at = record === undefined ? now : record.ts;
813
+ const seq = record?.seq ?? derivation.decisionSeq;
814
+ if (outcome === "withdrawn") {
815
+ const why = typeof payload["reason"] === "string" ? payload["reason"] : "withdrawn";
816
+ const note = typeof payload["note"] === "string" ? `\n${payload["note"]}` : "";
817
+ settled.push({
818
+ actionKey,
819
+ deliveryId,
820
+ outcome,
821
+ // APRV-106's exact line, unchanged: it is what the approver reads.
822
+ detail: [`withdrawn by the requester at ${utcClock(at)} (${why}) · nothing to do${note}`],
823
+ });
824
+ continue;
825
+ }
826
+ if (outcome === "expired") {
827
+ settled.push({
828
+ actionKey,
829
+ deliveryId,
830
+ outcome,
831
+ detail: [
832
+ `no answer arrived before the deadline · recorded at ${utcClock(at)}${seq === null ? "" : ` (seq ${seq})`}`,
833
+ ],
834
+ });
835
+ continue;
836
+ }
837
+ // granted / rejected / revoked: a human answered, somewhere. The actor is
838
+ // the log's, never this listener's configured identity — the answer may
839
+ // have come from another surface entirely.
840
+ settled.push({
841
+ actionKey,
842
+ deliveryId,
843
+ outcome,
844
+ detail: record === undefined
845
+ ? [`recorded at ${utcClock(at)}`]
846
+ : [decidedLine(record.actor, record.ts, record.seq)],
847
+ });
848
+ }
849
+ return settled;
850
+ }
851
+ /**
852
+ * One dispatch cycle: re-derive the pending queue from the verified log, send
853
+ * whatever this process has not already sent.
854
+ *
855
+ * `now` is a parameter, not a clock read: TTL judgment inside
856
+ * {@link buildPendingQueue} is deterministic and the tests drive it at chosen
857
+ * instants. Requests that are decided, expired, or not yet requested are absent
858
+ * from the derivation and so are never sent.
859
+ */
860
+ export async function dispatchPending(setup, streams, state, now) {
861
+ const result = {
862
+ delivered: [],
863
+ failed: [],
864
+ annotated: [],
865
+ digests: [],
866
+ pruned: [],
867
+ };
868
+ const queue = buildPendingQueue(setup.logPath, setup.tagOptions, now);
869
+ if (!queue.ok) {
870
+ result.queueError = { code: queue.code, message: queue.message };
871
+ return result;
872
+ }
873
+ // APRV-106 (withdrawal) generalized by APRV-113 (every terminal state),
874
+ // before the sends. A request this process delivered and that the log now
875
+ // says is settled — granted or rejected at any surface, revoked, expired by
876
+ // the daemon, or withdrawn by its requester — gets its message annotated with
877
+ // that outcome and its buttons taken away, so the approver's phone stops
878
+ // showing a decided question as a live one. What became of it is derived from
879
+ // the VERIFIED log by `terminalDeliveries`, never remembered here; this state
880
+ // only prevents a second edit of the same message.
881
+ for (const settled of terminalDeliveries(setup, state, now)) {
882
+ if (state.annotated.has(settled.actionKey))
883
+ continue;
884
+ state.annotated.add(settled.actionKey);
885
+ try {
886
+ // The action key is what makes this per member on a digest (APRV-115):
887
+ // one delivery id can carry several requests, and settling one of them
888
+ // must leave the others armed.
889
+ await setup.channel.annotate(settled.deliveryId, TELEGRAM_TERMINAL_HEADLINES[settled.outcome], settled.detail, settled.actionKey);
890
+ result.annotated.push({
891
+ action_key: settled.actionKey,
892
+ delivery_id: settled.deliveryId,
893
+ outcome: settled.outcome,
894
+ });
895
+ // APRV-196. The question is over, the message says so, and nothing will
896
+ // ever consult this entry again: the pending queue is derived from the
897
+ // log and a settled request is not in it. Held until now rather than at
898
+ // the moment the log settled, so the annotation pass above still has the
899
+ // message id it needs.
900
+ forget(state, settled.actionKey);
901
+ result.pruned.push({ action_key: settled.actionKey, reason: "settled" });
902
+ if (setup.json) {
903
+ streams.out(`${JSON.stringify({
904
+ event: "annotated",
905
+ action_key: settled.actionKey,
906
+ delivery_id: settled.deliveryId,
907
+ outcome: settled.outcome,
908
+ })}\n`);
909
+ }
910
+ else {
911
+ streams.out(`annotated ${settled.actionKey} (message ${settled.deliveryId}): ${settled.outcome}\n`);
912
+ }
913
+ }
914
+ catch (cause) {
915
+ // APRV-277. Telegram answers an edit that would change nothing with 400
916
+ // "message is not modified", and this pass re-derives its annotations
917
+ // from the verified log rather than remembering which ones landed — so a
918
+ // message this listener (or a previous one, or the channel's own decision
919
+ // path) already annotated produces exactly that. The phone shows the
920
+ // outcome, the annotation stands, and there is nothing to report. Every
921
+ // other 400 and every other failure still reaches the operator below.
922
+ if (isMessageNotModified(cause))
923
+ continue;
924
+ // Cosmetic, and said so on stderr. The gate refuses a tap on the stale
925
+ // buttons anyway (`already-decided`, `request-withdrawn`, `expired`), so
926
+ // nothing can be decided by one.
927
+ streams.err(`approval: telegram could not annotate the ${settled.outcome} ${settled.actionKey} (message ${settled.deliveryId}): ${cause instanceof Error ? cause.message : String(cause)} — the buttons are stale but the gate refuses a tap on them\n`);
928
+ }
929
+ }
930
+ // APRV-196, the other half of the prune: an entry whose message was never
931
+ // annotated (the edit failed, the request lapsed with nothing to say about
932
+ // it, the log settled it while this process was down) would otherwise sit in
933
+ // the map for the life of a listener that `approval up` now keeps running for
934
+ // weeks. Dropped once it is older than the retention window AND the log no
935
+ // longer calls it pending, which is the same pair of conditions the channel's
936
+ // own sweep uses: past that, a re-derivation cannot ask for it and a callback
937
+ // cannot be honoured against it.
938
+ const pendingNow = new Set(queue.requests.map((request) => request.action_key.value));
939
+ const nowMs = Date.parse(now);
940
+ for (const [actionKey, sentAtMs] of state.sentAtMs) {
941
+ if (pendingNow.has(actionKey))
942
+ continue;
943
+ if (Number.isNaN(nowMs) || nowMs - sentAtMs < DISPATCH_RETENTION_MS)
944
+ continue;
945
+ forget(state, actionKey);
946
+ result.pruned.push({ action_key: actionKey, reason: "stale" });
947
+ }
948
+ for (const skipped of queue.skipped) {
949
+ const token = `${skipped.action_key}:${skipped.code}`;
950
+ if (state.warned.has(token))
951
+ continue;
952
+ state.warned.add(token);
953
+ streams.err(`approval: telegram cannot deliver ${skipped.action_key} (${skipped.code}): ${skipped.message}\n`);
954
+ }
955
+ // APRV-257. The checkpoint tap, offered before the requests. Everything about
956
+ // WHETHER to offer is `checkpointOfferFor`, which reads the policy and the
957
+ // verified log; this cycle's only jobs are "has this process already asked"
958
+ // and "is the approver already looking at something".
959
+ //
960
+ // Under `paced` a prompt that went out ends the cycle, because the approver
961
+ // is now looking at a question and sending a request underneath it would be
962
+ // two. Under `burst` it does NOT: burst sends everything pending on every
963
+ // cycle, and `--once` is one cycle, so returning here would leave a startup
964
+ // batch undelivered for the sake of a prompt that blocks nothing.
965
+ const offered = await offerCheckpoint(setup, streams, state, result);
966
+ if (offered && setup.delivery === "paced")
967
+ return result;
968
+ // Everything the log calls pending that this process has not put on the
969
+ // phone. Both modes start here and differ only in how much of it they send.
970
+ const allUndecided = queue.requests.filter((request) => !state.delivered.has(request.action_key.value));
971
+ // APRV-287. A listener that has just started or reconnected re-derives the
972
+ // pending set and re-delivers it. For a queue somebody is waiting on that is
973
+ // exactly right; for one nobody is, it is the flood of 2026-09-06 — a dozen
974
+ // requests whose hooks had long since given up, one message each. The ones
975
+ // older than the hook's wait go out as ONE message with a single reject-all,
976
+ // and the rest are delivered as they always were.
977
+ //
978
+ // `state.banner.sent` is this process's own "have I completed a cycle yet",
979
+ // and it is read here BEFORE the burst banner consumes it: the first cycle of
980
+ // a process is precisely the re-delivery, and a later cycle carries requests
981
+ // that have just been asked.
982
+ // `burst` only, and that is where the harm is. Under `paced` the listener
983
+ // already puts ONE question at a time in front of the approver behind a
984
+ // summary line that names the count, the classes and the oldest age
985
+ // (APRV-216), so a restart there is two messages rather than a dozen and
986
+ // there is no flood to collapse. Collapsing a paced walkthrough would also
987
+ // take away the thing it exists for: an approver working deliberately
988
+ // through an old queue can still approve an old request, and a reject-all
989
+ // summary offers no way to.
990
+ const firstCycle = !state.banner.sent && setup.delivery === "burst";
991
+ const undecided = firstCycle
992
+ ? await collapseStale(setup, streams, state, result, allUndecided, now)
993
+ : allUndecided;
994
+ // APRV-216. Under `paced` this cycle sends at most ONE unit, and `null` means
995
+ // it sends nothing because a question is already in front of the approver.
996
+ // Under `burst` it sends everything, which is what this file did before.
997
+ const selected = setup.delivery === "paced" ? pacedSelection(state, queue.requests, undecided) : undecided;
998
+ if (selected === null) {
999
+ // APRV-299. Nothing to SEND is not nothing to do: either a question is
1000
+ // already in front of the approver (in which case the review pass declines
1001
+ // on its own) or the pending queue is empty, which is exactly when the
1002
+ // retrospective backlog should get the screen.
1003
+ await dispatchReviews(setup, streams, state, result, now);
1004
+ return result;
1005
+ }
1006
+ // APRV-115. The window is this cycle: whatever is being sent right now is
1007
+ // grouped, and a group of similar requests goes out as one digest instead of
1008
+ // one message each. Nothing waits for more — there is no new latency
1009
+ // mechanism here, and a lone request is delivered exactly as it always was.
1010
+ //
1011
+ // The gloss is attached to the SELECTED requests and to no others (APRV-216):
1012
+ // under `paced` the rest of the queue is not being rendered this cycle, and
1013
+ // paying 10-15 seconds of subprocess for a sentence nobody will read before
1014
+ // the next decision would be the terminal walker's mistake made here.
1015
+ const tally = { asked: 0, absent: 0 };
1016
+ const undelivered = selected.map((request) => withGloss(setup, request, tally));
1017
+ // APRV-197. One line per cycle, and only when a model was actually asked and
1018
+ // did not answer. Absence used to be silent by design, which was right for
1019
+ // one request and wrong for a thousand: with APRV-144's ceiling the
1020
+ // subprocess missed every time, and the operator's only evidence was prompts
1021
+ // that looked exactly like prompts from before the feature existed.
1022
+ if (tally.absent > 0) {
1023
+ streams.err(glossAbsenceLine("telegram", tally.absent, tally.asked, GLOSS_TIMEOUT_MS));
1024
+ }
1025
+ // APRV-216. The paced opening: one line saying how many are waiting and what
1026
+ // kind, in front of the one being shown. Sent by the first cycle that has
1027
+ // something to show, and again whenever the pending set has GROWN while
1028
+ // nothing was in front of the approver — which is the only moment a count
1029
+ // they were told is stale in the direction that matters.
1030
+ if (setup.delivery === "paced" && undelivered.length > 0) {
1031
+ const pending = queue.requests.length;
1032
+ if (!state.paced.summarySent || pending > state.paced.announced) {
1033
+ state.paced.summarySent = true;
1034
+ state.paced.announced = pending;
1035
+ try {
1036
+ const deliveryId = await setup.channel.announce(summaryLines(queue.requests, now));
1037
+ result.summary = { delivery_id: deliveryId, pending };
1038
+ }
1039
+ catch (cause) {
1040
+ // Cosmetic, exactly like the banner: the request below it is the point,
1041
+ // and withholding a question because its preamble failed to send would
1042
+ // be the wrong direction on the only axis that matters here.
1043
+ streams.err(`approval: telegram could not send the queue summary: ${cause instanceof Error ? cause.message : String(cause)} — the request below is unaffected\n`);
1044
+ }
1045
+ }
1046
+ }
1047
+ // APRV-196. The STARTUP batch gets one line in front of it, and only that
1048
+ // one: a later cycle delivers what has just been requested, which is a
1049
+ // notification and not a re-delivery, and a banner over it would say
1050
+ // something false. So the flag is consumed by the first cycle that completes
1051
+ // a derivation whether or not it had anything to send — a listener that
1052
+ // started against an empty queue has no re-delivery to announce, ever. See
1053
+ // {@link bannerLines} for why this is a banner rather than an edit of the
1054
+ // copies that came before.
1055
+ const announcing = setup.delivery === "burst" && !state.banner.sent && undelivered.length > 0;
1056
+ state.banner.sent = true;
1057
+ if (announcing) {
1058
+ try {
1059
+ const deliveryId = await setup.channel.announce(bannerLines(undelivered.length));
1060
+ result.banner = { delivery_id: deliveryId, pending: undelivered.length };
1061
+ }
1062
+ catch (cause) {
1063
+ // Cosmetic, and never a reason to withhold the requests it introduces.
1064
+ streams.err(`approval: telegram could not send the re-delivery banner: ${cause instanceof Error ? cause.message : String(cause)} — the requests below are unaffected\n`);
1065
+ }
1066
+ }
1067
+ const deliveredBefore = result.delivered.length;
1068
+ for (const group of groupForDigest(undelivered)) {
1069
+ if (group.length < 2) {
1070
+ await deliverUnits(setup, streams, state, result, group, now);
1071
+ continue;
1072
+ }
1073
+ // B7 first (SPEC.md §10.3): a set carrying more than one distinct payload
1074
+ // where any member is not whole must not be presented as a set at all. The
1075
+ // refusal is reported once and the members go out individually, which is
1076
+ // the same direction every other digest fallback takes.
1077
+ const assembled = assembleBatch(group);
1078
+ if (!assembled.ok) {
1079
+ const token = `${group.map((request) => request.action_key.value).join(",")}:${assembled.code}`;
1080
+ if (!state.warned.has(token)) {
1081
+ state.warned.add(token);
1082
+ streams.err(`approval: telegram cannot digest ${group.length} similar requests (${assembled.code}): ${assembled.message} — sending them one message each instead\n`);
1083
+ }
1084
+ await deliverUnits(setup, streams, state, result, group, now);
1085
+ continue;
1086
+ }
1087
+ const keys = group.map((request) => request.action_key.value);
1088
+ try {
1089
+ const delivered = await setup.channel.notifyBatch(assembled.batch);
1090
+ for (const member of delivered.members) {
1091
+ state.delivered.set(member.action_key, member.delivery_id);
1092
+ remember(state, member.action_key, now);
1093
+ state.attempts.delete(member.action_key);
1094
+ result.delivered.push({ action_key: member.action_key, delivery_id: member.delivery_id });
1095
+ }
1096
+ if (delivered.digestId !== null) {
1097
+ result.digests.push({
1098
+ delivery_id: delivered.digestId,
1099
+ batch_delivery_id: delivered.batchDeliveryId,
1100
+ action_keys: delivered.members.map((member) => member.action_key),
1101
+ });
1102
+ }
1103
+ report(setup, streams, delivered.digestId, delivered.members);
1104
+ }
1105
+ catch (cause) {
1106
+ // Every member stays out of `delivered`, so the next cycle re-sends the
1107
+ // whole group. A half-sent digest arms nothing (the member nonces are
1108
+ // registered only once the message with the buttons exists), so the cost
1109
+ // is a duplicate prompt and never a live button on a partial set.
1110
+ const message = cause instanceof Error ? cause.message : String(cause);
1111
+ for (const actionKey of keys) {
1112
+ const attempts = (state.attempts.get(actionKey) ?? 0) + 1;
1113
+ state.attempts.set(actionKey, attempts);
1114
+ result.failed.push({ action_key: actionKey, attempts, message });
1115
+ }
1116
+ }
1117
+ }
1118
+ // APRV-216. What is in front of the approver is what actually reached the
1119
+ // chat, which is why this reads the RESULT rather than the selection: a send
1120
+ // that failed leaves `current` null, so the next cycle retries the same unit
1121
+ // instead of standing still behind a message nobody ever received.
1122
+ if (setup.delivery === "paced") {
1123
+ const sent = result.delivered.slice(deliveredBefore).map((entry) => entry.action_key);
1124
+ state.paced.current = sent.length === 0 ? null : sent;
1125
+ }
1126
+ // APRV-299, last and least urgent. The retrospective backlog is offered only
1127
+ // when nothing this cycle put a question in front of the approver: a pending
1128
+ // request is somebody waiting, and a sampled action is somebody's work that
1129
+ // already finished. Reconciliation runs even when no card goes out, so a
1130
+ // sample reviewed at a terminal releases the walkthrough on the next cycle.
1131
+ await dispatchReviews(setup, streams, state, result, now);
1132
+ return result;
1133
+ }
1134
+ /**
1135
+ * One cycle's worth of "put the retrospective backlog in front of the approver"
1136
+ * (APRV-299).
1137
+ *
1138
+ * The same shape as the paced request walkthrough and for the same reasons. The
1139
+ * LOG decides what exists: `openReviewCards` re-derives the open samples from
1140
+ * verified records every cycle, so a sample reviewed anywhere — this card, a
1141
+ * terminal, another listener — leaves the order and the shown slot, and nothing
1142
+ * this process remembers can keep a reviewed sample on the phone or an open one
1143
+ * off it. The ORDER is this process's, seeded from log order and rearranged by
1144
+ * `/skip` alone. And ONE AT A TIME: a card in front of the approver means this
1145
+ * cycle sends nothing.
1146
+ *
1147
+ * Never fatal, at startup or after. A backlog that cannot be derived is a
1148
+ * stderr line and a `reviewError` on the result; a card that fails to send
1149
+ * leaves the sample undelivered and the next cycle retries it. Neither can lose
1150
+ * a review, because the sample is in the log and the log is what is read.
1151
+ */
1152
+ /** The log's size in bytes, or `null` when it cannot be stated. */
1153
+ function logSizeOf(logPath) {
1154
+ try {
1155
+ return statSync(logPath).size;
1156
+ }
1157
+ catch {
1158
+ return null;
1159
+ }
1160
+ }
1161
+ async function dispatchReviews(setup, streams, state, result, now) {
1162
+ const review = state.review;
1163
+ // The cost guard. A card is already in front of the approver and the log has
1164
+ // not grown since this pass last ran, so nothing can have been reviewed and
1165
+ // nothing can have been sampled: skip the verified walk. Sound because the
1166
+ // log is append-only; a `null` size, an unreadable stat, or any growth at all
1167
+ // falls through to the ordinary derivation.
1168
+ const size = logSizeOf(setup.logPath);
1169
+ if (review.current !== null && size !== null && size === review.logSize)
1170
+ return;
1171
+ const built = openReviewCards(setup.logPath, setup.tagOptions.payload === undefined ? {} : { payload: setup.tagOptions.payload });
1172
+ if (!built.ok) {
1173
+ result.reviewError = { code: built.code, message: built.message };
1174
+ return;
1175
+ }
1176
+ review.logSize = size;
1177
+ const open = built.cards;
1178
+ const openSeqs = new Set(open.map((card) => card.sampleSeq));
1179
+ review.order = review.order.filter((seq) => openSeqs.has(seq));
1180
+ const known = new Set(review.order);
1181
+ for (const card of open) {
1182
+ if (!known.has(card.sampleSeq))
1183
+ review.order.push(card.sampleSeq);
1184
+ }
1185
+ // Deleting the current entry mid-iteration is defined behaviour for a Map.
1186
+ for (const seq of review.delivered.keys()) {
1187
+ if (!openSeqs.has(seq))
1188
+ review.delivered.delete(seq);
1189
+ }
1190
+ if (review.current !== null && !openSeqs.has(review.current))
1191
+ review.current = null;
1192
+ // A count the approver was told that is now too high is the number they
1193
+ // watched go down, not growth to announce again.
1194
+ review.announced = Math.min(review.announced, open.length);
1195
+ if (review.current !== null)
1196
+ return;
1197
+ if (setup.delivery === "paced" && state.paced.current !== null)
1198
+ return;
1199
+ const nextSeq = review.order.find((seq) => !review.delivered.has(seq));
1200
+ if (nextSeq === undefined)
1201
+ return;
1202
+ const card = open.find((entry) => entry.sampleSeq === nextSeq);
1203
+ if (card === undefined)
1204
+ return;
1205
+ if (!review.summarySent || open.length > review.announced) {
1206
+ review.summarySent = true;
1207
+ review.announced = open.length;
1208
+ try {
1209
+ const deliveryId = await setup.channel.announce(reviewSummaryLines(open, now));
1210
+ result.reviewSummary = { delivery_id: deliveryId, open: open.length };
1211
+ }
1212
+ catch (cause) {
1213
+ // Cosmetic, exactly like the request summary: the card below it is the
1214
+ // point, and withholding it because its preamble failed would be the
1215
+ // wrong direction on the only axis that matters.
1216
+ streams.err(`approval: telegram could not send the review summary: ${cause instanceof Error ? cause.message : String(cause)} — the card below is unaffected\n`);
1217
+ }
1218
+ }
1219
+ const actionKey = card.fields.action_key.value;
1220
+ try {
1221
+ const deliveryId = await setup.channel.offerReview(card);
1222
+ review.delivered.set(nextSeq, deliveryId);
1223
+ review.current = nextSeq;
1224
+ result.reviewCard = {
1225
+ delivery_id: deliveryId,
1226
+ sample_seq: nextSeq,
1227
+ action_key: actionKey,
1228
+ };
1229
+ if (setup.json) {
1230
+ streams.out(`${JSON.stringify({
1231
+ event: "review_offered",
1232
+ sample_seq: nextSeq,
1233
+ action_key: actionKey,
1234
+ delivery_id: deliveryId,
1235
+ })}\n`);
1236
+ }
1237
+ else {
1238
+ streams.out(`offered a review of sample seq ${String(nextSeq)} (${actionKey}, message ${deliveryId})\n`);
1239
+ }
1240
+ }
1241
+ catch (cause) {
1242
+ // The sample stays open and undelivered, so the next cycle offers it again.
1243
+ streams.err(`approval: telegram could not offer the review of sample seq ${String(nextSeq)} (${actionKey}): ${cause instanceof Error ? cause.message : String(cause)} — the sample stays open and the next cycle tries again\n`);
1244
+ }
1245
+ }
1246
+ /**
1247
+ * How old a pending request must be, on a listener's first cycle, to be one
1248
+ * nobody is waiting on (APRV-287).
1249
+ *
1250
+ * The hook's own wait PLUS its retry grace, read from the same module the hook
1251
+ * reads (`core/harness-wait.ts`), because two numbers would be two answers to
1252
+ * "is anybody still holding this". Past the wait alone a hook process has
1253
+ * stopped blocking and a retry can still adopt the question, so those are
1254
+ * ordinary pending requests. Past the wait and the grace together nothing will
1255
+ * adopt it: that is the moment the hook itself takes such a request back, and a
1256
+ * request still pending here is one whose session never came back at all —
1257
+ * exactly the dozen that arrived on a phone behind a dead daemon on
1258
+ * 2026-09-06.
1259
+ *
1260
+ * Collapsing is not deciding. These stay pending, listable by `/queue`, and
1261
+ * decidable from any copy already delivered; what changes is how many messages
1262
+ * it takes to say they are there.
1263
+ */
1264
+ export const COLLAPSE_STALE_AFTER_MS = abandonedAfterMs(HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS);
1265
+ /**
1266
+ * How many stale requests it takes to collapse them (APRV-287).
1267
+ *
1268
+ * Two. A lone stale request keeps its own card, with its payload and both
1269
+ * buttons, because collapsing it would take away the ability to approve it and
1270
+ * save nobody a message. A flood starts at two.
1271
+ */
1272
+ const COLLAPSE_MIN = 2;
1273
+ /**
1274
+ * A DURATION in words, which is not what {@link ageText} renders.
1275
+ *
1276
+ * `ageText` says how long ago something happened ("5 min ago"), and these two
1277
+ * numbers are lengths of time rather than instants: writing "older than the
1278
+ * hook's 5 min ago retry grace" would be a sentence about the wrong kind of
1279
+ * thing.
1280
+ */
1281
+ function durationText(ms) {
1282
+ if (ms < 60_000)
1283
+ return `${String(Math.round(ms / 1000))}s`;
1284
+ const minutes = Math.round(ms / 60_000);
1285
+ return `${String(minutes)}m`;
1286
+ }
1287
+ /** The computed lines a collapsed re-delivery leads with (APRV-287). */
1288
+ export function staleLines(requests, now) {
1289
+ const nowMs = Date.parse(now);
1290
+ const ages = requests
1291
+ .map((request) => nowMs - Date.parse(request.requested_ts.value))
1292
+ .filter((age) => !Number.isNaN(age));
1293
+ const oldest = ages.length === 0 ? null : Math.max(...ages);
1294
+ const tally = new Map();
1295
+ for (const request of requests) {
1296
+ const cls = request.class.value;
1297
+ tally.set(cls, (tally.get(cls) ?? 0) + 1);
1298
+ }
1299
+ return [
1300
+ `${String(requests.length)} pending requests, all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`,
1301
+ `oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
1302
+ `classes: ${[...tally.entries()]
1303
+ .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
1304
+ .join(", ")}`,
1305
+ "collapsed into this one message because the tool calls that asked have stopped waiting; a decision on any of them can still authorize an identical retry",
1306
+ ];
1307
+ }
1308
+ /**
1309
+ * Put the requests nobody is waiting on into ONE message, and hand back the
1310
+ * ones that still get a message each (APRV-287).
1311
+ *
1312
+ * Called on a process's first cycle only, which is exactly a daemon start or a
1313
+ * listener reconnect. Everything it does is bookkeeping in the sense SPEC.md
1314
+ * §10.3 fixes: the pending set is re-derived from the verified log every cycle,
1315
+ * so a summary that fails to send, or a process that forgets it sent one,
1316
+ * degrades to showing those requests again, and never to a pending request
1317
+ * nobody is shown.
1318
+ */
1319
+ async function collapseStale(setup, streams, state, result, undecided, now) {
1320
+ const nowMs = Date.parse(now);
1321
+ if (Number.isNaN(nowMs))
1322
+ return undecided;
1323
+ const age = (request) => {
1324
+ const at = Date.parse(request.requested_ts.value);
1325
+ return Number.isNaN(at) ? 0 : nowMs - at;
1326
+ };
1327
+ const stale = undecided.filter((request) => age(request) >= COLLAPSE_STALE_AFTER_MS);
1328
+ if (stale.length < COLLAPSE_MIN)
1329
+ return undecided;
1330
+ let delivered = null;
1331
+ try {
1332
+ delivered = await setup.channel.notifyStale(stale, { lines: staleLines(stale, now) });
1333
+ }
1334
+ catch (cause) {
1335
+ delivered = null;
1336
+ streams.err(`approval: telegram could not send the collapsed re-delivery of ${String(stale.length)} stale requests: ${cause instanceof Error ? cause.message : String(cause)} — they are delivered one message each instead\n`);
1337
+ }
1338
+ if (delivered === null || delivered.digestId === null) {
1339
+ // Degrades to showing the requests again, which is this bookkeeping's rule.
1340
+ return undecided;
1341
+ }
1342
+ const digestId = delivered.digestId;
1343
+ for (const member of delivered.members) {
1344
+ state.delivered.set(member.action_key, member.delivery_id);
1345
+ remember(state, member.action_key, now);
1346
+ state.attempts.delete(member.action_key);
1347
+ result.delivered.push({ action_key: member.action_key, delivery_id: member.delivery_id });
1348
+ }
1349
+ result.collapsed = {
1350
+ delivery_id: digestId,
1351
+ action_keys: delivered.members.map((member) => member.action_key),
1352
+ oldest_ms: Math.max(...stale.map((request) => age(request))),
1353
+ };
1354
+ report(setup, streams, digestId, delivered.members);
1355
+ const collapsedKeys = new Set(delivered.members.map((member) => member.action_key));
1356
+ return undecided.filter((request) => !collapsedKeys.has(request.action_key.value));
1357
+ }
1358
+ /**
1359
+ * The checkpoint prompt, at most one outstanding and never a nag (APRV-257).
1360
+ *
1361
+ * Returns `true` when this cycle sent one; the caller decides what that means,
1362
+ * and under `paced` it means the cycle is over. It costs the queue one cycle
1363
+ * and never more, because a checkpoint prompt is never `paced.current` —
1364
+ * nothing releases it, so nothing could be left waiting on it.
1365
+ *
1366
+ * Three conditions, and each one is a different failure it avoids:
1367
+ *
1368
+ * 1. **Nothing already in front of the approver** (`paced` only). The whole
1369
+ * content of APRV-216 is one question at a time, and a checkpoint is a
1370
+ * question.
1371
+ * 2. **Not already asked for this lapse.** `state.checkpoint.offeredSince`
1372
+ * holds the newest checkpoint's seq at the moment the last prompt went out.
1373
+ * A lapsed cadence produces an offer on every cycle for as long as it lasts,
1374
+ * and a listener that sent one every cycle would be the nag APRV-220 refused
1375
+ * to build. The value moves only when a checkpoint actually LANDS, which is
1376
+ * also when due-ness goes false — so the next prompt comes from the next
1377
+ * lapse.
1378
+ * 3. **The policy asked for it.** No cadence, or no key, and there is no offer
1379
+ * at all; `checkpointOfferFor` decides that and this function never second-
1380
+ * guesses it.
1381
+ *
1382
+ * A send that fails leaves `offered` false, so the next cycle tries again — the
1383
+ * same direction a failed request send takes, and for the same reason.
1384
+ */
1385
+ async function offerCheckpoint(setup, streams, state, result) {
1386
+ if (setup.delivery === "paced" && state.paced.current !== null)
1387
+ return false;
1388
+ const offer = checkpointOfferFor(setup.checkpoint);
1389
+ if (offer === null)
1390
+ return false;
1391
+ if (state.checkpoint.offered && state.checkpoint.offeredSince === offer.since)
1392
+ return false;
1393
+ try {
1394
+ const deliveryId = await setup.channel.offerCheckpoint({
1395
+ head: offer.head,
1396
+ lines: checkpointPromptLines(offer),
1397
+ });
1398
+ state.checkpoint.offered = true;
1399
+ state.checkpoint.offeredSince = offer.since;
1400
+ result.checkpoint = {
1401
+ delivery_id: deliveryId,
1402
+ seq: offer.head.seq,
1403
+ hash: offer.head.hash,
1404
+ };
1405
+ if (setup.json) {
1406
+ streams.out(`${JSON.stringify({
1407
+ event: "checkpoint_offered",
1408
+ delivery_id: deliveryId,
1409
+ seq: offer.head.seq,
1410
+ hash: offer.head.hash,
1411
+ })}\n`);
1412
+ }
1413
+ else {
1414
+ streams.out(`offered a checkpoint of seq ${String(offer.head.seq)} (message ${deliveryId})\n`);
1415
+ }
1416
+ return true;
1417
+ }
1418
+ catch (cause) {
1419
+ // Never fatal, not even at startup. A checkpoint that is due is a warning
1420
+ // at every layer, so a listener that refused to start because it could not
1421
+ // ASK for one would have turned a warning into an outage.
1422
+ streams.err(`approval: telegram could not offer a checkpoint of seq ${String(offer.head.seq)}: ${cause instanceof Error ? cause.message : String(cause)} — nothing was signed and the next cycle tries again\n`);
1423
+ return false;
1424
+ }
1425
+ }
1426
+ /**
1427
+ * What a paced cycle sends: one unit, or nothing (APRV-216).
1428
+ *
1429
+ * It re-reconciles the walkthrough against the verified log first, and that
1430
+ * order is the whole design:
1431
+ *
1432
+ * 1. **The log decides what exists.** Keys the derivation no longer carries
1433
+ * leave the order and leave the shown unit, however they left the queue —
1434
+ * granted here, rejected from the terminal, withdrawn by their requester,
1435
+ * expired by the daemon. So a decision made anywhere advances the
1436
+ * walkthrough, and nothing this process remembers can keep a settled
1437
+ * question on the phone or a live one off it.
1438
+ * 2. **The order is this process's.** Newly pending keys join the back in log
1439
+ * order (oldest first); `/skip` is the only thing that rearranges it.
1440
+ * 3. **One at a time.** A unit still holding a pending member means a question
1441
+ * is in front of the approver, and this cycle sends nothing.
1442
+ *
1443
+ * The unit is a digest group rather than a single request when the oldest
1444
+ * pending request has similar company (APRV-115): what is being paced is the
1445
+ * approver's ATTENTION, and four identical `network.call`s are one thing to
1446
+ * read whether or not they are four things to decide.
1447
+ */
1448
+ function pacedSelection(state, pending, undelivered) {
1449
+ const paced = state.paced;
1450
+ const pendingKeys = pending.map((request) => request.action_key.value);
1451
+ const pendingSet = new Set(pendingKeys);
1452
+ paced.order = paced.order.filter((key) => pendingSet.has(key));
1453
+ const known = new Set(paced.order);
1454
+ for (const key of pendingKeys) {
1455
+ if (!known.has(key))
1456
+ paced.order.push(key);
1457
+ }
1458
+ if (paced.current !== null) {
1459
+ paced.current = paced.current.filter((key) => pendingSet.has(key));
1460
+ if (paced.current.length === 0)
1461
+ paced.current = null;
1462
+ }
1463
+ // A count the approver was told that is now too high is not a growth to
1464
+ // announce; it is the number they watched go down. Clamping here is what
1465
+ // makes "the pending set grew" mean growth from wherever it actually is.
1466
+ paced.announced = Math.min(paced.announced, pendingKeys.length);
1467
+ if (paced.current !== null)
1468
+ return null;
1469
+ const available = new Map(undelivered.map((request) => [request.action_key.value, request]));
1470
+ const nextKey = paced.order.find((key) => available.has(key));
1471
+ if (nextKey === undefined)
1472
+ return null;
1473
+ return (groupForDigest(undelivered).find((group) => group.some((request) => request.action_key.value === nextKey)) ?? null);
1474
+ }
1475
+ /**
1476
+ * The request, plus a model's one-sentence gloss when one can be had (APRV-144).
1477
+ *
1478
+ * The attaching itself moved to `cli/gloss-attach.ts` in APRV-197, when the
1479
+ * terminal channel needed the same thing; what stays here is the listener's own
1480
+ * two decisions. Whether to ask at all is `setup.gloss`, which the verb sets
1481
+ * only under `--gloss`. What to do with the answer is nothing, except count it:
1482
+ * absence used to be silent by design, and with the old 2s ceiling that made a
1483
+ * chronically failing subprocess indistinguishable from a feature nobody built.
1484
+ *
1485
+ * Once per request, not once per cycle: a request already in `delivered` never
1486
+ * reaches this.
1487
+ */
1488
+ function withGloss(setup, request, tally) {
1489
+ if (setup.gloss === undefined)
1490
+ return request;
1491
+ const attached = attachGloss(request, setup.gloss);
1492
+ if (attached.outcome !== "opaque")
1493
+ tally.asked += 1;
1494
+ if (attached.outcome === "absent")
1495
+ tally.absent += 1;
1496
+ return attached.request;
1497
+ }
1498
+ /** Deliver each request as its own prompt: the pre-digest path, unchanged. */
1499
+ async function deliverUnits(setup, streams, state, result, requests, now) {
1500
+ for (const request of requests) {
1501
+ const actionKey = request.action_key.value;
1502
+ try {
1503
+ const deliveryId = await setup.channel.notify(request);
1504
+ state.delivered.set(actionKey, deliveryId);
1505
+ remember(state, actionKey, now);
1506
+ state.attempts.delete(actionKey);
1507
+ result.delivered.push({ action_key: actionKey, delivery_id: deliveryId });
1508
+ report(setup, streams, null, [{ action_key: actionKey, delivery_id: deliveryId }]);
1509
+ }
1510
+ catch (cause) {
1511
+ // The key stays out of `delivered`, so the next cycle tries again.
1512
+ const attempts = (state.attempts.get(actionKey) ?? 0) + 1;
1513
+ state.attempts.set(actionKey, attempts);
1514
+ const message = cause instanceof Error ? cause.message : String(cause);
1515
+ result.failed.push({ action_key: actionKey, attempts, message });
1516
+ }
1517
+ }
1518
+ }
1519
+ /** One `notified` line (or JSON object) per request actually delivered. */
1520
+ function report(setup, streams, digestId, members) {
1521
+ for (const member of members) {
1522
+ if (setup.json) {
1523
+ streams.out(`${JSON.stringify({
1524
+ event: "notified",
1525
+ action_key: member.action_key,
1526
+ delivery_id: member.delivery_id,
1527
+ ...(digestId === null ? {} : { digest_id: digestId, digest_size: members.length }),
1528
+ })}\n`);
1529
+ }
1530
+ else {
1531
+ streams.out(digestId === null
1532
+ ? `notified ${member.action_key} (message ${member.delivery_id})\n`
1533
+ : `notified ${member.action_key} (digest ${digestId}, ${members.length} requests)\n`);
1534
+ }
1535
+ }
1536
+ }
1537
+ /**
1538
+ * Report a steady-state cycle's problems on stderr. Startup reports its own,
1539
+ * as exit codes, in {@link runListener}.
1540
+ */
1541
+ function reportCycle(result, streams) {
1542
+ if (result.queueError !== undefined) {
1543
+ streams.err(`approval: telegram cannot read the pending queue (${result.queueError.code}): ${result.queueError.message} — retrying next cycle\n`);
1544
+ }
1545
+ for (const failure of result.failed) {
1546
+ // Loud for the first few, then every tenth: an outage must stay visible
1547
+ // without turning the operator's terminal into a log of one message.
1548
+ if (failure.attempts > DISPATCH_LOUD_ATTEMPTS && failure.attempts % 10 !== 0)
1549
+ continue;
1550
+ streams.err(`approval: telegram sendMessage failed for ${failure.action_key} (attempt ${failure.attempts}): ${failure.message} — still pending, retrying next cycle\n`);
1551
+ }
1552
+ }
1553
+ /** The decision handler: the only thing this process does with a button press. */
1554
+ function handlerFor(setup, streams) {
1555
+ return (decision) => {
1556
+ const result = recordChannelDecision(setup.logPath, decision, { actor: setup.actor, channel: "telegram" }, setup.gateOptions);
1557
+ if (setup.json) {
1558
+ streams.out(`${JSON.stringify({
1559
+ event: "decision",
1560
+ action_key: decision.action_key,
1561
+ decision: decision.decision,
1562
+ ok: result.outcome.ok,
1563
+ ...(result.outcome.ok
1564
+ ? { seq: result.outcome.record.seq, state: result.outcome.state }
1565
+ : { code: result.outcome.code }),
1566
+ // The token is NEVER in the JSON stream either: --json output is the
1567
+ // thing most likely to be piped into a file or a log aggregator.
1568
+ token_issued: result.token !== undefined,
1569
+ })}\n`);
1570
+ }
1571
+ else if (result.outcome.ok) {
1572
+ streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${setup.actor} via telegram\n`);
1573
+ }
1574
+ else {
1575
+ streams.err(`approval: telegram decision refused (${result.outcome.code}): ${result.outcome.message}\n`);
1576
+ }
1577
+ // APRV-17: the raw token is printed exactly once, here, on this terminal's
1578
+ // stdout. It is not sent to Telegram (a chat transcript is not a secrets
1579
+ // channel), not written to the log (which holds only its sha256), and not
1580
+ // handed back to the channel. Once this line scrolls away it is gone.
1581
+ if (result.token !== undefined) {
1582
+ // APRV-102: the shared rule-boxed panel, with the Telegram clause of the
1583
+ // notice — the one surface where "not sent to Telegram" is a fact the
1584
+ // reader might otherwise doubt, having just decided in a chat window.
1585
+ streams.out(`${tokenPanel(style(), decision.action_key, result.token, TOKEN_NOTICE_TELEGRAM)}\n`);
1586
+ }
1587
+ return result.outcome;
1588
+ };
1589
+ }
1590
+ /**
1591
+ * What a checkpoint tap does, on the machine the listener runs on (APRV-257).
1592
+ *
1593
+ * The signing happens HERE, in the listener's process, and that is the whole
1594
+ * point of the tap: this process holds the vault passphrase because a HUMAN
1595
+ * exported it into the shell they started it from, and `core/child-env.ts`
1596
+ * strips that variable from every child an agent's session spawns. No agent can
1597
+ * arrange for a process that reaches this function with a key.
1598
+ *
1599
+ * Nothing about the head is re-derived. The `(seq, hash)` comes back from the
1600
+ * channel exactly as it was put on the screen, and
1601
+ * {@link ../core/checkpoint.js appendCheckpointAt} signs that and checks the
1602
+ * log still carries it. A handler that quietly re-read the head would be
1603
+ * putting a human's key over bytes nobody looked at.
1604
+ *
1605
+ * `Not now` appends nothing and says so. It is not a rejection: there is no
1606
+ * request here to reject, and a checkpoint that is owed is a warning at every
1607
+ * layer and a refusal at none.
1608
+ */
1609
+ export function checkpointHandlerFor(setup, streams) {
1610
+ return (tap) => {
1611
+ if (!tap.sign) {
1612
+ return {
1613
+ ok: true,
1614
+ headline: "NOT SIGNED",
1615
+ detail: [
1616
+ `The checkpoint of seq ${String(tap.head.seq)} was declined. Nothing was appended.`,
1617
+ "A checkpoint that is owed is a warning and never a refusal; you will be asked again when the next one is due.",
1618
+ ],
1619
+ toast: "Not now.",
1620
+ };
1621
+ }
1622
+ const result = signCheckpointOffer(setup.checkpoint, tap.head, setup.actor, "telegram", process.cwd());
1623
+ const lines = checkpointSignedLines(result);
1624
+ const [headline, ...detail] = lines;
1625
+ if (setup.json) {
1626
+ streams.out(`${JSON.stringify({
1627
+ event: "checkpoint",
1628
+ ok: result.ok,
1629
+ seq: result.ok ? result.seq : null,
1630
+ signed: tap.head,
1631
+ ...(result.ok ? {} : { code: result.code }),
1632
+ })}\n`);
1633
+ }
1634
+ else if (result.ok) {
1635
+ streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${setup.actor} via telegram\n`);
1636
+ }
1637
+ else {
1638
+ streams.err(`approval: telegram checkpoint refused (${result.code}): ${result.message}\n`);
1639
+ }
1640
+ return {
1641
+ ok: result.ok,
1642
+ headline: headline ?? "NOT CHECKPOINTED",
1643
+ detail,
1644
+ toast: result.ok ? "Signed." : "Not signed — the message says why.",
1645
+ };
1646
+ };
1647
+ }
1648
+ /**
1649
+ * What a review tap does: the human-only `reviewSample`, and nothing else
1650
+ * (APRV-299).
1651
+ *
1652
+ * The one path from a button on a phone to an `audit.reviewed`, and it is the
1653
+ * SAME path `approval audit review` takes — same function, same refusals, same
1654
+ * record shape — so a reaction given on a card and one given at a terminal are
1655
+ * indistinguishable to `approval feedback`, which is the whole of AC3.
1656
+ *
1657
+ * The actor is `setup.actor`, the human identity this listener was configured
1658
+ * with (`--as` / `APPROVAL_HUMAN`), exactly as a grant's is. It is never read
1659
+ * off the tap, never off the callback, and never out of a payload field: this
1660
+ * channel does not authenticate the person who pressed the button, and SPEC.md
1661
+ * §11's config-declared identity is what a review is recorded against. Anyone
1662
+ * who can reach the configured chat reviews as that actor, which is the same
1663
+ * trust boundary a tapped grant already stands on.
1664
+ *
1665
+ * The reaction is passed through untouched and NOTHING here reads it (SPEC.md
1666
+ * §11.1 invariant 10): it is a field on a record, chosen by a human, on its way
1667
+ * to the log.
1668
+ */
1669
+ export function reviewHandlerFor(setup, streams) {
1670
+ return (tap) => {
1671
+ const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, setup.actor, tap.note ?? null, {
1672
+ ...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
1673
+ verdict: tap.verdict,
1674
+ ...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
1675
+ });
1676
+ if (!result.ok) {
1677
+ // SPEC.md §11.1 invariant 6: the code is machine-readable and distinct,
1678
+ // and it reaches the approver as itself. The card carries both halves —
1679
+ // the code on its own line, the message under it — because the codes that
1680
+ // get here are ones a reviewer can act on: `reaction-conflicts-verdict`
1681
+ // asks which half they meant, `note-required` asks for words.
1682
+ streams.err(`approval: telegram review refused (${result.code}): ${result.message}\n`);
1683
+ if (setup.json) {
1684
+ streams.out(`${JSON.stringify({
1685
+ event: "review",
1686
+ ok: false,
1687
+ sample_seq: tap.sampleSeq,
1688
+ verdict: tap.verdict,
1689
+ reaction: tap.reaction ?? null,
1690
+ code: result.code,
1691
+ })}\n`);
1692
+ }
1693
+ return {
1694
+ ok: false,
1695
+ headline: TELEGRAM_NOT_RECORDED,
1696
+ detail: [result.code, result.message],
1697
+ toast: "Not recorded — the card says why.",
1698
+ };
1699
+ }
1700
+ const obligation = result.obligation;
1701
+ if (setup.json) {
1702
+ streams.out(`${JSON.stringify({
1703
+ event: "review",
1704
+ ok: true,
1705
+ seq: result.record.seq,
1706
+ sample_seq: result.subject.seq,
1707
+ action_key: result.subject.actionKey,
1708
+ verdict: tap.verdict,
1709
+ reaction: tap.reaction ?? null,
1710
+ obligation_seq: obligation === null ? null : obligation.seq,
1711
+ })}\n`);
1712
+ }
1713
+ else {
1714
+ streams.out(`reviewed sample at seq ${String(result.subject.seq)} (action ${result.subject.actionKey ?? "-"}) at seq ${String(result.record.seq)} by ${setup.actor} via telegram${tap.verdict === "denied" ? " — DENIED" : ""}${tap.reaction === undefined ? "" : ` — reaction: ${tap.reaction} (guidance, not policy)`}\n`);
1715
+ }
1716
+ const detail = [
1717
+ `recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict}`,
1718
+ ...(tap.reaction === undefined
1719
+ ? []
1720
+ : [`reaction: ${tap.reaction} — guidance, not policy; it changes no verdict and no budget`]),
1721
+ ...(tap.note === undefined || tap.note.trim().length === 0
1722
+ ? []
1723
+ : [`note: ${tap.note.trim()}`]),
1724
+ ];
1725
+ if (obligation !== null) {
1726
+ // APRV-127's reconciliation, said on the reply: a denial cannot undo
1727
+ // anything, and what it DOES is open an obligation somebody has to
1728
+ // discharge. An approver who denied on a phone learns that here rather
1729
+ // than from a health surface later.
1730
+ const shape = String(obligation.payload?.["obligation"] ?? "");
1731
+ detail.push(shape === "gated-revert"
1732
+ ? `reconciliation obligation at seq ${String(obligation.seq)}: GATED REVERT. The action was declared reversible, so undo it through the gate and close this with \`approval audit reconcile ${String(obligation.seq)} --revert <action-key> --note "…"\`.`
1733
+ : `reconciliation obligation at seq ${String(obligation.seq)}: POLICY FINDING. Nothing can be reverted, so what is owed is a review of the class that permitted this. Close it with \`approval audit reconcile ${String(obligation.seq)} --note "…"\` once that review has happened.`);
1734
+ }
1735
+ return {
1736
+ ok: true,
1737
+ headline: tap.verdict === "denied" ? TELEGRAM_REVIEW_DENIED : TELEGRAM_REVIEW_RECORDED,
1738
+ detail,
1739
+ toast: tap.verdict === "denied" ? "Recorded — denied." : "Recorded.",
1740
+ };
1741
+ };
1742
+ }
1743
+ /**
1744
+ * `/queue`, `/skip`, `/next` — the paced walkthrough's three verbs (APRV-216).
1745
+ *
1746
+ * **None of them appends anything**, and the reason is structural rather than
1747
+ * careful: this function never touches `recordChannelDecision`, so there is no
1748
+ * path from a typed word to the log. A decision is a button, always, because a
1749
+ * button carries the nonce and the action reference that bind an answer to the
1750
+ * bytes an approver was shown, and a word typed into a chat carries neither.
1751
+ *
1752
+ * What they do move is process memory:
1753
+ *
1754
+ * - `/queue` reads the verified log and replies with the summary and a numbered
1755
+ * list. It changes nothing, and it works while a request is selected, because
1756
+ * the list is derived and not held. The reply says outright that it carries no
1757
+ * buttons and that it cannot vouch for a card it once sent (APRV-256).
1758
+ * - `/skip` sends the shown unit to the BACK of this process's order and
1759
+ * forgets having delivered it, so the next cycle shows the next question and
1760
+ * this one comes round again after the rest. The copy already in the chat
1761
+ * keeps its buttons, and they still decide the same request by action
1762
+ * reference (APRV-196), so a skip is "later", never "gone".
1763
+ * - `/next` releases the shown unit without reordering, so this process moves
1764
+ * past it and does not show it again. The same copy stays live in the chat:
1765
+ * the approver has kept the question and given up their place in the queue,
1766
+ * which is the opposite trade from `/skip`.
1767
+ *
1768
+ * A command that finds nothing to do says so, because silence in a chat window
1769
+ * is indistinguishable from a listener that has died.
1770
+ */
1771
+ export function commandHandlerFor(setup, streams, state,
1772
+ /**
1773
+ * When the command arrived. A clock read in production, because a command is
1774
+ * answered when a human types it; injectable for the same reason
1775
+ * {@link dispatchPending} takes `now` as a parameter, since the ages a reply
1776
+ * states are arithmetic against it and a suite must be able to choose them.
1777
+ */
1778
+ clock = () => new Date().toISOString()) {
1779
+ const say = async (lines) => {
1780
+ try {
1781
+ await setup.channel.announce(lines);
1782
+ }
1783
+ catch (cause) {
1784
+ streams.err(`approval: telegram could not answer a command: ${cause instanceof Error ? cause.message : String(cause)} — nothing was appended and the listener is still up\n`);
1785
+ }
1786
+ };
1787
+ return async (command) => {
1788
+ const now = clock();
1789
+ if (command === "queue") {
1790
+ const queue = buildPendingQueue(setup.logPath, setup.tagOptions, now);
1791
+ if (!queue.ok) {
1792
+ // SPEC.md §11.1(1): a sentence a human reads about what the log says is
1793
+ // derived from a log that verified, or it is not derived at all. So the
1794
+ // reply names the refusal rather than a queue nobody could derive.
1795
+ streams.err(`approval: telegram cannot read the pending queue for /queue (${queue.code}): ${queue.message}\n`);
1796
+ await say([
1797
+ "The queue could not be read.",
1798
+ `The log did not verify or could not be read (${queue.code}). Nothing is decided and nothing is lost; the listener retries every cycle.`,
1799
+ ]);
1800
+ return;
1801
+ }
1802
+ // APRV-299. The retrospective backlog is part of what the log is holding,
1803
+ // and `/queue` is the verb that says what that is. Appended rather than
1804
+ // interleaved, because the two lists answer different questions: one is
1805
+ // what is waiting on the approver, the other what already happened.
1806
+ const lines = queueLines(queue.requests, now, state.paced.current ?? []);
1807
+ const built = openReviewCards(setup.logPath);
1808
+ if (built.ok && built.cards.length > 0)
1809
+ lines.push(...reviewSummaryLines(built.cards, now));
1810
+ await say(lines);
1811
+ return;
1812
+ }
1813
+ const shown = state.paced.current;
1814
+ if (shown === null) {
1815
+ // APRV-299. With no request selected, the two verbs act on the review
1816
+ // card if one is in front of the approver. Neither decides anything here
1817
+ // either: the sample stays open in the log whichever way it goes, and
1818
+ // `approval audit review <seq>` still names it from a terminal.
1819
+ const card = state.review.current;
1820
+ if (card !== null) {
1821
+ state.review.current = null;
1822
+ if (command === "skip") {
1823
+ state.review.delivered.delete(card);
1824
+ state.review.order = state.review.order.filter((seq) => seq !== card);
1825
+ state.review.order.push(card);
1826
+ }
1827
+ await say([
1828
+ command === "skip"
1829
+ ? `Review of sample ${String(card)} skipped — it goes to the back of the order and a fresh card is sent once the rest have had their turn.`
1830
+ : `Review of sample ${String(card)} passed over — this listener sends no further card for it.`,
1831
+ "Nothing was recorded. The sample is still open: it is listed by `approval audit list` and reviewable with `approval audit review " +
1832
+ String(card) +
1833
+ "`, and the card already in this chat keeps its buttons.",
1834
+ ]);
1835
+ return;
1836
+ }
1837
+ await say([
1838
+ // APRV-256: selection language, matching `/queue`'s. "In front of you"
1839
+ // was a claim about the approver's screen, which this process has never
1840
+ // been able to see.
1841
+ command === "skip"
1842
+ ? "Nothing to skip — this listener has no request selected."
1843
+ : "This listener has no request selected right now.",
1844
+ "The next pending request is sent with its buttons on an upcoming cycle. /queue lists what the log is holding.",
1845
+ ]);
1846
+ return;
1847
+ }
1848
+ state.paced.current = null;
1849
+ if (command === "skip") {
1850
+ for (const key of shown) {
1851
+ // Forgotten as well as reordered: the delivery bookkeeping is what stops
1852
+ // a re-send, so a request that must be SHOWN again has to leave it. The
1853
+ // message already in the chat is untouched and still decides.
1854
+ forget(state, key);
1855
+ state.paced.order = state.paced.order.filter((entry) => entry !== key);
1856
+ state.paced.order.push(key);
1857
+ }
1858
+ }
1859
+ };
1860
+ }
1861
+ /**
1862
+ * Start the dispatch-and-poll loop. **Installs no signal handler** and chooses
1863
+ * no exit code (APRV-110): both are the caller's, because `approval up` runs
1864
+ * this beside a daemon loop and a web server under one set of handlers.
1865
+ *
1866
+ * A FRESH {@link DispatchState} per call, which is the whole of the restart
1867
+ * story: a supervisor that restarts a fallen listener re-derives the pending
1868
+ * queue from the verified log and re-sends everything still pending, exactly as
1869
+ * a restarted process would. A duplicate on the phone, never a silence.
1870
+ */
1871
+ export function startListener(setup, streams) {
1872
+ const { channel } = setup;
1873
+ channel.onDecision(handlerFor(setup, streams));
1874
+ // APRV-257. Registered unconditionally, because whether a checkpoint is ever
1875
+ // OFFERED is the policy's answer and a handler that exists for a prompt
1876
+ // nobody sends costs nothing. Registering it here is also what makes the
1877
+ // channel's `offerCheckpoint` legal at all: it refuses to send a button
1878
+ // nothing is listening for.
1879
+ channel.onCheckpoint(checkpointHandlerFor(setup, streams));
1880
+ // APRV-299. Registered unconditionally, for the same reason the checkpoint
1881
+ // handler is: whether a review card is ever OFFERED is the log's answer (an
1882
+ // open `audit.sampled`), and a handler that exists for a card nobody sends
1883
+ // costs nothing. Registering it is also what makes `offerReview` legal at
1884
+ // all, and what makes the channel read the `message` updates a note reply
1885
+ // arrives on.
1886
+ channel.onReview(reviewHandlerFor(setup, streams));
1887
+ // Delivery bookkeeping is in memory only — channels hold no state (SPEC.md
1888
+ // §10.3). A restarted listener therefore re-sends everything still pending.
1889
+ // Duplicated messages are the acceptable failure; a decision that depends on
1890
+ // a channel's memory surviving a crash is not. Since APRV-196 the duplicates
1891
+ // announce themselves (the banner this cycle sends) and the buttons on the
1892
+ // pre-restart copies still decide the same request, so what a restart costs
1893
+ // the approver is a longer transcript rather than a stuck one.
1894
+ const state = newDispatchState();
1895
+ // APRV-216. Registering the command handler is also what makes the channel
1896
+ // ask for `message` updates at all, so a burst listener consumes none and
1897
+ // `approval setup channel telegram`'s chat discovery is untouched by this
1898
+ // task. See `TelegramChannel.onCommand`.
1899
+ if (setup.delivery === "paced") {
1900
+ channel.onCommand(commandHandlerFor(setup, streams, state));
1901
+ }
1902
+ let stopping = false;
1903
+ const stop = () => {
1904
+ stopping = true;
1905
+ channel.stop();
1906
+ };
1907
+ const done = (async () => {
1908
+ // The startup cycle. Same call as every later one; only its *failures* are
1909
+ // treated differently, because an operator who has just mistyped a token or
1910
+ // pointed at an unreadable log should learn it at once rather than watch a
1911
+ // retry loop.
1912
+ const startup = await dispatchPending(setup, streams, state, new Date().toISOString());
1913
+ if (startup.queueError !== undefined) {
1914
+ return { kind: "queue-error", ...startup.queueError };
1915
+ }
1916
+ const firstFailure = startup.failed[0];
1917
+ if (firstFailure !== undefined) {
1918
+ return { kind: "send-failed", message: firstFailure.message };
1919
+ }
1920
+ // A stop that arrived during the startup cycle: `listen()` clears its own
1921
+ // stopped flag on entry, so a loop entered now would ignore it and block.
1922
+ if (stopping)
1923
+ return { kind: "stopped" };
1924
+ // Every subsequent cycle: re-derive, send what is new, complain and carry
1925
+ // on. Runs before each `getUpdates`, including the poll after a recovered
1926
+ // poll error, so a request appended mid-run is delivered without a restart.
1927
+ const beforePoll = async () => {
1928
+ reportCycle(await dispatchPending(setup, streams, state, new Date().toISOString()), streams);
1929
+ };
1930
+ await channel.listen(setup.once ? { once: true, beforePoll } : { beforePoll });
1931
+ if (setup.json) {
1932
+ streams.out(`${JSON.stringify({ event: "stopped", ...channel.stats() })}\n`);
1933
+ }
1934
+ return { kind: "stopped" };
1935
+ })();
1936
+ return { done, stop };
1937
+ }
1938
+ async function runListener(setup, streams) {
1939
+ const running = startListener(setup, streams);
1940
+ const stop = () => running.stop();
1941
+ process.on("SIGINT", stop);
1942
+ process.on("SIGTERM", stop);
1943
+ let outcome;
1944
+ try {
1945
+ outcome = await running.done;
1946
+ }
1947
+ finally {
1948
+ process.off("SIGINT", stop);
1949
+ process.off("SIGTERM", stop);
1950
+ }
1951
+ switch (outcome.kind) {
1952
+ case "stopped":
1953
+ return EXIT_OK;
1954
+ case "queue-error":
1955
+ return outcome.code === "log-unreadable"
1956
+ ? ioError(streams, setup.json, outcome.message)
1957
+ : integrityError(streams, setup.json, outcome.message);
1958
+ case "send-failed":
1959
+ return ioError(streams, setup.json, `telegram sendMessage failed: ${outcome.message}`);
1960
+ }
1961
+ }
1962
+ /**
1963
+ * The listener verb. Returns a promise, which is why `main` treats `channel`
1964
+ * specially: it is the only long-lived command in the CLI.
1965
+ */
1966
+ export function commandTelegramListen(argv, streams, cwd) {
1967
+ const prepared = setUp(argv, streams, cwd);
1968
+ if (prepared.kind === "handled")
1969
+ return prepared.code;
1970
+ return runListener(prepared.setup, streams);
1971
+ }
1972
+ // ---------------------------------------------------------------------------
1973
+ // approval channel telegram health
1974
+ // ---------------------------------------------------------------------------
1975
+ /**
1976
+ * Configuration health, offline.
1977
+ *
1978
+ * It answers one question — "is this runtime configured to talk to Telegram?"
1979
+ * — and deliberately makes no network call: a health check that contacted the
1980
+ * Bot API would leak the existence of the bot from any shell, and would fail
1981
+ * for reasons (a captive portal, a rate limit) that say nothing about whether
1982
+ * the operator's configuration is right. The *live* counters (deliveries,
1983
+ * decisions, ignored callbacks, recovered poll errors) belong to a running
1984
+ * listener and are surfaced by `TelegramChannel.health()` / `stats()` in
1985
+ * process, and on the listener's stderr as they happen.
1986
+ */
1987
+ export function commandTelegramHealth(argv, streams, cwd) {
1988
+ const json = argv.includes("--json");
1989
+ const parsed = parseFlags(argv, {
1990
+ "--policy": "string",
1991
+ "--dir": "string",
1992
+ "--json": "boolean",
1993
+ "--help": "boolean",
1994
+ "-h": "boolean",
1995
+ });
1996
+ if (!parsed.ok)
1997
+ return usageError(streams, json, parsed.message, TELEGRAM_HEALTH_HELP);
1998
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
1999
+ streams.out(`${TELEGRAM_HEALTH_HELP}\n`);
2000
+ return EXIT_OK;
2001
+ }
2002
+ // Which variables to look at is a policy question (§5.1), so this offline
2003
+ // check reads the policy for the NAMES — and only the names. It still makes
2004
+ // no network call, and an unloadable policy leaves the defaults in force
2005
+ // rather than reporting a channel that cannot be configured at all.
2006
+ const policyFlag = stringFlag(parsed.flags, "--policy");
2007
+ const dirFlag = stringFlag(parsed.flags, "--dir");
2008
+ const policyLoad = loadPolicy(policyFlag !== null
2009
+ ? { file: absolute(policyFlag, cwd) }
2010
+ : { dir: dirFlag === null ? cwd : absolute(dirFlag, cwd) });
2011
+ const tokenEnv = telegramTokenEnvFor(policyLoad);
2012
+ const chatEnv = telegramChatEnvFor(policyLoad);
2013
+ const token = env(tokenEnv);
2014
+ const chatId = env(chatEnv);
2015
+ const ok = token !== null && chatId !== null;
2016
+ if (json) {
2017
+ streams.out(`${JSON.stringify({
2018
+ ok,
2019
+ channel: "telegram",
2020
+ // Presence only. The token's value never appears in any output.
2021
+ token_env: tokenEnv,
2022
+ token_set: token !== null,
2023
+ chat_env: chatEnv,
2024
+ chat_id: chatId,
2025
+ })}\n`);
2026
+ }
2027
+ else if (ok) {
2028
+ streams.out(`telegram: configured (${tokenEnv} set, chat ${String(chatId)})\n`);
2029
+ }
2030
+ else {
2031
+ streams.err(`approval: telegram is not configured: ${[
2032
+ token === null ? tokenEnv : null,
2033
+ chatId === null ? chatEnv : null,
2034
+ ]
2035
+ .filter((name) => name !== null)
2036
+ .join(" and ")} unset or empty\n`);
2037
+ }
2038
+ return ok ? EXIT_OK : EXIT_INTEGRITY;
2039
+ }
2040
+ // ---------------------------------------------------------------------------
2041
+ // Dispatch
2042
+ // ---------------------------------------------------------------------------
2043
+ export function commandTelegram(argv, streams, cwd) {
2044
+ const sub = argv[0];
2045
+ const rest = argv.slice(1);
2046
+ const json = argv.includes("--json");
2047
+ if (sub === undefined) {
2048
+ return usageError(streams, json, "missing subcommand for `approval channel telegram`", TELEGRAM_HELP);
2049
+ }
2050
+ if (sub === "--help" || sub === "-h" || sub === "help") {
2051
+ streams.out(`${TELEGRAM_HELP}\n`);
2052
+ return EXIT_OK;
2053
+ }
2054
+ switch (sub) {
2055
+ case "listen":
2056
+ return commandTelegramListen(rest, streams, cwd);
2057
+ case "health":
2058
+ return commandTelegramHealth(rest, streams, cwd);
2059
+ default:
2060
+ return usageError(streams, json, `unknown subcommand ${JSON.stringify(sub)} for \`approval channel telegram\``, TELEGRAM_HELP);
2061
+ }
2062
+ }
2063
+ //# sourceMappingURL=channel-telegram.js.map