@quickchatai/quickreview 0.0.0-stage → 1.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 (1479) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +401 -2
  3. package/THIRD-PARTY-NOTICES.md +134 -0
  4. package/agents/pass-abstraction.md +64 -0
  5. package/agents/pass-ask-human.md +134 -0
  6. package/agents/pass-backcompat.md +67 -0
  7. package/agents/pass-baseline.md +61 -0
  8. package/agents/pass-behaviour-delta.md +64 -0
  9. package/agents/pass-bounds.md +36 -0
  10. package/agents/pass-claims.md +31 -0
  11. package/agents/pass-concurrency.md +168 -0
  12. package/agents/pass-consistency.md +57 -0
  13. package/agents/pass-contracts.md +30 -0
  14. package/agents/pass-data-safety.md +54 -0
  15. package/agents/pass-deferred.md +35 -0
  16. package/agents/pass-delivery.md +37 -0
  17. package/agents/pass-duplication.md +60 -0
  18. package/agents/pass-intent.md +63 -0
  19. package/agents/pass-lint-conformance.md +46 -0
  20. package/agents/pass-maintainability.md +67 -0
  21. package/agents/pass-placement.md +68 -0
  22. package/agents/pass-prod-risk.md +24 -0
  23. package/agents/pass-rollout.md +32 -0
  24. package/agents/pass-roundtrip.md +46 -0
  25. package/agents/pass-security.md +31 -0
  26. package/agents/pass-simplicity.md +68 -0
  27. package/agents/pass-statelessness.md +27 -0
  28. package/agents/pass-style.md +37 -0
  29. package/agents/pass-sweep.md +57 -0
  30. package/agents/pass-tests.md +58 -0
  31. package/agents/pass-ui.md +23 -0
  32. package/agents/pass-validate.md +50 -0
  33. package/agents/pass-widened.md +46 -0
  34. package/agents/scan/pass-scan.md +86 -0
  35. package/bin/qcr.js +11 -0
  36. package/config.example.toml +1099 -0
  37. package/deploy/ansible/README.md +356 -0
  38. package/deploy/ansible/group_vars/all.yml +212 -0
  39. package/deploy/ansible/inventory.example.ini +25 -0
  40. package/deploy/ansible/playbook.yml +360 -0
  41. package/deploy/collect-secrets.sh +289 -0
  42. package/deploy/collect-slack-secrets.mjs +1190 -0
  43. package/deploy/github-app-manifest.json +31 -0
  44. package/deploy/manifest.schema.json +75 -0
  45. package/deploy/qcr-update.sh +803 -0
  46. package/deploy/reviewer/AGENTS.override.md +12 -0
  47. package/dist/extensions/cli/bash-guard-ambient.js +42 -0
  48. package/dist/extensions/cli/bash-guard-ambient.js.map +1 -0
  49. package/dist/extensions/cli/bash-guard.js +5 -0
  50. package/dist/extensions/cli/bash-guard.js.map +1 -0
  51. package/dist/extensions/cli/companion-guard.js +5 -0
  52. package/dist/extensions/cli/companion-guard.js.map +1 -0
  53. package/dist/extensions/cli/debug-round.js +8 -0
  54. package/dist/extensions/cli/debug-round.js.map +1 -0
  55. package/dist/extensions/cli/inject-bridge.js +5 -0
  56. package/dist/extensions/cli/inject-bridge.js.map +1 -0
  57. package/dist/extensions/cli/ledger-pin.js +5 -0
  58. package/dist/extensions/cli/ledger-pin.js.map +1 -0
  59. package/dist/extensions/cli/lens.js +13 -0
  60. package/dist/extensions/cli/lens.js.map +1 -0
  61. package/dist/extensions/cli/login-route.js +5 -0
  62. package/dist/extensions/cli/login-route.js.map +1 -0
  63. package/dist/extensions/cli/muted-theme.js +5 -0
  64. package/dist/extensions/cli/muted-theme.js.map +1 -0
  65. package/dist/extensions/cli/onboarding.js +5 -0
  66. package/dist/extensions/cli/onboarding.js.map +1 -0
  67. package/dist/extensions/cli/setup.js +5 -0
  68. package/dist/extensions/cli/setup.js.map +1 -0
  69. package/dist/extensions/cli/tui-view.js +5 -0
  70. package/dist/extensions/cli/tui-view.js.map +1 -0
  71. package/dist/extensions/companion-guard.js +361 -0
  72. package/dist/extensions/companion-guard.js.map +1 -0
  73. package/dist/extensions/debug-round.js +204 -0
  74. package/dist/extensions/debug-round.js.map +1 -0
  75. package/dist/extensions/inject-bridge.js +603 -0
  76. package/dist/extensions/inject-bridge.js.map +1 -0
  77. package/dist/extensions/login-route.js +180 -0
  78. package/dist/extensions/login-route.js.map +1 -0
  79. package/dist/extensions/muted-theme.js +12 -0
  80. package/dist/extensions/muted-theme.js.map +1 -0
  81. package/dist/extensions/onboarding.js +198 -0
  82. package/dist/extensions/onboarding.js.map +1 -0
  83. package/dist/extensions/setup-replay.js +269 -0
  84. package/dist/extensions/setup-replay.js.map +1 -0
  85. package/dist/extensions/setup-scan.js +998 -0
  86. package/dist/extensions/setup-scan.js.map +1 -0
  87. package/dist/extensions/setup-triage.js +175 -0
  88. package/dist/extensions/setup-triage.js.map +1 -0
  89. package/dist/extensions/setup.js +1160 -0
  90. package/dist/extensions/setup.js.map +1 -0
  91. package/dist/extensions/tui-view.js +389 -0
  92. package/dist/extensions/tui-view.js.map +1 -0
  93. package/dist/extensions/vendor/openviking-pi/client.js +376 -0
  94. package/dist/extensions/vendor/openviking-pi/client.js.map +1 -0
  95. package/dist/extensions/vendor/openviking-pi/config.js +189 -0
  96. package/dist/extensions/vendor/openviking-pi/config.js.map +1 -0
  97. package/dist/extensions/vendor/openviking-pi/index.js +421 -0
  98. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -0
  99. package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +86 -0
  100. package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs.map +1 -0
  101. package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs +408 -0
  102. package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs.map +1 -0
  103. package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +41 -0
  104. package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs.map +1 -0
  105. package/dist/extensions/vendor/openviking-pi/qcr-hint.js +290 -0
  106. package/dist/extensions/vendor/openviking-pi/qcr-hint.js.map +1 -0
  107. package/dist/extensions/vendor/openviking-pi/qcr-honest.js +148 -0
  108. package/dist/extensions/vendor/openviking-pi/qcr-honest.js.map +1 -0
  109. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +68 -0
  110. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -0
  111. package/dist/extensions/vendor/openviking-pi/qcr-leaf.js +77 -0
  112. package/dist/extensions/vendor/openviking-pi/qcr-leaf.js.map +1 -0
  113. package/dist/extensions/vendor/openviking-pi/qcr-lesson.js +216 -0
  114. package/dist/extensions/vendor/openviking-pi/qcr-lesson.js.map +1 -0
  115. package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js +154 -0
  116. package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js.map +1 -0
  117. package/dist/extensions/vendor/openviking-pi/qcr-recall.js +254 -0
  118. package/dist/extensions/vendor/openviking-pi/qcr-recall.js.map +1 -0
  119. package/dist/extensions/vendor/openviking-pi/qcr-shim.js +94 -0
  120. package/dist/extensions/vendor/openviking-pi/qcr-shim.js.map +1 -0
  121. package/dist/extensions/vendor/openviking-pi/qcr-write.js +333 -0
  122. package/dist/extensions/vendor/openviking-pi/qcr-write.js.map +1 -0
  123. package/dist/extensions/vendor/openviking-pi/recall.js +108 -0
  124. package/dist/extensions/vendor/openviking-pi/recall.js.map +1 -0
  125. package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs +529 -0
  126. package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs.map +1 -0
  127. package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs +205 -0
  128. package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs.map +1 -0
  129. package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs +442 -0
  130. package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs.map +1 -0
  131. package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs +286 -0
  132. package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs.map +1 -0
  133. package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +189 -0
  134. package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs.map +1 -0
  135. package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs +580 -0
  136. package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs.map +1 -0
  137. package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs +11 -0
  138. package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs.map +1 -0
  139. package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs +63 -0
  140. package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs.map +1 -0
  141. package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs +58 -0
  142. package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs.map +1 -0
  143. package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
  144. package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs.map +1 -0
  145. package/dist/extensions/vendor/openviking-pi/sync.js +127 -0
  146. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -0
  147. package/dist/extensions/vendor/openviking-pi/takeover.js +25 -0
  148. package/dist/extensions/vendor/openviking-pi/takeover.js.map +1 -0
  149. package/dist/extensions/vendor/openviking-pi/tools.js +417 -0
  150. package/dist/extensions/vendor/openviking-pi/tools.js.map +1 -0
  151. package/dist/src/anomalies.js +483 -0
  152. package/dist/src/anomalies.js.map +1 -0
  153. package/dist/src/answerworker.js +180 -0
  154. package/dist/src/answerworker.js.map +1 -0
  155. package/dist/src/appstamp.js +68 -0
  156. package/dist/src/appstamp.js.map +1 -0
  157. package/dist/src/archive/admission.js +3 -0
  158. package/dist/src/archive/admission.js.map +1 -0
  159. package/dist/src/archive/archivedb.js +474 -0
  160. package/dist/src/archive/archivedb.js.map +1 -0
  161. package/dist/src/archive/archiver.js +1770 -0
  162. package/dist/src/archive/archiver.js.map +1 -0
  163. package/dist/src/archive/archivewrite.js +198 -0
  164. package/dist/src/archive/archivewrite.js.map +1 -0
  165. package/dist/src/archive/bundle.js +266 -0
  166. package/dist/src/archive/bundle.js.map +1 -0
  167. package/dist/src/archive/dbmove.js +638 -0
  168. package/dist/src/archive/dbmove.js.map +1 -0
  169. package/dist/src/archive/duplicatecounts.js +80 -0
  170. package/dist/src/archive/duplicatecounts.js.map +1 -0
  171. package/dist/src/archive/eligibility.js +113 -0
  172. package/dist/src/archive/eligibility.js.map +1 -0
  173. package/dist/src/archive/extract.js +288 -0
  174. package/dist/src/archive/extract.js.map +1 -0
  175. package/dist/src/archive/fsutil.js +330 -0
  176. package/dist/src/archive/fsutil.js.map +1 -0
  177. package/dist/src/archive/gzfile.js +277 -0
  178. package/dist/src/archive/gzfile.js.map +1 -0
  179. package/dist/src/archive/kinds.js +22 -0
  180. package/dist/src/archive/kinds.js.map +1 -0
  181. package/dist/src/archive/livecheckpoint.js +178 -0
  182. package/dist/src/archive/livecheckpoint.js.map +1 -0
  183. package/dist/src/archive/liveretention.js +145 -0
  184. package/dist/src/archive/liveretention.js.map +1 -0
  185. package/dist/src/archive/manifest.js +284 -0
  186. package/dist/src/archive/manifest.js.map +1 -0
  187. package/dist/src/archive/overseer.js +283 -0
  188. package/dist/src/archive/overseer.js.map +1 -0
  189. package/dist/src/archive/piglobal.js +129 -0
  190. package/dist/src/archive/piglobal.js.map +1 -0
  191. package/dist/src/archive/plan.js +398 -0
  192. package/dist/src/archive/plan.js.map +1 -0
  193. package/dist/src/archive/prarchive.js +831 -0
  194. package/dist/src/archive/prarchive.js.map +1 -0
  195. package/dist/src/archive/prfiles.js +303 -0
  196. package/dist/src/archive/prfiles.js.map +1 -0
  197. package/dist/src/archive/report.js +169 -0
  198. package/dist/src/archive/report.js.map +1 -0
  199. package/dist/src/archive/restore.js +276 -0
  200. package/dist/src/archive/restore.js.map +1 -0
  201. package/dist/src/archive/rows.js +106 -0
  202. package/dist/src/archive/rows.js.map +1 -0
  203. package/dist/src/archive/seal.js +480 -0
  204. package/dist/src/archive/seal.js.map +1 -0
  205. package/dist/src/archive/status.js +528 -0
  206. package/dist/src/archive/status.js.map +1 -0
  207. package/dist/src/archive/storepass.js +409 -0
  208. package/dist/src/archive/storepass.js.map +1 -0
  209. package/dist/src/backfill.js +217 -0
  210. package/dist/src/backfill.js.map +1 -0
  211. package/dist/src/bench/cost.js +87 -0
  212. package/dist/src/bench/exec.js +76 -0
  213. package/dist/src/bench/values.js +14 -0
  214. package/dist/src/botcontrol.js +127 -0
  215. package/dist/src/botcontrol.js.map +1 -0
  216. package/dist/src/botworker.js +805 -0
  217. package/dist/src/botworker.js.map +1 -0
  218. package/dist/src/capture.js +234 -0
  219. package/dist/src/capture.js.map +1 -0
  220. package/dist/src/cases.js +1491 -0
  221. package/dist/src/cases.js.map +1 -0
  222. package/dist/src/channelfs.js +27 -0
  223. package/dist/src/channelfs.js.map +1 -0
  224. package/dist/src/checklist.js +312 -0
  225. package/dist/src/checklist.js.map +1 -0
  226. package/dist/src/ciretry.js +92 -0
  227. package/dist/src/ciretry.js.map +1 -0
  228. package/dist/src/cli.js +1947 -0
  229. package/dist/src/cli.js.map +1 -0
  230. package/dist/src/cliops.js +334 -0
  231. package/dist/src/cliops.js.map +1 -0
  232. package/dist/src/clonedir.js +15 -0
  233. package/dist/src/clonedir.js.map +1 -0
  234. package/dist/src/commandeffects.js +43 -0
  235. package/dist/src/commandeffects.js.map +1 -0
  236. package/dist/src/commands.js +1335 -0
  237. package/dist/src/commands.js.map +1 -0
  238. package/dist/src/companioninbox.js +203 -0
  239. package/dist/src/companioninbox.js.map +1 -0
  240. package/dist/src/compare.js +673 -0
  241. package/dist/src/compare.js.map +1 -0
  242. package/dist/src/config.js +1041 -0
  243. package/dist/src/config.js.map +1 -0
  244. package/dist/src/configcheck.js +111 -0
  245. package/dist/src/configcheck.js.map +1 -0
  246. package/dist/src/coreext.js +43 -0
  247. package/dist/src/coreext.js.map +1 -0
  248. package/dist/src/daemon.js +945 -0
  249. package/dist/src/daemon.js.map +1 -0
  250. package/dist/src/debuground.js +731 -0
  251. package/dist/src/debuground.js.map +1 -0
  252. package/dist/src/debugroundpi.js +223 -0
  253. package/dist/src/debugroundpi.js.map +1 -0
  254. package/dist/src/diagnostics.js +14 -0
  255. package/dist/src/diagnostics.js.map +1 -0
  256. package/dist/src/digest.js +139 -0
  257. package/dist/src/digest.js.map +1 -0
  258. package/dist/src/dispatch.js +1727 -0
  259. package/dist/src/dispatch.js.map +1 -0
  260. package/dist/src/engine/answerguard.js +18 -0
  261. package/dist/src/engine/answerguard.js.map +1 -0
  262. package/dist/src/engine/botsdk.js +262 -0
  263. package/dist/src/engine/botsdk.js.map +1 -0
  264. package/dist/src/engine/caserunner-sdk.js +332 -0
  265. package/dist/src/engine/caserunner-sdk.js.map +1 -0
  266. package/dist/src/engine/debugpi.js +206 -0
  267. package/dist/src/engine/debugpi.js.map +1 -0
  268. package/dist/src/eod.js +473 -0
  269. package/dist/src/eod.js.map +1 -0
  270. package/dist/src/eodpinwatch.js +391 -0
  271. package/dist/src/eodpinwatch.js.map +1 -0
  272. package/dist/src/escalation.js +1009 -0
  273. package/dist/src/escalation.js.map +1 -0
  274. package/dist/src/eventloop.js +779 -0
  275. package/dist/src/eventloop.js.map +1 -0
  276. package/dist/src/execboundary.js +437 -0
  277. package/dist/src/execboundary.js.map +1 -0
  278. package/dist/src/feedback.js +915 -0
  279. package/dist/src/feedback.js.map +1 -0
  280. package/dist/src/findingscheck.js +200 -0
  281. package/dist/src/findingscheck.js.map +1 -0
  282. package/dist/src/fsdenied.js +26 -0
  283. package/dist/src/fsdenied.js.map +1 -0
  284. package/dist/src/ghread.js +181 -0
  285. package/dist/src/ghread.js.map +1 -0
  286. package/dist/src/github.js +1098 -0
  287. package/dist/src/github.js.map +1 -0
  288. package/dist/src/githubtoken.js +346 -0
  289. package/dist/src/githubtoken.js.map +1 -0
  290. package/dist/src/homes.js +103 -0
  291. package/dist/src/homes.js.map +1 -0
  292. package/dist/src/hostroute.js +24 -0
  293. package/dist/src/hostroute.js.map +1 -0
  294. package/dist/src/improvements.js +625 -0
  295. package/dist/src/improvements.js.map +1 -0
  296. package/dist/src/install.js +946 -0
  297. package/dist/src/install.js.map +1 -0
  298. package/dist/src/installkind.js +40 -0
  299. package/dist/src/installkind.js.map +1 -0
  300. package/dist/src/ipc/capability.js +286 -0
  301. package/dist/src/ipc/capability.js.map +1 -0
  302. package/dist/src/ipc/client.js +226 -0
  303. package/dist/src/ipc/client.js.map +1 -0
  304. package/dist/src/ipc/control.js +70 -0
  305. package/dist/src/ipc/control.js.map +1 -0
  306. package/dist/src/ipc/ghreadshim.js +77 -0
  307. package/dist/src/ipc/ghreadshim.js.map +1 -0
  308. package/dist/src/ipc/launcher.js +65 -0
  309. package/dist/src/ipc/launcher.js.map +1 -0
  310. package/dist/src/ipc/permits.js +293 -0
  311. package/dist/src/ipc/permits.js.map +1 -0
  312. package/dist/src/ipc/postshim.js +137 -0
  313. package/dist/src/ipc/postshim.js.map +1 -0
  314. package/dist/src/ipc/protocol.js +239 -0
  315. package/dist/src/ipc/protocol.js.map +1 -0
  316. package/dist/src/ipc/server.js +312 -0
  317. package/dist/src/ipc/server.js.map +1 -0
  318. package/dist/src/ipc/workerspec.js +148 -0
  319. package/dist/src/ipc/workerspec.js.map +1 -0
  320. package/dist/src/learner/learner.js +1272 -0
  321. package/dist/src/learner/learner.js.map +1 -0
  322. package/dist/src/learner/miner.js +948 -0
  323. package/dist/src/learner/miner.js.map +1 -0
  324. package/dist/src/learner/packet.js +216 -0
  325. package/dist/src/learner/packet.js.map +1 -0
  326. package/dist/src/learner/receipt.js +135 -0
  327. package/dist/src/learner/receipt.js.map +1 -0
  328. package/dist/src/learner/status.js +38 -0
  329. package/dist/src/learner/status.js.map +1 -0
  330. package/dist/src/ledgercheck.js +120 -0
  331. package/dist/src/ledgercheck.js.map +1 -0
  332. package/dist/src/lessonledger.js +451 -0
  333. package/dist/src/lessonledger.js.map +1 -0
  334. package/dist/src/lessonselect.js +639 -0
  335. package/dist/src/lessonselect.js.map +1 -0
  336. package/dist/src/lessonshadow.js +540 -0
  337. package/dist/src/lessonshadow.js.map +1 -0
  338. package/dist/src/levers.js +302 -0
  339. package/dist/src/levers.js.map +1 -0
  340. package/dist/src/live/projection.js +450 -0
  341. package/dist/src/live/projection.js.map +1 -0
  342. package/dist/src/live/publish.js +83 -0
  343. package/dist/src/live/publish.js.map +1 -0
  344. package/dist/src/live/read.js +60 -0
  345. package/dist/src/live/read.js.map +1 -0
  346. package/dist/src/livecli.js +288 -0
  347. package/dist/src/livecli.js.map +1 -0
  348. package/dist/src/loginhint.js +47 -0
  349. package/dist/src/loginhint.js.map +1 -0
  350. package/dist/src/loginrefresh/atomic.js +66 -0
  351. package/dist/src/loginrefresh/atomic.js.map +1 -0
  352. package/dist/src/loginrefresh/lock.js +93 -0
  353. package/dist/src/loginrefresh/lock.js.map +1 -0
  354. package/dist/src/loginrefresh/main.js +58 -0
  355. package/dist/src/loginrefresh/main.js.map +1 -0
  356. package/dist/src/loginrefresh/pirefresh.js +52 -0
  357. package/dist/src/loginrefresh/pirefresh.js.map +1 -0
  358. package/dist/src/loginrefresh/refresher.js +286 -0
  359. package/dist/src/loginrefresh/refresher.js.map +1 -0
  360. package/dist/src/loginrefresh/supervisor.js +253 -0
  361. package/dist/src/loginrefresh/supervisor.js.map +1 -0
  362. package/dist/src/loginrefresh/types.js +4 -0
  363. package/dist/src/loginrefresh/types.js.map +1 -0
  364. package/dist/src/memory/context.js +251 -0
  365. package/dist/src/memory/context.js.map +1 -0
  366. package/dist/src/memory/datafence.js +5 -0
  367. package/dist/src/memory/datafence.js.map +1 -0
  368. package/dist/src/memory/delivery.js +131 -0
  369. package/dist/src/memory/delivery.js.map +1 -0
  370. package/dist/src/memory/eventtags.js +81 -0
  371. package/dist/src/memory/eventtags.js.map +1 -0
  372. package/dist/src/memory/health.js +713 -0
  373. package/dist/src/memory/health.js.map +1 -0
  374. package/dist/src/memory/import.js +504 -0
  375. package/dist/src/memory/import.js.map +1 -0
  376. package/dist/src/memory/layout.js +252 -0
  377. package/dist/src/memory/layout.js.map +1 -0
  378. package/dist/src/memory/legacy-file.js +100 -0
  379. package/dist/src/memory/legacy-file.js.map +1 -0
  380. package/dist/src/memory/lessons.js +370 -0
  381. package/dist/src/memory/lessons.js.map +1 -0
  382. package/dist/src/memory/ovsession.js +449 -0
  383. package/dist/src/memory/ovsession.js.map +1 -0
  384. package/dist/src/memory/relayout.js +259 -0
  385. package/dist/src/memory/relayout.js.map +1 -0
  386. package/dist/src/memory/roundactivity.js +180 -0
  387. package/dist/src/memory/roundactivity.js.map +1 -0
  388. package/dist/src/memory/selection.js +690 -0
  389. package/dist/src/memory/selection.js.map +1 -0
  390. package/dist/src/memory/shadowstatus.js +69 -0
  391. package/dist/src/memory/shadowstatus.js.map +1 -0
  392. package/dist/src/memory/store.js +83 -0
  393. package/dist/src/memory/store.js.map +1 -0
  394. package/dist/src/memory/textidentity.js +8 -0
  395. package/dist/src/memory/textidentity.js.map +1 -0
  396. package/dist/src/metrics.js +773 -0
  397. package/dist/src/metrics.js.map +1 -0
  398. package/dist/src/missingconfig.js +8 -0
  399. package/dist/src/missingconfig.js.map +1 -0
  400. package/dist/src/npmverbs.js +228 -0
  401. package/dist/src/npmverbs.js.map +1 -0
  402. package/dist/src/onboard/choices.js +219 -0
  403. package/dist/src/onboard/choices.js.map +1 -0
  404. package/dist/src/onboard/engine.js +329 -0
  405. package/dist/src/onboard/engine.js.map +1 -0
  406. package/dist/src/onboard/launch.js +75 -0
  407. package/dist/src/onboard/launch.js.map +1 -0
  408. package/dist/src/onboard/pipin.js +22 -0
  409. package/dist/src/onboard/pipin.js.map +1 -0
  410. package/dist/src/onboard/stepforward.js +41 -0
  411. package/dist/src/onboard/stepforward.js.map +1 -0
  412. package/dist/src/onboard/steps/appslug.js +214 -0
  413. package/dist/src/onboard/steps/appslug.js.map +1 -0
  414. package/dist/src/onboard/steps/bringup.js +408 -0
  415. package/dist/src/onboard/steps/bringup.js.map +1 -0
  416. package/dist/src/onboard/steps/compose.js +372 -0
  417. package/dist/src/onboard/steps/compose.js.map +1 -0
  418. package/dist/src/onboard/steps/configdelivery.js +50 -0
  419. package/dist/src/onboard/steps/configdelivery.js.map +1 -0
  420. package/dist/src/onboard/steps/configfill.js +1218 -0
  421. package/dist/src/onboard/steps/configfill.js.map +1 -0
  422. package/dist/src/onboard/steps/deploysh.js +80 -0
  423. package/dist/src/onboard/steps/deploysh.js.map +1 -0
  424. package/dist/src/onboard/steps/embeddings.js +472 -0
  425. package/dist/src/onboard/steps/embeddings.js.map +1 -0
  426. package/dist/src/onboard/steps/escalation.js +571 -0
  427. package/dist/src/onboard/steps/escalation.js.map +1 -0
  428. package/dist/src/onboard/steps/githubaccount.js +595 -0
  429. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  430. package/dist/src/onboard/steps/githubapp.js +1150 -0
  431. package/dist/src/onboard/steps/githubapp.js.map +1 -0
  432. package/dist/src/onboard/steps/golive.js +508 -0
  433. package/dist/src/onboard/steps/golive.js.map +1 -0
  434. package/dist/src/onboard/steps/index.js +78 -0
  435. package/dist/src/onboard/steps/index.js.map +1 -0
  436. package/dist/src/onboard/steps/ingress.js +817 -0
  437. package/dist/src/onboard/steps/ingress.js.map +1 -0
  438. package/dist/src/onboard/steps/inventory.js +420 -0
  439. package/dist/src/onboard/steps/inventory.js.map +1 -0
  440. package/dist/src/onboard/steps/llmauth.js +966 -0
  441. package/dist/src/onboard/steps/llmauth.js.map +1 -0
  442. package/dist/src/onboard/steps/memory.js +1112 -0
  443. package/dist/src/onboard/steps/memory.js.map +1 -0
  444. package/dist/src/onboard/steps/memorykeys.js +43 -0
  445. package/dist/src/onboard/steps/memorykeys.js.map +1 -0
  446. package/dist/src/onboard/steps/preflight.js +172 -0
  447. package/dist/src/onboard/steps/preflight.js.map +1 -0
  448. package/dist/src/onboard/steps/proof.js +812 -0
  449. package/dist/src/onboard/steps/proof.js.map +1 -0
  450. package/dist/src/onboard/steps/ssh.js +300 -0
  451. package/dist/src/onboard/steps/ssh.js.map +1 -0
  452. package/dist/src/onboard/steps/topology.js +291 -0
  453. package/dist/src/onboard/steps/topology.js.map +1 -0
  454. package/dist/src/onboard/steps/transport.js +519 -0
  455. package/dist/src/onboard/steps/transport.js.map +1 -0
  456. package/dist/src/onboard/steps/tuiroute.js +102 -0
  457. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  458. package/dist/src/onboard/steps/verifyup.js +378 -0
  459. package/dist/src/onboard/steps/verifyup.js.map +1 -0
  460. package/dist/src/onboard/steps/vmdeploy.js +368 -0
  461. package/dist/src/onboard/steps/vmdeploy.js.map +1 -0
  462. package/dist/src/onboard/toml.js +53 -0
  463. package/dist/src/onboard/toml.js.map +1 -0
  464. package/dist/src/onboard/toolnames.js +30 -0
  465. package/dist/src/onboard/toolnames.js.map +1 -0
  466. package/dist/src/opencli.js +508 -0
  467. package/dist/src/opencli.js.map +1 -0
  468. package/dist/src/openpr.js +457 -0
  469. package/dist/src/openpr.js.map +1 -0
  470. package/dist/src/openviking/client.js +821 -0
  471. package/dist/src/openviking/client.js.map +1 -0
  472. package/dist/src/openviking/context.js +218 -0
  473. package/dist/src/openviking/context.js.map +1 -0
  474. package/dist/src/openviking/embedder.js +457 -0
  475. package/dist/src/openviking/embedder.js.map +1 -0
  476. package/dist/src/openviking/gapsink.js +38 -0
  477. package/dist/src/openviking/gapsink.js.map +1 -0
  478. package/dist/src/openviking/health.js +69 -0
  479. package/dist/src/openviking/health.js.map +1 -0
  480. package/dist/src/openviking/index.js +24 -0
  481. package/dist/src/openviking/index.js.map +1 -0
  482. package/dist/src/openviking/lessonfind.js +54 -0
  483. package/dist/src/openviking/lessonfind.js.map +1 -0
  484. package/dist/src/openviking/probe.js +89 -0
  485. package/dist/src/openviking/probe.js.map +1 -0
  486. package/dist/src/openviking/proc.js +95 -0
  487. package/dist/src/openviking/proc.js.map +1 -0
  488. package/dist/src/openviking/provisionreport.js +144 -0
  489. package/dist/src/openviking/provisionreport.js.map +1 -0
  490. package/dist/src/openviking/reembed.js +317 -0
  491. package/dist/src/openviking/reembed.js.map +1 -0
  492. package/dist/src/openviking/relayout.js +153 -0
  493. package/dist/src/openviking/relayout.js.map +1 -0
  494. package/dist/src/openviking/sidecar-main.js +39 -0
  495. package/dist/src/openviking/sidecar-main.js.map +1 -0
  496. package/dist/src/openviking/sidecar.js +79 -0
  497. package/dist/src/openviking/sidecar.js.map +1 -0
  498. package/dist/src/openviking/store.js +1758 -0
  499. package/dist/src/openviking/store.js.map +1 -0
  500. package/dist/src/openviking/supervisor.js +422 -0
  501. package/dist/src/openviking/supervisor.js.map +1 -0
  502. package/dist/src/openviking/types.js +5 -0
  503. package/dist/src/openviking/types.js.map +1 -0
  504. package/dist/src/operator-recovery.js +172 -0
  505. package/dist/src/operator-recovery.js.map +1 -0
  506. package/dist/src/operatormessage.js +44 -0
  507. package/dist/src/operatormessage.js.map +1 -0
  508. package/dist/src/operatorwrite.js +23 -0
  509. package/dist/src/operatorwrite.js.map +1 -0
  510. package/dist/src/outcomes.js +1364 -0
  511. package/dist/src/outcomes.js.map +1 -0
  512. package/dist/src/piauthcopy.js +5 -0
  513. package/dist/src/piauthcopy.js.map +1 -0
  514. package/dist/src/picli.js +45 -0
  515. package/dist/src/picli.js.map +1 -0
  516. package/dist/src/picwd.js +25 -0
  517. package/dist/src/picwd.js.map +1 -0
  518. package/dist/src/pilogin.js +35 -0
  519. package/dist/src/pilogin.js.map +1 -0
  520. package/dist/src/pingpong.js +1028 -0
  521. package/dist/src/pingpong.js.map +1 -0
  522. package/dist/src/posting-journal.js +442 -0
  523. package/dist/src/posting-journal.js.map +1 -0
  524. package/dist/src/posting-outbox.js +666 -0
  525. package/dist/src/posting-outbox.js.map +1 -0
  526. package/dist/src/posting-receipt.js +216 -0
  527. package/dist/src/posting-receipt.js.map +1 -0
  528. package/dist/src/posting.js +348 -0
  529. package/dist/src/posting.js.map +1 -0
  530. package/dist/src/postops.js +637 -0
  531. package/dist/src/postops.js.map +1 -0
  532. package/dist/src/pr-activity.js +11 -0
  533. package/dist/src/pr-activity.js.map +1 -0
  534. package/dist/src/pr-author.js +5 -0
  535. package/dist/src/pr-author.js.map +1 -0
  536. package/dist/src/pr-conversation.js +355 -0
  537. package/dist/src/pr-conversation.js.map +1 -0
  538. package/dist/src/pr-session-lifecycle.js +542 -0
  539. package/dist/src/pr-session-lifecycle.js.map +1 -0
  540. package/dist/src/preflight.js +341 -0
  541. package/dist/src/preflight.js.map +1 -0
  542. package/dist/src/priorresolve.js +275 -0
  543. package/dist/src/priorresolve.js.map +1 -0
  544. package/dist/src/qcrbin.js +180 -0
  545. package/dist/src/qcrbin.js.map +1 -0
  546. package/dist/src/recheck.js +1399 -0
  547. package/dist/src/recheck.js.map +1 -0
  548. package/dist/src/reconcile.js +840 -0
  549. package/dist/src/reconcile.js.map +1 -0
  550. package/dist/src/recordedfindings.js +15 -0
  551. package/dist/src/recordedfindings.js.map +1 -0
  552. package/dist/src/regression.js +546 -0
  553. package/dist/src/regression.js.map +1 -0
  554. package/dist/src/replay.js +598 -0
  555. package/dist/src/replay.js.map +1 -0
  556. package/dist/src/requiredpasses.js +113 -0
  557. package/dist/src/requiredpasses.js.map +1 -0
  558. package/dist/src/resumecheck.js +168 -0
  559. package/dist/src/resumecheck.js.map +1 -0
  560. package/dist/src/reviewcli.js +671 -0
  561. package/dist/src/reviewcli.js.map +1 -0
  562. package/dist/src/reviewcontext.js +22 -0
  563. package/dist/src/reviewcontext.js.map +1 -0
  564. package/dist/src/reviewdetach.js +574 -0
  565. package/dist/src/reviewdetach.js.map +1 -0
  566. package/dist/src/reviewhistory.js +310 -0
  567. package/dist/src/reviewhistory.js.map +1 -0
  568. package/dist/src/reviewhome.js +26 -0
  569. package/dist/src/reviewhome.js.map +1 -0
  570. package/dist/src/reviewmemory.js +110 -0
  571. package/dist/src/reviewmemory.js.map +1 -0
  572. package/dist/src/reviewnow.js +45 -0
  573. package/dist/src/reviewnow.js.map +1 -0
  574. package/dist/src/reviewrendercli.js +104 -0
  575. package/dist/src/reviewrendercli.js.map +1 -0
  576. package/dist/src/round-work.js +20 -0
  577. package/dist/src/round-work.js.map +1 -0
  578. package/dist/src/runhost.js +72 -0
  579. package/dist/src/runhost.js.map +1 -0
  580. package/dist/src/scanauth.js +25 -0
  581. package/dist/src/scanauth.js.map +1 -0
  582. package/dist/src/scanbox.js +846 -0
  583. package/dist/src/scanbox.js.map +1 -0
  584. package/dist/src/scancli.js +1074 -0
  585. package/dist/src/scancli.js.map +1 -0
  586. package/dist/src/scandriverlog.js +30 -0
  587. package/dist/src/scandriverlog.js.map +1 -0
  588. package/dist/src/session-bridge.js +75 -0
  589. package/dist/src/session-bridge.js.map +1 -0
  590. package/dist/src/session-command-payload.js +29 -0
  591. package/dist/src/session-command-payload.js.map +1 -0
  592. package/dist/src/session-command-supervisor.js +939 -0
  593. package/dist/src/session-command-supervisor.js.map +1 -0
  594. package/dist/src/sessionjsonl.js +85 -0
  595. package/dist/src/sessionjsonl.js.map +1 -0
  596. package/dist/src/sessions.js +541 -0
  597. package/dist/src/sessions.js.map +1 -0
  598. package/dist/src/setup/adapters.js +330 -0
  599. package/dist/src/setup/adapters.js.map +1 -0
  600. package/dist/src/setup/baselines.js +64 -0
  601. package/dist/src/setup/baselines.js.map +1 -0
  602. package/dist/src/setup/caserunner.js +554 -0
  603. package/dist/src/setup/caserunner.js.map +1 -0
  604. package/dist/src/setup/configcommit.js +358 -0
  605. package/dist/src/setup/configcommit.js.map +1 -0
  606. package/dist/src/setup/engine.js +672 -0
  607. package/dist/src/setup/engine.js.map +1 -0
  608. package/dist/src/setup/facts.js +593 -0
  609. package/dist/src/setup/facts.js.map +1 -0
  610. package/dist/src/setup/findings.js +948 -0
  611. package/dist/src/setup/findings.js.map +1 -0
  612. package/dist/src/setup/history.js +338 -0
  613. package/dist/src/setup/history.js.map +1 -0
  614. package/dist/src/setup/interview.js +185 -0
  615. package/dist/src/setup/interview.js.map +1 -0
  616. package/dist/src/setup/ledger.js +207 -0
  617. package/dist/src/setup/ledger.js.map +1 -0
  618. package/dist/src/setup/pins.js +43 -0
  619. package/dist/src/setup/pins.js.map +1 -0
  620. package/dist/src/setup/portrait.js +432 -0
  621. package/dist/src/setup/portrait.js.map +1 -0
  622. package/dist/src/setup/presentation.js +9 -0
  623. package/dist/src/setup/presentation.js.map +1 -0
  624. package/dist/src/setup/profile.js +417 -0
  625. package/dist/src/setup/profile.js.map +1 -0
  626. package/dist/src/setup/replay.js +240 -0
  627. package/dist/src/setup/replay.js.map +1 -0
  628. package/dist/src/setup/replaybuild.js +311 -0
  629. package/dist/src/setup/replaybuild.js.map +1 -0
  630. package/dist/src/setup/replayrun.js +806 -0
  631. package/dist/src/setup/replayrun.js.map +1 -0
  632. package/dist/src/setup/replaysource.js +169 -0
  633. package/dist/src/setup/replaysource.js.map +1 -0
  634. package/dist/src/setup/retune.js +333 -0
  635. package/dist/src/setup/retune.js.map +1 -0
  636. package/dist/src/setup/retunelog.js +51 -0
  637. package/dist/src/setup/retunelog.js.map +1 -0
  638. package/dist/src/setup/scan.js +725 -0
  639. package/dist/src/setup/scan.js.map +1 -0
  640. package/dist/src/setup/scanbuild.js +133 -0
  641. package/dist/src/setup/scanbuild.js.map +1 -0
  642. package/dist/src/setup/scanfacts.js +96 -0
  643. package/dist/src/setup/scanfacts.js.map +1 -0
  644. package/dist/src/setup/scanlease.js +251 -0
  645. package/dist/src/setup/scanlease.js.map +1 -0
  646. package/dist/src/setup/scanreport.js +289 -0
  647. package/dist/src/setup/scanreport.js.map +1 -0
  648. package/dist/src/setup/scanrun.js +1733 -0
  649. package/dist/src/setup/scanrun.js.map +1 -0
  650. package/dist/src/setup/scantree.js +257 -0
  651. package/dist/src/setup/scantree.js.map +1 -0
  652. package/dist/src/setup/stage2.js +617 -0
  653. package/dist/src/setup/stage2.js.map +1 -0
  654. package/dist/src/setup/stage2request.js +259 -0
  655. package/dist/src/setup/stage2request.js.map +1 -0
  656. package/dist/src/setup/survey.js +307 -0
  657. package/dist/src/setup/survey.js.map +1 -0
  658. package/dist/src/setup/surveyrun.js +549 -0
  659. package/dist/src/setup/surveyrun.js.map +1 -0
  660. package/dist/src/setup/textsniff.js +89 -0
  661. package/dist/src/setup/textsniff.js.map +1 -0
  662. package/dist/src/setup/trackersink.js +395 -0
  663. package/dist/src/setup/trackersink.js.map +1 -0
  664. package/dist/src/setup/triage.js +636 -0
  665. package/dist/src/setup/triage.js.map +1 -0
  666. package/dist/src/setup/tuning.js +585 -0
  667. package/dist/src/setup/tuning.js.map +1 -0
  668. package/dist/src/setup/unitcut.js +437 -0
  669. package/dist/src/setup/unitcut.js.map +1 -0
  670. package/dist/src/singleflight.js +72 -0
  671. package/dist/src/singleflight.js.map +1 -0
  672. package/dist/src/slack/announce.js +725 -0
  673. package/dist/src/slack/announce.js.map +1 -0
  674. package/dist/src/slack/api.js +227 -0
  675. package/dist/src/slack/api.js.map +1 -0
  676. package/dist/src/slack/messages.js +158 -0
  677. package/dist/src/slack/messages.js.map +1 -0
  678. package/dist/src/slack/origin.js +550 -0
  679. package/dist/src/slack/origin.js.map +1 -0
  680. package/dist/src/slack/policy.js +225 -0
  681. package/dist/src/slack/policy.js.map +1 -0
  682. package/dist/src/slack/reply.js +153 -0
  683. package/dist/src/slack/reply.js.map +1 -0
  684. package/dist/src/slack/router.js +189 -0
  685. package/dist/src/slack/router.js.map +1 -0
  686. package/dist/src/slack/socket.js +323 -0
  687. package/dist/src/slack/socket.js.map +1 -0
  688. package/dist/src/slack/types.js +11 -0
  689. package/dist/src/slack/types.js.map +1 -0
  690. package/dist/src/sqlitehint.js +30 -0
  691. package/dist/src/sqlitehint.js.map +1 -0
  692. package/dist/src/ssh-observation.js +25 -0
  693. package/dist/src/ssh-observation.js.map +1 -0
  694. package/dist/src/state.js +5869 -0
  695. package/dist/src/state.js.map +1 -0
  696. package/dist/src/statemachine.js +223 -0
  697. package/dist/src/statemachine.js.map +1 -0
  698. package/dist/src/stubs.js +52 -0
  699. package/dist/src/stubs.js.map +1 -0
  700. package/dist/src/takeover.js +506 -0
  701. package/dist/src/takeover.js.map +1 -0
  702. package/dist/src/takeoverpi.js +262 -0
  703. package/dist/src/takeoverpi.js.map +1 -0
  704. package/dist/src/terminal-failure.js +26 -0
  705. package/dist/src/terminal-failure.js.map +1 -0
  706. package/dist/src/thread-resolution-refusal.js +4 -0
  707. package/dist/src/thread-resolution-refusal.js.map +1 -0
  708. package/dist/src/tmux.js +126 -0
  709. package/dist/src/tmux.js.map +1 -0
  710. package/dist/src/trackerid.js +43 -0
  711. package/dist/src/trackerid.js.map +1 -0
  712. package/dist/src/transcriptindex.js +512 -0
  713. package/dist/src/transcriptindex.js.map +1 -0
  714. package/dist/src/tui/actions.js +404 -0
  715. package/dist/src/tui/actions.js.map +1 -0
  716. package/dist/src/tui/alerts.js +523 -0
  717. package/dist/src/tui/alerts.js.map +1 -0
  718. package/dist/src/tui/app.js +1884 -0
  719. package/dist/src/tui/app.js.map +1 -0
  720. package/dist/src/tui/archiveops.js +164 -0
  721. package/dist/src/tui/archiveops.js.map +1 -0
  722. package/dist/src/tui/autofile.js +191 -0
  723. package/dist/src/tui/autofile.js.map +1 -0
  724. package/dist/src/tui/board.js +1219 -0
  725. package/dist/src/tui/board.js.map +1 -0
  726. package/dist/src/tui/candidates.js +699 -0
  727. package/dist/src/tui/candidates.js.map +1 -0
  728. package/dist/src/tui/cellwidth.js +151 -0
  729. package/dist/src/tui/cellwidth.js.map +1 -0
  730. package/dist/src/tui/changelog-store.js +404 -0
  731. package/dist/src/tui/changelog-store.js.map +1 -0
  732. package/dist/src/tui/chord.js +26 -0
  733. package/dist/src/tui/chord.js.map +1 -0
  734. package/dist/src/tui/clipboard.js +81 -0
  735. package/dist/src/tui/clipboard.js.map +1 -0
  736. package/dist/src/tui/cmdbar.js +256 -0
  737. package/dist/src/tui/cmdbar.js.map +1 -0
  738. package/dist/src/tui/companion.js +1339 -0
  739. package/dist/src/tui/companion.js.map +1 -0
  740. package/dist/src/tui/companionplan.js +200 -0
  741. package/dist/src/tui/companionplan.js.map +1 -0
  742. package/dist/src/tui/companiontab.js +61 -0
  743. package/dist/src/tui/companiontab.js.map +1 -0
  744. package/dist/src/tui/confighome.js +105 -0
  745. package/dist/src/tui/confighome.js.map +1 -0
  746. package/dist/src/tui/connect.js +50 -0
  747. package/dist/src/tui/connect.js.map +1 -0
  748. package/dist/src/tui/deploy-ceremony.js +75 -0
  749. package/dist/src/tui/deploy-ceremony.js.map +1 -0
  750. package/dist/src/tui/deploy-drain.js +93 -0
  751. package/dist/src/tui/deploy-drain.js.map +1 -0
  752. package/dist/src/tui/deploy-process.js +234 -0
  753. package/dist/src/tui/deploy-process.js.map +1 -0
  754. package/dist/src/tui/deploytab.js +1263 -0
  755. package/dist/src/tui/deploytab.js.map +1 -0
  756. package/dist/src/tui/design.js +111 -0
  757. package/dist/src/tui/design.js.map +1 -0
  758. package/dist/src/tui/digesttab.js +148 -0
  759. package/dist/src/tui/digesttab.js.map +1 -0
  760. package/dist/src/tui/emptystate.js +32 -0
  761. package/dist/src/tui/emptystate.js.map +1 -0
  762. package/dist/src/tui/evidence.js +790 -0
  763. package/dist/src/tui/evidence.js.map +1 -0
  764. package/dist/src/tui/feed.js +415 -0
  765. package/dist/src/tui/feed.js.map +1 -0
  766. package/dist/src/tui/feedbacktab.js +907 -0
  767. package/dist/src/tui/feedbacktab.js.map +1 -0
  768. package/dist/src/tui/filters.js +75 -0
  769. package/dist/src/tui/filters.js.map +1 -0
  770. package/dist/src/tui/ghread.js +328 -0
  771. package/dist/src/tui/ghread.js.map +1 -0
  772. package/dist/src/tui/historypull.js +61 -0
  773. package/dist/src/tui/historypull.js.map +1 -0
  774. package/dist/src/tui/inboxpull.js +197 -0
  775. package/dist/src/tui/inboxpull.js.map +1 -0
  776. package/dist/src/tui/index.js +1511 -0
  777. package/dist/src/tui/index.js.map +1 -0
  778. package/dist/src/tui/issuesink.js +206 -0
  779. package/dist/src/tui/issuesink.js.map +1 -0
  780. package/dist/src/tui/jsonfile.js +32 -0
  781. package/dist/src/tui/jsonfile.js.map +1 -0
  782. package/dist/src/tui/keys.js +97 -0
  783. package/dist/src/tui/keys.js.map +1 -0
  784. package/dist/src/tui/layout.js +70 -0
  785. package/dist/src/tui/layout.js.map +1 -0
  786. package/dist/src/tui/learning.js +469 -0
  787. package/dist/src/tui/learning.js.map +1 -0
  788. package/dist/src/tui/lessonswitch.js +103 -0
  789. package/dist/src/tui/lessonswitch.js.map +1 -0
  790. package/dist/src/tui/livecontrol.js +167 -0
  791. package/dist/src/tui/livecontrol.js.map +1 -0
  792. package/dist/src/tui/memforward.js +347 -0
  793. package/dist/src/tui/memforward.js.map +1 -0
  794. package/dist/src/tui/memincident.js +287 -0
  795. package/dist/src/tui/memincident.js.map +1 -0
  796. package/dist/src/tui/memory.js +485 -0
  797. package/dist/src/tui/memory.js.map +1 -0
  798. package/dist/src/tui/memtab.js +513 -0
  799. package/dist/src/tui/memtab.js.map +1 -0
  800. package/dist/src/tui/metricstab.js +979 -0
  801. package/dist/src/tui/metricstab.js.map +1 -0
  802. package/dist/src/tui/model.js +129 -0
  803. package/dist/src/tui/model.js.map +1 -0
  804. package/dist/src/tui/mouse.js +83 -0
  805. package/dist/src/tui/mouse.js.map +1 -0
  806. package/dist/src/tui/msg.js +196 -0
  807. package/dist/src/tui/msg.js.map +1 -0
  808. package/dist/src/tui/nativecompanion.js +321 -0
  809. package/dist/src/tui/nativecompanion.js.map +1 -0
  810. package/dist/src/tui/openurl.js +17 -0
  811. package/dist/src/tui/openurl.js.map +1 -0
  812. package/dist/src/tui/paint.js +501 -0
  813. package/dist/src/tui/paint.js.map +1 -0
  814. package/dist/src/tui/panes.js +74 -0
  815. package/dist/src/tui/panes.js.map +1 -0
  816. package/dist/src/tui/pipane.js +166 -0
  817. package/dist/src/tui/pipane.js.map +1 -0
  818. package/dist/src/tui/poll-hooks.js +11 -0
  819. package/dist/src/tui/poll-hooks.js.map +1 -0
  820. package/dist/src/tui/poll.js +113 -0
  821. package/dist/src/tui/poll.js.map +1 -0
  822. package/dist/src/tui/proposals.js +715 -0
  823. package/dist/src/tui/proposals.js.map +1 -0
  824. package/dist/src/tui/prpause.js +139 -0
  825. package/dist/src/tui/prpause.js.map +1 -0
  826. package/dist/src/tui/reload.js +351 -0
  827. package/dist/src/tui/reload.js.map +1 -0
  828. package/dist/src/tui/remote.js +318 -0
  829. package/dist/src/tui/remote.js.map +1 -0
  830. package/dist/src/tui/rollover.js +767 -0
  831. package/dist/src/tui/rollover.js.map +1 -0
  832. package/dist/src/tui/route.js +143 -0
  833. package/dist/src/tui/route.js.map +1 -0
  834. package/dist/src/tui/scantab.js +1053 -0
  835. package/dist/src/tui/scantab.js.map +1 -0
  836. package/dist/src/tui/setupentry.js +81 -0
  837. package/dist/src/tui/setupentry.js.map +1 -0
  838. package/dist/src/tui/setupstate.js +29 -0
  839. package/dist/src/tui/setupstate.js.map +1 -0
  840. package/dist/src/tui/sidebar.js +602 -0
  841. package/dist/src/tui/sidebar.js.map +1 -0
  842. package/dist/src/tui/sshtriage.js +490 -0
  843. package/dist/src/tui/sshtriage.js.map +1 -0
  844. package/dist/src/tui/startup.js +172 -0
  845. package/dist/src/tui/startup.js.map +1 -0
  846. package/dist/src/tui/tabs.js +41 -0
  847. package/dist/src/tui/tabs.js.map +1 -0
  848. package/dist/src/tui/termmirror.js +217 -0
  849. package/dist/src/tui/termmirror.js.map +1 -0
  850. package/dist/src/tui/theme.js +96 -0
  851. package/dist/src/tui/theme.js.map +1 -0
  852. package/dist/src/tui/ticketstab.js +224 -0
  853. package/dist/src/tui/ticketstab.js.map +1 -0
  854. package/dist/src/tui/tmuxsocket.js +101 -0
  855. package/dist/src/tui/tmuxsocket.js.map +1 -0
  856. package/dist/src/tui/trackertickets.js +77 -0
  857. package/dist/src/tui/trackertickets.js.map +1 -0
  858. package/dist/src/tui/unwedge.js +235 -0
  859. package/dist/src/tui/unwedge.js.map +1 -0
  860. package/dist/src/tui/viewer.js +479 -0
  861. package/dist/src/tui/viewer.js.map +1 -0
  862. package/dist/src/tui/viewerinput.js +125 -0
  863. package/dist/src/tui/viewerinput.js.map +1 -0
  864. package/dist/src/updatenotice.js +143 -0
  865. package/dist/src/updatenotice.js.map +1 -0
  866. package/dist/src/usage.js +1007 -0
  867. package/dist/src/usage.js.map +1 -0
  868. package/dist/src/verdicts.js +3860 -0
  869. package/dist/src/verdicts.js.map +1 -0
  870. package/dist/src/version.js +58 -0
  871. package/dist/src/version.js.map +1 -0
  872. package/dist/src/wakes.js +411 -0
  873. package/dist/src/wakes.js.map +1 -0
  874. package/dist/src/webhook.js +736 -0
  875. package/dist/src/webhook.js.map +1 -0
  876. package/dist/src/webhookheal.js +238 -0
  877. package/dist/src/webhookheal.js.map +1 -0
  878. package/dist/src/wire.js +1823 -0
  879. package/dist/src/wire.js.map +1 -0
  880. package/dist/src/worker-session-host.js +531 -0
  881. package/dist/src/worker-session-host.js.map +1 -0
  882. package/dist/src/workerbreaker.js +112 -0
  883. package/dist/src/workerbreaker.js.map +1 -0
  884. package/dist/src/workercontrol.js +49 -0
  885. package/dist/src/workercontrol.js.map +1 -0
  886. package/dist/src/workerlease.js +351 -0
  887. package/dist/src/workerlease.js.map +1 -0
  888. package/dist/src/workerliveness.js +84 -0
  889. package/dist/src/workerliveness.js.map +1 -0
  890. package/dist/src/workerlog.js +65 -0
  891. package/dist/src/workerlog.js.map +1 -0
  892. package/dist/src/workerruntime.js +10 -0
  893. package/dist/src/workerruntime.js.map +1 -0
  894. package/dist/src/workersupervisor.js +1206 -0
  895. package/dist/src/workersupervisor.js.map +1 -0
  896. package/dist/src/workerusage.js +23 -0
  897. package/dist/src/workerusage.js.map +1 -0
  898. package/dist/src/workerworld.js +338 -0
  899. package/dist/src/workerworld.js.map +1 -0
  900. package/dist/src/worktrees.js +984 -0
  901. package/dist/src/worktrees.js.map +1 -0
  902. package/docker/Dockerfile +284 -0
  903. package/docker/Dockerfile.dockerignore +35 -0
  904. package/docker/README.md +221 -0
  905. package/docker/companion/Dockerfile +139 -0
  906. package/docker/companion/agents/evidence.md +34 -0
  907. package/docker/companion/agents/explore.md +26 -0
  908. package/docker/companion/agents/pass-survey.md +49 -0
  909. package/docker/companion/agents/study.md +26 -0
  910. package/docker/companion/entrypoint.sh +362 -0
  911. package/docker/companion/memory-health.mjs +269 -0
  912. package/docker/entrypoint.sh +799 -0
  913. package/docker/memory/Dockerfile +191 -0
  914. package/docker/memory/Dockerfile.dockerignore +20 -0
  915. package/docker/memory/README.md +60 -0
  916. package/docker/memory/entrypoint.sh +163 -0
  917. package/docker/setid/qcr-setid.c +109 -0
  918. package/docker/tunnel/Dockerfile.cloudflared +51 -0
  919. package/docker/tunnel/Dockerfile.ngrok +40 -0
  920. package/docker/tunnel/sidecar.mjs +411 -0
  921. package/docker/vm/tmux.conf +26 -0
  922. package/docker-compose.yml +348 -0
  923. package/extensions/cli/bash-guard-ambient.ts +71 -0
  924. package/extensions/cli/bash-guard.ts +12 -0
  925. package/extensions/cli/companion-guard.ts +8 -0
  926. package/extensions/cli/debug-round.ts +9 -0
  927. package/extensions/cli/inject-bridge.ts +8 -0
  928. package/extensions/cli/ledger-pin.ts +13 -0
  929. package/extensions/cli/lens.ts +22 -0
  930. package/extensions/cli/login-route.ts +10 -0
  931. package/extensions/cli/muted-theme.ts +9 -0
  932. package/extensions/cli/onboarding.ts +8 -0
  933. package/extensions/cli/setup.ts +8 -0
  934. package/extensions/cli/tui-view.ts +8 -0
  935. package/extensions/companion-guard.ts +411 -0
  936. package/extensions/debug-round.ts +281 -0
  937. package/extensions/inject-bridge.ts +630 -0
  938. package/extensions/inline-bash.ts +102 -0
  939. package/extensions/login-route.ts +220 -0
  940. package/extensions/muted-theme.json +68 -0
  941. package/extensions/muted-theme.ts +23 -0
  942. package/extensions/onboarding.ts +262 -0
  943. package/extensions/setup-replay.ts +336 -0
  944. package/extensions/setup-scan.ts +1236 -0
  945. package/extensions/setup-triage.ts +190 -0
  946. package/extensions/setup.ts +1428 -0
  947. package/extensions/tui-view.ts +489 -0
  948. package/extensions/vendor/openviking-pi/LICENSE +201 -0
  949. package/extensions/vendor/openviking-pi/NOTICE +68 -0
  950. package/extensions/vendor/openviking-pi/README.md +550 -0
  951. package/extensions/vendor/openviking-pi/client.ts +588 -0
  952. package/extensions/vendor/openviking-pi/config.json +28 -0
  953. package/extensions/vendor/openviking-pi/config.ts +271 -0
  954. package/extensions/vendor/openviking-pi/index.ts +523 -0
  955. package/extensions/vendor/openviking-pi/lib/capture-adapter.d.mts +10 -0
  956. package/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +88 -0
  957. package/extensions/vendor/openviking-pi/lib/takeover-core.d.mts +80 -0
  958. package/extensions/vendor/openviking-pi/lib/takeover-core.mjs +406 -0
  959. package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.d.mts +1 -0
  960. package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +42 -0
  961. package/extensions/vendor/openviking-pi/patches/0000-vendoring-and-build-conformance.patch +726 -0
  962. package/extensions/vendor/openviking-pi/patches/0001-remove-session-before-compact-handler.patch +101 -0
  963. package/extensions/vendor/openviking-pi/patches/0002-no-ambient-work-in-subagent-leaves.patch +202 -0
  964. package/extensions/vendor/openviking-pi/patches/0003-never-auto-create-openviking-sessions.patch +120 -0
  965. package/extensions/vendor/openviking-pi/patches/0004-viking-write-durable-batch-write.patch +521 -0
  966. package/extensions/vendor/openviking-pi/patches/0005-second-memory-namespace.patch +786 -0
  967. package/extensions/vendor/openviking-pi/patches/0006-writes-that-stick-and-honest-errors.patch +996 -0
  968. package/extensions/vendor/openviking-pi/patches/0007-bounded-recall-no-commit.patch +794 -0
  969. package/extensions/vendor/openviking-pi/patches/0008-pr-derived-recall.patch +379 -0
  970. package/extensions/vendor/openviking-pi/patches/0009-pr-session-tools-in-round-areas.patch +375 -0
  971. package/extensions/vendor/openviking-pi/patches/0010-typed-lessons.patch +448 -0
  972. package/extensions/vendor/openviking-pi/patches/0011-review-session-memory-boundary.patch +382 -0
  973. package/extensions/vendor/openviking-pi/patches/0012-typecheck-against-the-real-pi.patch +129 -0
  974. package/extensions/vendor/openviking-pi/patches/0013-createopenviking-factory.patch +1168 -0
  975. package/extensions/vendor/openviking-pi/qcr-hint.ts +313 -0
  976. package/extensions/vendor/openviking-pi/qcr-honest.ts +183 -0
  977. package/extensions/vendor/openviking-pi/qcr-hooks.ts +87 -0
  978. package/extensions/vendor/openviking-pi/qcr-leaf.ts +78 -0
  979. package/extensions/vendor/openviking-pi/qcr-lesson.ts +231 -0
  980. package/extensions/vendor/openviking-pi/qcr-namespaces.ts +198 -0
  981. package/extensions/vendor/openviking-pi/qcr-recall.ts +288 -0
  982. package/extensions/vendor/openviking-pi/qcr-shim.ts +142 -0
  983. package/extensions/vendor/openviking-pi/qcr-write.ts +427 -0
  984. package/extensions/vendor/openviking-pi/recall.ts +149 -0
  985. package/extensions/vendor/openviking-pi/shared/capture-utils.d.mts +9 -0
  986. package/extensions/vendor/openviking-pi/shared/capture-utils.mjs +515 -0
  987. package/extensions/vendor/openviking-pi/shared/credentials.d.mts +14 -0
  988. package/extensions/vendor/openviking-pi/shared/credentials.mjs +228 -0
  989. package/extensions/vendor/openviking-pi/shared/pending-queue.d.mts +19 -0
  990. package/extensions/vendor/openviking-pi/shared/pending-queue.mjs +458 -0
  991. package/extensions/vendor/openviking-pi/shared/profile-inject.d.mts +17 -0
  992. package/extensions/vendor/openviking-pi/shared/profile-inject.mjs +294 -0
  993. package/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +195 -0
  994. package/extensions/vendor/openviking-pi/shared/recall-core.d.mts +13 -0
  995. package/extensions/vendor/openviking-pi/shared/recall-core.mjs +609 -0
  996. package/extensions/vendor/openviking-pi/shared/retryable.mjs +9 -0
  997. package/extensions/vendor/openviking-pi/shared/session-model.d.mts +3 -0
  998. package/extensions/vendor/openviking-pi/shared/session-model.mjs +57 -0
  999. package/extensions/vendor/openviking-pi/shared/uri-guard.mjs +56 -0
  1000. package/extensions/vendor/openviking-pi/shared/workspace-peer.d.mts +5 -0
  1001. package/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
  1002. package/extensions/vendor/openviking-pi/sync.ts +163 -0
  1003. package/extensions/vendor/openviking-pi/takeover.ts +36 -0
  1004. package/extensions/vendor/openviking-pi/tools.ts +479 -0
  1005. package/extensions/vendor/openviking-pi/upstream.json +48 -0
  1006. package/package.json +138 -4
  1007. package/profiles/default/README.md +21 -0
  1008. package/profiles/default/addenda/.gitkeep +0 -0
  1009. package/profiles/default/profile.toml +17 -0
  1010. package/profiles/example-django/README.md +21 -0
  1011. package/profiles/example-django/addenda/10-migration-discipline.md +13 -0
  1012. package/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
  1013. package/profiles/example-django/addenda/30-test-doctrine.md +12 -0
  1014. package/profiles/example-django/addenda/40-orm-usage.md +19 -0
  1015. package/profiles/example-django/addenda/50-declared-shape.md +10 -0
  1016. package/profiles/example-django/addenda/60-config-placement.md +14 -0
  1017. package/profiles/example-django/heuristics.toml +24 -0
  1018. package/profiles/example-django/profile.toml +9 -0
  1019. package/profiles/fast/README.md +24 -0
  1020. package/profiles/fast/profile.toml +59 -0
  1021. package/profiles/minimal/README.md +17 -0
  1022. package/profiles/minimal/addenda/.gitkeep +0 -0
  1023. package/profiles/minimal/profile.toml +8 -0
  1024. package/profiles/snappy/README.md +24 -0
  1025. package/profiles/snappy/addenda/10-race-as-question.md +6 -0
  1026. package/profiles/snappy/profile.toml +46 -0
  1027. package/prompts/companion-day-open.md +46 -0
  1028. package/prompts/companion-first-run.md +104 -0
  1029. package/prompts/companion.md +821 -0
  1030. package/prompts/conversation-system.md +117 -0
  1031. package/prompts/eod-sweep.md +120 -0
  1032. package/prompts/onboarding.md +113 -0
  1033. package/prompts/pass-common.md +52 -0
  1034. package/prompts/pass-quality-common.md +22 -0
  1035. package/prompts/round-first.md +874 -0
  1036. package/prompts/round-follow-up.md +991 -0
  1037. package/prompts/round-re-review.md +889 -0
  1038. package/prompts/round-recheck.md +196 -0
  1039. package/prompts/round-staleness.md +856 -0
  1040. package/schemas/review-result.v1.json +391 -0
  1041. package/schemas/scan-result.v1.json +421 -0
  1042. package/schemas/version.v1.json +57 -0
  1043. package/scripts/approve-pr.sh +16 -0
  1044. package/scripts/build-stamp.mjs +67 -0
  1045. package/scripts/commit-verdict.sh +155 -0
  1046. package/scripts/companion-e2e.sh +250 -0
  1047. package/scripts/companion-memory-forward.mjs +1966 -0
  1048. package/scripts/config-fingerprint.sh +28 -0
  1049. package/scripts/deploy-manifest.mjs +141 -0
  1050. package/scripts/deploy.sh +1435 -0
  1051. package/scripts/directory-lock.mjs +193 -0
  1052. package/scripts/heartbeat-fresh.mjs +54 -0
  1053. package/scripts/install-locator.sh +328 -0
  1054. package/scripts/install-wrapper.sh +76 -0
  1055. package/scripts/memory-search-check.mjs +98 -0
  1056. package/scripts/mint-token.sh +233 -0
  1057. package/scripts/openviking-embedder.py +392 -0
  1058. package/scripts/openviking-ov-conf.mjs +161 -0
  1059. package/scripts/openviking-provision.mjs +1122 -0
  1060. package/scripts/openviking-reembed.mjs +1154 -0
  1061. package/scripts/post-review.sh +16 -0
  1062. package/scripts/pr-checks.sh +256 -0
  1063. package/scripts/pr-context.py +174 -0
  1064. package/scripts/pr-context.sh +63 -0
  1065. package/scripts/pr-self.sh +144 -0
  1066. package/scripts/pr-threads.sh +145 -0
  1067. package/scripts/qcr +15 -0
  1068. package/scripts/qcr-host.sh +769 -0
  1069. package/scripts/render-review.sh +24 -0
  1070. package/scripts/reply-thread.sh +16 -0
  1071. package/scripts/required-passes.py +112 -0
  1072. package/scripts/smoke-companion.sh +211 -0
  1073. package/scripts/tui-deploy-runner.mjs +61 -0
  1074. package/scripts/verify-dist.mjs +201 -0
  1075. package/scripts/vm-ghcr-login.sh +188 -0
  1076. package/seeds/agents/pass-baseline.md +61 -0
  1077. package/seeds/agents/pass-contracts.md +30 -0
  1078. package/seeds/agents/pass-data-safety.md +54 -0
  1079. package/seeds/agents/pass-delivery.md +37 -0
  1080. package/seeds/agents/pass-prod-risk.md +24 -0
  1081. package/seeds/agents/pass-security.md +31 -0
  1082. package/seeds/agents/pass-statelessness.md +27 -0
  1083. package/seeds/agents/pass-ui.md +23 -0
  1084. package/seeds/agents/pass-validate.md +50 -0
  1085. package/seeds/prompts/conversation-system.md +117 -0
  1086. package/seeds/prompts/eod-sweep.md +120 -0
  1087. package/seeds/prompts/pass-common.md +52 -0
  1088. package/skills/companion/adjudicate/SKILL.md +247 -0
  1089. package/skills/companion/evidence/SKILL.md +150 -0
  1090. package/skills/companion/land/SKILL.md +200 -0
  1091. package/skills/companion/replay/SKILL.md +138 -0
  1092. package/skills/companion/setup/SKILL.md +167 -0
  1093. package/skills/companion/survey/SKILL.md +160 -0
  1094. package/skills/selfimprove/README.md +34 -0
  1095. package/skills/selfimprove/SKILL.md +127 -0
  1096. package/skills/selfimprove/agents/openai.yaml +2 -0
  1097. package/skills/selfimprove/commands/selfimprove.md +6 -0
  1098. package/skills/selfimprove/references/fix-loop.md +49 -0
  1099. package/skills/selfimprove/references/ledger.md +20 -0
  1100. package/skills/selfimprove/references/setup.md +40 -0
  1101. package/src/anomalies.ts +566 -0
  1102. package/src/answerworker.ts +251 -0
  1103. package/src/appstamp.ts +65 -0
  1104. package/src/archive/admission.ts +3 -0
  1105. package/src/archive/archivedb.ts +548 -0
  1106. package/src/archive/archiver.ts +1963 -0
  1107. package/src/archive/archivewrite.ts +279 -0
  1108. package/src/archive/bundle.ts +333 -0
  1109. package/src/archive/dbmove.ts +911 -0
  1110. package/src/archive/duplicatecounts.ts +98 -0
  1111. package/src/archive/eligibility.ts +181 -0
  1112. package/src/archive/extract.ts +342 -0
  1113. package/src/archive/fsutil.ts +415 -0
  1114. package/src/archive/gzfile.ts +370 -0
  1115. package/src/archive/kinds.ts +21 -0
  1116. package/src/archive/livecheckpoint.ts +217 -0
  1117. package/src/archive/liveretention.ts +180 -0
  1118. package/src/archive/manifest.ts +519 -0
  1119. package/src/archive/overseer.ts +336 -0
  1120. package/src/archive/piglobal.ts +174 -0
  1121. package/src/archive/plan.ts +612 -0
  1122. package/src/archive/prarchive.ts +984 -0
  1123. package/src/archive/prfiles.ts +406 -0
  1124. package/src/archive/report.ts +252 -0
  1125. package/src/archive/restore.ts +330 -0
  1126. package/src/archive/rows.ts +187 -0
  1127. package/src/archive/seal.ts +630 -0
  1128. package/src/archive/status.ts +719 -0
  1129. package/src/archive/storepass.ts +581 -0
  1130. package/src/backfill.ts +362 -0
  1131. package/src/bench/cost.ts +113 -0
  1132. package/src/bench/exec.ts +90 -0
  1133. package/src/bench/values.ts +13 -0
  1134. package/src/botcontrol.ts +176 -0
  1135. package/src/botworker.ts +953 -0
  1136. package/src/capture.ts +321 -0
  1137. package/src/cases.ts +1789 -0
  1138. package/src/channelfs.ts +26 -0
  1139. package/src/checklist.ts +506 -0
  1140. package/src/ciretry.ts +142 -0
  1141. package/src/cli.ts +2306 -0
  1142. package/src/cliops.ts +385 -0
  1143. package/src/clonedir.ts +15 -0
  1144. package/src/commandeffects.ts +57 -0
  1145. package/src/commands.ts +1704 -0
  1146. package/src/companioninbox.ts +239 -0
  1147. package/src/compare.ts +957 -0
  1148. package/src/config.ts +2070 -0
  1149. package/src/configcheck.ts +133 -0
  1150. package/src/coreext.ts +47 -0
  1151. package/src/daemon.ts +1327 -0
  1152. package/src/debuground.ts +927 -0
  1153. package/src/debugroundpi.ts +272 -0
  1154. package/src/diagnostics.ts +14 -0
  1155. package/src/digest.ts +183 -0
  1156. package/src/dispatch.ts +2251 -0
  1157. package/src/engine/answerguard.ts +27 -0
  1158. package/src/engine/botsdk.ts +336 -0
  1159. package/src/engine/caserunner-sdk.ts +423 -0
  1160. package/src/engine/debugpi.ts +247 -0
  1161. package/src/eod.ts +669 -0
  1162. package/src/eodpinwatch.ts +556 -0
  1163. package/src/escalation.ts +1253 -0
  1164. package/src/eventloop.ts +1045 -0
  1165. package/src/execboundary.ts +645 -0
  1166. package/src/feedback.ts +1162 -0
  1167. package/src/findingscheck.ts +298 -0
  1168. package/src/fsdenied.ts +24 -0
  1169. package/src/ghread.ts +242 -0
  1170. package/src/github.ts +1539 -0
  1171. package/src/githubtoken.ts +435 -0
  1172. package/src/homes.ts +175 -0
  1173. package/src/hostroute.ts +53 -0
  1174. package/src/improvements.ts +787 -0
  1175. package/src/install.ts +1103 -0
  1176. package/src/installkind.ts +50 -0
  1177. package/src/ipc/capability.ts +333 -0
  1178. package/src/ipc/client.ts +269 -0
  1179. package/src/ipc/control.ts +90 -0
  1180. package/src/ipc/ghreadshim.ts +94 -0
  1181. package/src/ipc/launcher.ts +65 -0
  1182. package/src/ipc/permits.ts +362 -0
  1183. package/src/ipc/postshim.ts +164 -0
  1184. package/src/ipc/protocol.ts +342 -0
  1185. package/src/ipc/server.ts +371 -0
  1186. package/src/ipc/workerspec.ts +218 -0
  1187. package/src/learner/learner.ts +1655 -0
  1188. package/src/learner/miner.ts +1290 -0
  1189. package/src/learner/packet.ts +340 -0
  1190. package/src/learner/receipt.ts +168 -0
  1191. package/src/learner/status.ts +107 -0
  1192. package/src/ledgercheck.ts +157 -0
  1193. package/src/lessonledger.ts +703 -0
  1194. package/src/lessonselect.ts +888 -0
  1195. package/src/lessonshadow.ts +810 -0
  1196. package/src/levers.ts +393 -0
  1197. package/src/live/projection.ts +491 -0
  1198. package/src/live/publish.ts +92 -0
  1199. package/src/live/read.ts +86 -0
  1200. package/src/livecli.ts +336 -0
  1201. package/src/loginhint.ts +61 -0
  1202. package/src/loginrefresh/atomic.ts +76 -0
  1203. package/src/loginrefresh/lock.ts +104 -0
  1204. package/src/loginrefresh/main.ts +59 -0
  1205. package/src/loginrefresh/pirefresh.ts +60 -0
  1206. package/src/loginrefresh/refresher.ts +326 -0
  1207. package/src/loginrefresh/supervisor.ts +273 -0
  1208. package/src/loginrefresh/types.ts +35 -0
  1209. package/src/memory/context.ts +385 -0
  1210. package/src/memory/datafence.ts +9 -0
  1211. package/src/memory/delivery.ts +171 -0
  1212. package/src/memory/eventtags.ts +80 -0
  1213. package/src/memory/health.ts +953 -0
  1214. package/src/memory/import.ts +664 -0
  1215. package/src/memory/layout.ts +261 -0
  1216. package/src/memory/legacy-file.ts +104 -0
  1217. package/src/memory/lessons.ts +447 -0
  1218. package/src/memory/ovsession.ts +601 -0
  1219. package/src/memory/relayout.ts +335 -0
  1220. package/src/memory/roundactivity.ts +209 -0
  1221. package/src/memory/selection.ts +989 -0
  1222. package/src/memory/shadowstatus.ts +125 -0
  1223. package/src/memory/store.ts +275 -0
  1224. package/src/memory/textidentity.ts +8 -0
  1225. package/src/metrics.ts +1056 -0
  1226. package/src/missingconfig.ts +7 -0
  1227. package/src/npmverbs.ts +266 -0
  1228. package/src/onboard/choices.ts +255 -0
  1229. package/src/onboard/engine.ts +537 -0
  1230. package/src/onboard/launch.ts +103 -0
  1231. package/src/onboard/pipin.ts +31 -0
  1232. package/src/onboard/stepforward.ts +61 -0
  1233. package/src/onboard/steps/appslug.ts +268 -0
  1234. package/src/onboard/steps/bringup.ts +482 -0
  1235. package/src/onboard/steps/compose.ts +490 -0
  1236. package/src/onboard/steps/configdelivery.ts +58 -0
  1237. package/src/onboard/steps/configfill.ts +1440 -0
  1238. package/src/onboard/steps/deploysh.ts +105 -0
  1239. package/src/onboard/steps/embeddings.ts +550 -0
  1240. package/src/onboard/steps/escalation.ts +640 -0
  1241. package/src/onboard/steps/githubaccount.ts +716 -0
  1242. package/src/onboard/steps/githubapp.ts +1498 -0
  1243. package/src/onboard/steps/golive.ts +579 -0
  1244. package/src/onboard/steps/index.ts +82 -0
  1245. package/src/onboard/steps/ingress.ts +974 -0
  1246. package/src/onboard/steps/inventory.ts +526 -0
  1247. package/src/onboard/steps/llmauth.ts +1180 -0
  1248. package/src/onboard/steps/memory.ts +1347 -0
  1249. package/src/onboard/steps/memorykeys.ts +85 -0
  1250. package/src/onboard/steps/preflight.ts +191 -0
  1251. package/src/onboard/steps/proof.ts +1042 -0
  1252. package/src/onboard/steps/ssh.ts +377 -0
  1253. package/src/onboard/steps/topology.ts +338 -0
  1254. package/src/onboard/steps/transport.ts +680 -0
  1255. package/src/onboard/steps/tuiroute.ts +119 -0
  1256. package/src/onboard/steps/verifyup.ts +467 -0
  1257. package/src/onboard/steps/vmdeploy.ts +442 -0
  1258. package/src/onboard/toml.ts +50 -0
  1259. package/src/onboard/toolnames.ts +34 -0
  1260. package/src/opencli.ts +591 -0
  1261. package/src/openpr.ts +568 -0
  1262. package/src/openviking/client.ts +1131 -0
  1263. package/src/openviking/context.ts +263 -0
  1264. package/src/openviking/embedder.ts +516 -0
  1265. package/src/openviking/gapsink.ts +63 -0
  1266. package/src/openviking/health.ts +83 -0
  1267. package/src/openviking/index.ts +119 -0
  1268. package/src/openviking/lessonfind.ts +92 -0
  1269. package/src/openviking/probe.ts +129 -0
  1270. package/src/openviking/proc.ts +110 -0
  1271. package/src/openviking/provisionreport.ts +160 -0
  1272. package/src/openviking/reembed.ts +412 -0
  1273. package/src/openviking/relayout.ts +177 -0
  1274. package/src/openviking/sidecar-main.ts +38 -0
  1275. package/src/openviking/sidecar.ts +106 -0
  1276. package/src/openviking/store.ts +2025 -0
  1277. package/src/openviking/supervisor.ts +474 -0
  1278. package/src/openviking/types.ts +174 -0
  1279. package/src/operator-recovery.ts +237 -0
  1280. package/src/operatormessage.ts +52 -0
  1281. package/src/operatorwrite.ts +25 -0
  1282. package/src/outcomes.ts +1677 -0
  1283. package/src/piauthcopy.ts +12 -0
  1284. package/src/picli.ts +49 -0
  1285. package/src/picwd.ts +25 -0
  1286. package/src/pilogin.ts +37 -0
  1287. package/src/pingpong.ts +1339 -0
  1288. package/src/posting-journal.ts +554 -0
  1289. package/src/posting-outbox.ts +810 -0
  1290. package/src/posting-receipt.ts +341 -0
  1291. package/src/posting.ts +603 -0
  1292. package/src/postops.ts +832 -0
  1293. package/src/pr-activity.ts +20 -0
  1294. package/src/pr-author.ts +6 -0
  1295. package/src/pr-conversation.ts +432 -0
  1296. package/src/pr-session-lifecycle.ts +631 -0
  1297. package/src/preflight.ts +391 -0
  1298. package/src/priorresolve.ts +343 -0
  1299. package/src/qcrbin.ts +186 -0
  1300. package/src/recheck.ts +1776 -0
  1301. package/src/reconcile.ts +1056 -0
  1302. package/src/recordedfindings.ts +27 -0
  1303. package/src/regression.ts +833 -0
  1304. package/src/replay.ts +816 -0
  1305. package/src/requiredpasses.ts +148 -0
  1306. package/src/resumecheck.ts +246 -0
  1307. package/src/reviewcli.ts +787 -0
  1308. package/src/reviewcontext.ts +35 -0
  1309. package/src/reviewdetach.ts +734 -0
  1310. package/src/reviewhistory.ts +441 -0
  1311. package/src/reviewhome.ts +36 -0
  1312. package/src/reviewmemory.ts +133 -0
  1313. package/src/reviewnow.ts +76 -0
  1314. package/src/reviewrendercli.ts +139 -0
  1315. package/src/round-work.ts +42 -0
  1316. package/src/runhost.ts +108 -0
  1317. package/src/scanauth.ts +36 -0
  1318. package/src/scanbox.ts +1081 -0
  1319. package/src/scancli.ts +1296 -0
  1320. package/src/scandriverlog.ts +32 -0
  1321. package/src/session-bridge.ts +139 -0
  1322. package/src/session-command-payload.ts +35 -0
  1323. package/src/session-command-supervisor.ts +1142 -0
  1324. package/src/sessionjsonl.ts +88 -0
  1325. package/src/sessions.ts +659 -0
  1326. package/src/setup/adapters.ts +335 -0
  1327. package/src/setup/baselines.ts +110 -0
  1328. package/src/setup/caserunner.ts +611 -0
  1329. package/src/setup/configcommit.ts +443 -0
  1330. package/src/setup/engine.ts +1001 -0
  1331. package/src/setup/facts.ts +627 -0
  1332. package/src/setup/findings.ts +1264 -0
  1333. package/src/setup/history.ts +443 -0
  1334. package/src/setup/interview.ts +243 -0
  1335. package/src/setup/ledger.ts +324 -0
  1336. package/src/setup/pins.ts +66 -0
  1337. package/src/setup/portrait.ts +635 -0
  1338. package/src/setup/presentation.ts +18 -0
  1339. package/src/setup/profile.ts +508 -0
  1340. package/src/setup/replay.ts +370 -0
  1341. package/src/setup/replaybuild.ts +392 -0
  1342. package/src/setup/replayrun.ts +1096 -0
  1343. package/src/setup/replaysource.ts +198 -0
  1344. package/src/setup/retune.ts +415 -0
  1345. package/src/setup/retunelog.ts +95 -0
  1346. package/src/setup/scan.ts +1033 -0
  1347. package/src/setup/scanbuild.ts +168 -0
  1348. package/src/setup/scanfacts.ts +107 -0
  1349. package/src/setup/scanlease.ts +297 -0
  1350. package/src/setup/scanreport.ts +373 -0
  1351. package/src/setup/scanrun.ts +2268 -0
  1352. package/src/setup/scantree.ts +259 -0
  1353. package/src/setup/stage2.ts +926 -0
  1354. package/src/setup/stage2request.ts +347 -0
  1355. package/src/setup/survey.ts +458 -0
  1356. package/src/setup/surveyrun.ts +758 -0
  1357. package/src/setup/textsniff.ts +95 -0
  1358. package/src/setup/trackersink.ts +433 -0
  1359. package/src/setup/triage.ts +768 -0
  1360. package/src/setup/tuning.ts +766 -0
  1361. package/src/setup/unitcut.ts +523 -0
  1362. package/src/singleflight.ts +86 -0
  1363. package/src/slack/announce.ts +911 -0
  1364. package/src/slack/api.ts +303 -0
  1365. package/src/slack/messages.ts +288 -0
  1366. package/src/slack/origin.ts +599 -0
  1367. package/src/slack/policy.ts +354 -0
  1368. package/src/slack/reply.ts +201 -0
  1369. package/src/slack/router.ts +285 -0
  1370. package/src/slack/socket.ts +384 -0
  1371. package/src/slack/types.ts +108 -0
  1372. package/src/sqlitehint.ts +32 -0
  1373. package/src/ssh-observation.ts +30 -0
  1374. package/src/state.ts +8261 -0
  1375. package/src/statemachine.ts +372 -0
  1376. package/src/stubs.ts +68 -0
  1377. package/src/takeover.ts +634 -0
  1378. package/src/takeoverpi.ts +306 -0
  1379. package/src/terminal-failure.ts +41 -0
  1380. package/src/thread-resolution-refusal.ts +2 -0
  1381. package/src/tmux.ts +153 -0
  1382. package/src/trackerid.ts +41 -0
  1383. package/src/transcriptindex.ts +665 -0
  1384. package/src/tui/actions.ts +484 -0
  1385. package/src/tui/alerts.ts +666 -0
  1386. package/src/tui/app.ts +2026 -0
  1387. package/src/tui/archiveops.ts +192 -0
  1388. package/src/tui/autofile.ts +233 -0
  1389. package/src/tui/board.ts +1529 -0
  1390. package/src/tui/candidates.ts +889 -0
  1391. package/src/tui/cellwidth.ts +148 -0
  1392. package/src/tui/changelog-store.ts +503 -0
  1393. package/src/tui/chord.ts +25 -0
  1394. package/src/tui/clipboard.ts +92 -0
  1395. package/src/tui/cmdbar.ts +299 -0
  1396. package/src/tui/companion.ts +1495 -0
  1397. package/src/tui/companionplan.ts +247 -0
  1398. package/src/tui/companiontab.ts +83 -0
  1399. package/src/tui/confighome.ts +155 -0
  1400. package/src/tui/connect.ts +74 -0
  1401. package/src/tui/deploy-ceremony.ts +104 -0
  1402. package/src/tui/deploy-drain.ts +129 -0
  1403. package/src/tui/deploy-process.ts +265 -0
  1404. package/src/tui/deploytab.ts +1523 -0
  1405. package/src/tui/design.ts +125 -0
  1406. package/src/tui/digesttab.ts +214 -0
  1407. package/src/tui/emptystate.ts +39 -0
  1408. package/src/tui/evidence.ts +999 -0
  1409. package/src/tui/feed.ts +604 -0
  1410. package/src/tui/feedbacktab.ts +1077 -0
  1411. package/src/tui/filters.ts +94 -0
  1412. package/src/tui/ghread.ts +388 -0
  1413. package/src/tui/historypull.ts +81 -0
  1414. package/src/tui/inboxpull.ts +236 -0
  1415. package/src/tui/index.ts +1656 -0
  1416. package/src/tui/issuesink.ts +238 -0
  1417. package/src/tui/jsonfile.ts +42 -0
  1418. package/src/tui/keys.ts +109 -0
  1419. package/src/tui/layout.ts +108 -0
  1420. package/src/tui/learning.ts +627 -0
  1421. package/src/tui/lessonswitch.ts +118 -0
  1422. package/src/tui/livecontrol.ts +228 -0
  1423. package/src/tui/memforward.ts +492 -0
  1424. package/src/tui/memincident.ts +363 -0
  1425. package/src/tui/memory.ts +620 -0
  1426. package/src/tui/memtab.ts +606 -0
  1427. package/src/tui/metricstab.ts +1177 -0
  1428. package/src/tui/model.ts +165 -0
  1429. package/src/tui/mouse.ts +98 -0
  1430. package/src/tui/msg.ts +268 -0
  1431. package/src/tui/nativecompanion.ts +417 -0
  1432. package/src/tui/openurl.ts +21 -0
  1433. package/src/tui/paint.ts +511 -0
  1434. package/src/tui/panes.ts +98 -0
  1435. package/src/tui/pipane.ts +219 -0
  1436. package/src/tui/poll-hooks.ts +12 -0
  1437. package/src/tui/poll.ts +147 -0
  1438. package/src/tui/proposals.ts +838 -0
  1439. package/src/tui/prpause.ts +159 -0
  1440. package/src/tui/reload.ts +436 -0
  1441. package/src/tui/remote.ts +421 -0
  1442. package/src/tui/rollover.ts +969 -0
  1443. package/src/tui/route.ts +173 -0
  1444. package/src/tui/scantab.ts +1290 -0
  1445. package/src/tui/setupentry.ts +93 -0
  1446. package/src/tui/setupstate.ts +52 -0
  1447. package/src/tui/sidebar.ts +749 -0
  1448. package/src/tui/sshtriage.ts +631 -0
  1449. package/src/tui/startup.ts +231 -0
  1450. package/src/tui/tabs.ts +45 -0
  1451. package/src/tui/termmirror.ts +224 -0
  1452. package/src/tui/theme.ts +96 -0
  1453. package/src/tui/ticketstab.ts +310 -0
  1454. package/src/tui/tmuxsocket.ts +129 -0
  1455. package/src/tui/trackertickets.ts +84 -0
  1456. package/src/tui/unwedge.ts +274 -0
  1457. package/src/tui/viewer.ts +575 -0
  1458. package/src/tui/viewerinput.ts +173 -0
  1459. package/src/updatenotice.ts +178 -0
  1460. package/src/usage.ts +1154 -0
  1461. package/src/verdicts.ts +4707 -0
  1462. package/src/version.ts +80 -0
  1463. package/src/wakes.ts +476 -0
  1464. package/src/webhook.ts +951 -0
  1465. package/src/webhookheal.ts +276 -0
  1466. package/src/wire.ts +2182 -0
  1467. package/src/worker-session-host.ts +602 -0
  1468. package/src/workerbreaker.ts +156 -0
  1469. package/src/workercontrol.ts +104 -0
  1470. package/src/workerlease.ts +441 -0
  1471. package/src/workerliveness.ts +103 -0
  1472. package/src/workerlog.ts +79 -0
  1473. package/src/workerruntime.ts +30 -0
  1474. package/src/workersupervisor.ts +1538 -0
  1475. package/src/workerusage.ts +42 -0
  1476. package/src/workerworld.ts +433 -0
  1477. package/src/worktrees.ts +1138 -0
  1478. package/tests/fixtures/regression-cases.json +795 -0
  1479. package/tests/fixtures/seed-manifest.json +19 -0
@@ -0,0 +1,4707 @@
1
+ import {
2
+ enqueueRoundWork,
3
+ pendingRoundWork,
4
+ finishRoundWork,
5
+ type RoundWork,
6
+ } from "./round-work.js";
7
+ // Verdict watcher. The PR session writes
8
+ // verdict/pr-<n>-round-<k>.json at round end; the daemon owns everything that
9
+ // touches GitHub from there: labels off/on, the escalation review request
10
+ // (redundancy-skipped inside the adapter), the round record, the state
11
+ // transition.
12
+ //
13
+ // Two anomaly paths, both explicitly no-approve: a malformed/unparseable
14
+ // verdict file, and a round that blows its deadline. Both mark the round and
15
+ // write the companion inbox. There is no path here that touches an existing review:
16
+ // the invariant against retracting an approval is structural, not prompted
17
+ // (AGENTS.md invariant 2).
18
+ import {
19
+ botAccountLogin,
20
+ isPersonMode,
21
+ ownLogins,
22
+ } from "@quickchatai/pi-quickreview/src/identity.js";
23
+ import {
24
+ normalizePassPhase,
25
+ normalizeSweepClaim,
26
+ sweepSkippedOnApprove,
27
+ sweepStatusOf,
28
+ type SweepStatus,
29
+ } from "@quickchatai/pi-quickreview/src/sweepstatus.js";
30
+ import { pendingPosting } from "./posting-outbox.js";
31
+
32
+ export { sweepStatusOf, type SweepStatus };
33
+ import {
34
+ existsSync,
35
+ lstatSync,
36
+ mkdirSync,
37
+ readdirSync,
38
+ renameSync,
39
+ unlinkSync,
40
+ watch,
41
+ type FSWatcher,
42
+ } from "node:fs";
43
+ import { join } from "node:path";
44
+ import type { InboxWriter } from "./companioninbox.js";
45
+ import {
46
+ checkOutcome,
47
+ classifyCi,
48
+ latestRunPerName,
49
+ type CheckSummary,
50
+ } from "@quickchatai/pi-quickreview/src/cigate.js";
51
+ import {
52
+ deliverEscalations,
53
+ resumeEscalations,
54
+ type EscalationDeps,
55
+ evaluateEscalationRules,
56
+ normalizeSessionEscalations,
57
+ type EscalationFacts,
58
+ } from "./escalation.js";
59
+ import {
60
+ DEFAULT_MAX_PROMPT_REDELIVERIES,
61
+ DEFAULT_MAX_SELF_REQUEUES,
62
+ DEFAULT_POSTING_RECEIPT_GRACE_S,
63
+ DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN,
64
+ DEFAULT_ROUND_STALL_GRACE_MIN,
65
+ githubCloneDir,
66
+ sandboxRoot,
67
+ type Config,
68
+ } from "./config.js";
69
+ import {
70
+ fingerprintRoundIntegrity,
71
+ gitConfigChangeMessage,
72
+ INTEGRITY_BUDGET_MS,
73
+ roundIntegrityOpts,
74
+ integrityChanges,
75
+ policySurfaceChangeMessage,
76
+ splitIntegrityChanges,
77
+ type IntegrityFingerprint,
78
+ type RoundIntegrityOpts,
79
+ } from "./execboundary.js";
80
+ import { estimateUsd, type ModelPricing } from "./usage.js";
81
+ import { TranscriptIndex } from "./transcriptindex.js";
82
+ import { withinBudget } from "@quickchatai/pi-quickreview/src/budget.js";
83
+ import type { GateResult } from "@quickchatai/pi-quickreview/src/gate.js";
84
+ import { applyEvent, restoreStandingLifecycle } from "./statemachine.js";
85
+ import type { VerdictAnnouncer } from "./slack/announce.js";
86
+ import { isLoudCancelCause, type CancelCause, type RoundRow, type StateStore } from "./state.js";
87
+ import { enforceLedgerFreshness } from "./ledgercheck.js";
88
+ import {
89
+ findingsRecordPath,
90
+ readFindingsRecord,
91
+ } from "@quickchatai/pi-quickreview/src/findingsrecord.js";
92
+ import {
93
+ enforceFindingsRecord,
94
+ ingestRepairedFindingsRecords,
95
+ resumeFindingsRecordedHooks,
96
+ } from "./findingscheck.js";
97
+ import {
98
+ normalizeVerdict,
99
+ type RecordedAddablePass,
100
+ type RoundRecoveryDisposition,
101
+ type SessionHooks,
102
+ type MeritsVerdictFile,
103
+ type VerdictFile,
104
+ type VerdictPassEntry,
105
+ } from "@quickchatai/pi-quickreview/src/types.js";
106
+ import {
107
+ guardedNoopMatchesMark,
108
+ postingMarker,
109
+ readPostingReceipt,
110
+ receiptMatchesVerdict,
111
+ retrySoftenedApproval,
112
+ type GuardedNoopReceipt,
113
+ type PostingReceipt,
114
+ } from "./posting-receipt.js";
115
+ import {
116
+ snapshotMark,
117
+ standingReview,
118
+ standingVerdict,
119
+ type StandingDecidedVerdict,
120
+ } from "@quickchatai/pi-quickreview/src/standing-review.js";
121
+ import { flushHeldWakes, pauseDefersAllHeldWakes } from "./wakes.js";
122
+ import { prWorktreeRel } from "./worktrees.js";
123
+ import {
124
+ describeGap,
125
+ failedPasses,
126
+ parsePlan,
127
+ readRoundProgress,
128
+ requiredPassGap,
129
+ } from "./requiredpasses.js";
130
+ import {
131
+ readSettledJson,
132
+ resolvePartialWritePolicy,
133
+ type PartialWritePolicy,
134
+ } from "@quickchatai/pi-quickreview/src/settledread.js";
135
+
136
+ /** Self-requeue mode → the cancellation cause it records. Two modes mean the
137
+ * same thing to a human — the round stopped dead with no verdict, whether its
138
+ * session had died or was alive and wedged — and that is the one loud cause;
139
+ * the rest are the machinery working (a moved head, a delivery fault, a round
140
+ * redone because it executed no passes). */
141
+ type SelfRequeueMode =
142
+ | "dead"
143
+ | "alive_stuck"
144
+ | "needs_human_incomplete"
145
+ | "lost_prompt"
146
+ | "head_moved"
147
+ | "required_pass_missing"
148
+ | "infrastructure_active"
149
+ | "infrastructure_settled";
150
+
151
+ /**
152
+ * How long a round end waits for its transcripts. The reads are
153
+ * async and chunked, so the event loop is free throughout; this bound is for a
154
+ * read that never returns at all. Past it the usage is recorded unknown, the
155
+ * audit incomplete, and the round end goes on — a verdict is never held
156
+ * behind its own accounting.
157
+ */
158
+ export const USAGE_BUDGET_MS = 120_000;
159
+
160
+ /** How many unreadable paths the `round_usage_unknown` event names. */
161
+ const UNREADABLE_LISTED = 10;
162
+
163
+ /**
164
+ * How old an owed measurement's window may be and still be re-read after a
165
+ * restart. Past it the window is not read at all and the usage is recorded
166
+ * unknown: the pi-global archiver moves store
167
+ * files a day old, and a re-read that silently missed one would record a
168
+ * partial sum as the round's cost. A restart is minutes; this is generous.
169
+ */
170
+ export const OWED_MEASUREMENT_RESUME_MAX_MS = 12 * 3_600_000;
171
+
172
+ /**
173
+ * A staged verdict or recheck outcome (`verdict/<name>.tmp`)
174
+ * that was never committed is removed once it is this old. A session stages
175
+ * and commits within one turn of a round that lasts at most its deadline, so
176
+ * a day-old staging file belongs to a session that died between the two.
177
+ */
178
+ export const STAGED_FILE_MAX_AGE_MS = 24 * 3_600_000;
179
+
180
+ /** The staging names `scripts/commit-verdict.sh` publishes from: the two
181
+ * watched names (VERDICT_FILE_RE here, RECHECK_FILE_RE in recheck.ts) plus
182
+ * `.tmp`, and nothing else. */
183
+ const STAGED_FILE_RE = /^pr-(\d+)-(?:round-\d+|recheck-[0-9a-f]{7,40})\.json\.tmp$/i;
184
+
185
+ /**
186
+ * Where a round end's measurement runs.
187
+ * `inline`: awaited before the round end returns — the verdict sweep's
188
+ * paths, and the anomaly path, whose inbox item carries the violations
189
+ * the measurement finds. `background`: handed off once the
190
+ * round end's durable rows have landed — the round-stage supervisor's paths
191
+ * (its scan is serialized and health-leased, so one PR's accounting must not
192
+ * hold every other PR's stage checks) and the deadline tick's settlements.
193
+ * Either way the obligation is durable (`rounds.usage_owed_to`) from the
194
+ * moment the window is fixed, so a restart resumes it rather than losing it.
195
+ */
196
+ type MeasureMode = "inline" | "background";
197
+
198
+ /** A round end's measurement, owed from the moment its window was fixed. */
199
+ interface OwedMeasurement {
200
+ pr: number;
201
+ roundId: number;
202
+ roundNo: number;
203
+ startedAt: string;
204
+ measuredTo: string;
205
+ }
206
+
207
+ /** A receipt settlement's measurement mode: inline from the verdict sweep (a
208
+ * verdict file, or a malformed one), background from the deadline tick and the
209
+ * round-stage supervisor. */
210
+ function measureModeFor(via: ReceiptSettleVia): MeasureMode {
211
+ return via === "verdict" || via === "malformed_verdict" ? "inline" : "background";
212
+ }
213
+
214
+ /** A round-stage limit's answer for a posting failure the stage tried to
215
+ * open: a verdict that owns the round is the sweep's (retry the limit later),
216
+ * a round that ended is settled, a failure opened is loud. */
217
+ function stageFailure(outcome: "verdict" | "ended" | "failed"): RoundRecoveryDisposition {
218
+ return outcome === "verdict" ? "retryable" : outcome === "ended" ? "settled" : "failed_loud";
219
+ }
220
+
221
+ /** A self-requeue's measurement mode: the two that come from a verdict file
222
+ * are the sweep's; the rest come from the deadline tick or the supervisor. */
223
+ function measureModeForRequeue(mode: SelfRequeueMode): MeasureMode {
224
+ return mode === "head_moved" ||
225
+ mode === "needs_human_incomplete" ||
226
+ mode === "required_pass_missing"
227
+ ? "inline"
228
+ : "background";
229
+ }
230
+
231
+ const SELF_REQUEUE_CAUSE: Record<SelfRequeueMode, CancelCause> = {
232
+ dead: "deadline_overrun",
233
+ alive_stuck: "deadline_overrun",
234
+ needs_human_incomplete: "incomplete_round",
235
+ lost_prompt: "lost_prompt",
236
+ head_moved: "head_moved",
237
+ required_pass_missing: "incomplete_round",
238
+ infrastructure_active: "infrastructure_failure",
239
+ infrastructure_settled: "infrastructure_failure",
240
+ };
241
+
242
+ export interface VerdictAdapter {
243
+ addLabel(n: number, label: string): Promise<GateResult>;
244
+ removeLabel(n: number, label: string): Promise<GateResult>;
245
+ /** PR metadata — the escalation feature's label facts. Optional
246
+ * in the same shape as everything else here: an adapter that cannot answer
247
+ * simply means label-keyed escalation rules do not fire. */
248
+ getPr?(n: number): Promise<{ labels: string[] }>;
249
+ /** Changed files — the escalation feature's path facts. Same discipline. */
250
+ listPrFiles?(n: number): Promise<Array<{ filename: string }>>;
251
+ /** The two gated escalation writes. Both optional: a daemon whose
252
+ * adapter has neither delivers no escalations at all, which is exactly what
253
+ * the default (feature off) already does. */
254
+ createReview?(
255
+ n: number,
256
+ payload: { commit_id: string; event: "COMMENT"; body: string },
257
+ options?: { purpose?: "escalation" | "migration_precheck" },
258
+ ): Promise<GateResult>;
259
+ requestEscalationReview?(n: number, logins: string[]): Promise<GateResult>;
260
+ /** Checks at a head (read-only, cigate's classifier input). Optional: with
261
+ * it, a session-route withheld approval records WHICH CI state caused the
262
+ * withhold; without it the mark stores null. */
263
+ listChecks?(ref: string): Promise<CheckSummary[]>;
264
+ /** Read-only reconciliation for an ambiguous post-success/local-crash
265
+ * window. Exact marker + bot identity + head are required. */
266
+ listReviewsFull?(pr: number): Promise<
267
+ Array<{
268
+ id: number;
269
+ user: string;
270
+ user_type?: string;
271
+ state: string;
272
+ body: string;
273
+ commit_id: string;
274
+ submitted_at: string | null;
275
+ }>
276
+ >;
277
+ }
278
+
279
+ export interface VerdictDeps {
280
+ cfg: Config;
281
+ store: StateStore;
282
+ adapter: VerdictAdapter;
283
+ hooks: SessionHooks;
284
+ /** Writes one companion-inbox item — the self-requeue's recovery notice.
285
+ * Absent (tests that do not watch it) ⇒ no notice. */
286
+ writeInbox?: InboxWriter;
287
+ /** Directory the sessions write into (sandbox `verdict/`). */
288
+ verdictDir: string;
289
+ /** Settle time between requesting the round-end compaction and the
290
+ * ledger check, so ledger-pin's mirror has landed first. (The inject bridge
291
+ * holds every wake until the compaction ends.) 0 ⇒ inline (tests).
292
+ * Default 90s. */
293
+ ledgerCheckDelayMs?: number;
294
+ /** Slack announcer. Optional: absent ⇒ no Slack. Never allowed to throw in
295
+ * here. */
296
+ announce?: VerdictAnnouncer;
297
+ /** Sandbox root — anchors the default pi-session dir. */
298
+ root?: string;
299
+ /** Atomic shell posting receipts. Defaults to state/posting-receipts. */
300
+ postingReceiptDir?: string;
301
+ postingReceiptGraceS?: number;
302
+ /** Usage-measurement seams; every field defaults from cfg/root. */
303
+ usage?: {
304
+ /** pi session dir for a PR's sticky session (sessions.ts sessionDir()). */
305
+ sessionDirFor?: (pr: number) => string;
306
+ /** Per-model pricing; defaults to config `[models.pricing]`. */
307
+ pricing?: Record<string, ModelPricing>;
308
+ /** The transcript reader. Default: one per watcher, so its
309
+ * per-file offsets and parsed lines live as long as the daemon. */
310
+ transcripts?: TranscriptIndex;
311
+ /** How long a round end waits for its transcripts before it records the
312
+ * usage as unknown and moves on. Default USAGE_BUDGET_MS. */
313
+ budgetMs?: number;
314
+ };
315
+ /** The round-end integrity re-hash. Default
316
+ * `fingerprintRoundIntegrity` over the real disk — the same call dispatch's
317
+ * baseline makes. Injected by a test that needs a read that never returns. */
318
+ integrityFingerprint?: (opts: RoundIntegrityOpts) => Promise<IntegrityFingerprint>;
319
+ /** How long the re-hash may take before the round is left unchecked.
320
+ * Default INTEGRITY_BUDGET_MS. */
321
+ integrityBudgetMs?: number;
322
+ /** Where a PR's mirrored ledger snapshot lives. Defaults to
323
+ * `<root>/state/ledgers/pr-<n>.md` — sessions.ts ledgerPath's shape. */
324
+ ledgerPathFor?: (pr: number) => string;
325
+ /**
326
+ * Where the session writes its per-round reasoning record —
327
+ * daemon.ts passes `<root>/state/findings`.
328
+ *
329
+ * Opt-in rather than defaulted off `root`, in the same shape as every other
330
+ * additive round-end check here: absent ⇒ the record is neither parsed nor
331
+ * enforced (a stub-wired daemon and tests that do not need it). Production
332
+ * has one assembly site (daemon.ts) and it passes the dir.
333
+ */
334
+ findingsDir?: string;
335
+ /** Told when a round's findings record lands (the lesson shadow ledger's
336
+ * matcher). Absent ⇒ nothing is told. A returned promise is awaited, and a
337
+ * throw or rejection keeps the obligation for a retry. */
338
+ onFindingsRecorded?: (target: { pr: number; roundNo: number; roundId: number | null }) => unknown;
339
+ now?: () => Date;
340
+ log?: (msg: string, extra?: unknown) => void;
341
+ /** Partial-write tolerance (`packages/pi-plugin/src/settledread.ts`): how long an
342
+ * unparseable verdict file is treated as still being written before it is
343
+ * called malformed. Defaults to DEFAULT_PARTIAL_WRITE_POLICY; a test that
344
+ * wants one read to decide passes `{ windowMs: 0 }`. */
345
+ partialWrite?: Partial<PartialWritePolicy>;
346
+ }
347
+
348
+ /** The round-stage limits the supervisor may report, named once. */
349
+ export type InfrastructureStage =
350
+ | "command_start"
351
+ | "first_pass_start"
352
+ | "passes_incomplete"
353
+ | "parent_finalize"
354
+ | "settled_without_verdict"
355
+ | "running_hard_bound";
356
+
357
+ /** Every path that may close a round from its posting receipt rather than
358
+ * from a verdict file (a guard-2 no-op, or a review already posted). */
359
+ type ReceiptSettleVia = "verdict" | "deadline" | "malformed_verdict" | InfrastructureStage;
360
+
361
+ /** The merits decision a posting receipt proves. */
362
+ export interface ReceiptDecision {
363
+ verdict: "APPROVE" | "REQUEST_CHANGES";
364
+ /** The session asked for a COMMENT: the round templates' withheld-approval
365
+ * shape (red or pending CI, no blockers). */
366
+ approval_withheld?: true;
367
+ }
368
+
369
+ /**
370
+ * The merits decision a confirmed posting receipt proves, or null
371
+ * when it proves only that something was posted.
372
+ *
373
+ * The receipt records what the session asked the posting script for
374
+ * (`intended_event`) beside what the guards let through (`actual_event`), and
375
+ * the scripts' shapes are fixed: `approve-pr.sh` only ever records
376
+ * APPROVE→APPROVE, and `post-review.sh` takes REQUEST_CHANGES or COMMENT, with
377
+ * guard 1 softening REQUEST_CHANGES to COMMENT when a non-author human has
378
+ * already approved. So:
379
+ *
380
+ * - REQUEST_CHANGES asked for is a REQUEST_CHANGES decision whatever was
381
+ * posted — exactly what `receiptMatchesVerdict` accepts from a verdict file;
382
+ * - APPROVE posted is an APPROVE;
383
+ * - a COMMENT the session asked for is, by the round templates' own contract,
384
+ * the withheld-approval shape and nothing else: "red CI and no blockers ⇒
385
+ * post the full review as a COMMENT … write `"verdict": "APPROVE"` with
386
+ * `"approval_withheld": true`" (step 5's CI gate). Mechanical
387
+ * comments (the migration pre-check, an escalation) go through the daemon's
388
+ * gate and never write a session receipt, and a softened REQUEST_CHANGES
389
+ * says so in `intended_event`. Settling it as APPROVE + withheld records
390
+ * the withheld-approval mark, so CI turning green re-queues the PR and the
391
+ * SESSION decides again (cigate.ts) — the reading can never approve
392
+ * anything by itself.
393
+ *
394
+ * Not decided — null — is everything else: a receipt the marker reconcile
395
+ * synthesised from GitHub (`reconciled`: its `intended_event` is only the
396
+ * posted event, so a COMMENT there may be a softened REQUEST_CHANGES), and any
397
+ * pairing no posting script writes. Those keep the loud paths.
398
+ *
399
+ * This is the receipt's reading. The reviewed-head mark's `posted_event`
400
+ * COMMENT carries no `intended_event` and stays never-guessed
401
+ * (standing-review.ts rule 4).
402
+ */
403
+ export function decidedVerdictFromReceipt(receipt: PostingReceipt): ReceiptDecision | null {
404
+ // The daemon's deferred-posting retry delivered the session's
405
+ // APPROVE and guard 4 softened it on the gate — the withheld-approval shape.
406
+ if (retrySoftenedApproval(receipt)) return { verdict: "APPROVE", approval_withheld: true };
407
+ if (
408
+ receipt.intended_event === "REQUEST_CHANGES" &&
409
+ (receipt.actual_event === "REQUEST_CHANGES" || receipt.actual_event === "COMMENT")
410
+ )
411
+ return { verdict: "REQUEST_CHANGES" };
412
+ if (receipt.intended_event === "APPROVE" && receipt.actual_event === "APPROVE")
413
+ return { verdict: "APPROVE" };
414
+ // Person mode posts every review as a COMMENT: the approval the session
415
+ // asked for is the decision, and it was not withheld.
416
+ if (
417
+ receipt.person_mode === true &&
418
+ receipt.intended_event === "APPROVE" &&
419
+ receipt.actual_event === "COMMENT" &&
420
+ receipt.reconciled !== true
421
+ )
422
+ return { verdict: "APPROVE" };
423
+ if (
424
+ receipt.intended_event === "COMMENT" &&
425
+ receipt.actual_event === "COMMENT" &&
426
+ receipt.reconciled !== true
427
+ )
428
+ return { verdict: "APPROVE", approval_withheld: true };
429
+ return null;
430
+ }
431
+
432
+ /** The planned lens set the round-end findings check stamps against:
433
+ * the round's plan, plus the sweep's lenses when the sweep RAN —
434
+ * their findings are planned work, never `unplanned`. */
435
+ export function tailPlannedLenses(
436
+ round: Pick<RoundRow, "lenses" | "sweep_plan">,
437
+ sweepStatus: SweepStatus | null,
438
+ ): string {
439
+ const base = round.lenses ?? "";
440
+ if (sweepStatus !== "ran" || !round.sweep_plan) return base;
441
+ try {
442
+ const plan = JSON.parse(round.sweep_plan) as unknown;
443
+ if (!Array.isArray(plan)) return base;
444
+ const lenses = new Set(base.split(",").filter(Boolean));
445
+ for (const row of plan) {
446
+ const lens = (row as { lens?: unknown } | null)?.lens;
447
+ if (typeof lens === "string" && lens) lenses.add(lens);
448
+ }
449
+ return [...lenses].join(",");
450
+ } catch {
451
+ return base;
452
+ }
453
+ }
454
+
455
+ /** One stage's executed passes held against its plan — the round's main
456
+ * plan, or a follow-up round's pre-approval sweep. Pure. */
457
+ interface PlanComparison {
458
+ planned: string[];
459
+ missing: string[];
460
+ unknownAdditions: string[];
461
+ missingReason: string[];
462
+ wrongTier: string[];
463
+ allowedAdditions: Array<{ name: string; tier: string | null; reason: string }>;
464
+ }
465
+
466
+ type PlanRow = { lens: string; tier: string; raisable_to?: string[] };
467
+
468
+ function allowsTier(policy: PlanRow, tier: string | null | undefined): boolean {
469
+ return (
470
+ !!tier &&
471
+ [policy.tier, ...(policy.raisable_to ?? []).map((agent) => agent.split("--").at(-1)!)].includes(
472
+ tier,
473
+ )
474
+ );
475
+ }
476
+
477
+ /** Match executions to distinct rows before judging any excess retries. */
478
+ function plannedTierMismatches(passes: VerdictPassEntry[], rows: PlanRow[]): string[] {
479
+ const byLens = new Map<string, PlanRow[]>();
480
+ for (const row of rows) byLens.set(row.lens, [...(byLens.get(row.lens) ?? []), row]);
481
+ const wrong: string[] = [];
482
+ for (const [lens, policies] of byLens) {
483
+ const runs = passes.filter((pass) => pass.name === lens);
484
+ const assigned = new Map<number, number>();
485
+ const place = (run: number, seen: Set<number>): boolean => {
486
+ for (let row = 0; row < policies.length; row++) {
487
+ if (seen.has(row) || !allowsTier(policies[row]!, runs[run]!.tier)) continue;
488
+ seen.add(row);
489
+ const previous = assigned.get(row);
490
+ if (previous === undefined || place(previous, seen)) {
491
+ assigned.set(row, run);
492
+ return true;
493
+ }
494
+ }
495
+ return false;
496
+ };
497
+ const excess = runs.filter((_pass, run) => !place(run, new Set()));
498
+ for (const pass of excess)
499
+ if (
500
+ assigned.size < policies.length ||
501
+ !policies.some((policy) => allowsTier(policy, pass.tier))
502
+ )
503
+ wrong.push(lens);
504
+ }
505
+ return wrong;
506
+ }
507
+
508
+ function comparePlan(
509
+ passes: VerdictPassEntry[],
510
+ plan: PlanRow[] | null,
511
+ addable: RecordedAddablePass[] | null,
512
+ legacyPlanned: string[],
513
+ checkMissing: boolean,
514
+ ): PlanComparison {
515
+ const executed = passes.map((p) => p.name);
516
+ const out: PlanComparison = {
517
+ planned: legacyPlanned,
518
+ missing: [],
519
+ unknownAdditions: [],
520
+ missingReason: [],
521
+ wrongTier: [],
522
+ allowedAdditions: [],
523
+ };
524
+ if (plan && addable) {
525
+ out.planned = plan.map((p) => p.lens);
526
+ const byAddable = new Map<string, RecordedAddablePass[]>();
527
+ for (const policy of addable)
528
+ byAddable.set(policy.lens, [...(byAddable.get(policy.lens) ?? []), policy]);
529
+ out.wrongTier = plannedTierMismatches(passes, plan);
530
+ const plannedNames = new Set(plan.map((p) => p.lens));
531
+ for (const pass of passes) {
532
+ if (plannedNames.has(pass.name)) continue;
533
+ const policies = byAddable.get(pass.name);
534
+ if (!policies) {
535
+ out.unknownAdditions.push(pass.name);
536
+ continue;
537
+ }
538
+ const optional = policies.filter((policy) => policy.optional);
539
+ if (optional.length > 0) {
540
+ if (optional.some((policy) => allowsTier(policy, pass.tier)))
541
+ out.allowedAdditions.push({
542
+ name: pass.name,
543
+ tier: pass.tier!,
544
+ reason: pass.reason ?? "optional row",
545
+ });
546
+ else out.wrongTier.push(pass.name);
547
+ continue;
548
+ }
549
+ const policy = policies[0]!;
550
+ if (!pass.tier || pass.tier !== policy.tier) out.wrongTier.push(pass.name);
551
+ if (!pass.reason) out.missingReason.push(pass.name);
552
+ if (pass.tier === policy.tier && pass.reason) {
553
+ out.allowedAdditions.push({ name: pass.name, tier: pass.tier, reason: pass.reason });
554
+ }
555
+ }
556
+ } else {
557
+ // Rows with no structured plan (in flight across a migration) compare
558
+ // by lens name only.
559
+ out.unknownAdditions = [...new Set(executed.filter((lens) => !out.planned.includes(lens)))];
560
+ }
561
+ if (checkMissing) {
562
+ const remaining = new Map<string, number>();
563
+ for (const lens of executed) remaining.set(lens, (remaining.get(lens) ?? 0) + 1);
564
+ out.missing = out.planned.filter((lens) => {
565
+ const count = remaining.get(lens) ?? 0;
566
+ if (count === 0) return true;
567
+ remaining.set(lens, count - 1);
568
+ return false;
569
+ });
570
+ }
571
+ return out;
572
+ }
573
+
574
+ export const VERDICT_FILE_RE = /^pr-(\d+)-round-(\d+)\.json$/;
575
+
576
+ /** A round's verdict file name — the one spelling VERDICT_FILE_RE reads,
577
+ * the watcher's slot key and every presence check use. */
578
+ export function verdictFileName(pr: number, roundNo: number): string {
579
+ return `pr-${pr}-round-${roundNo}.json`;
580
+ }
581
+
582
+ export interface ParsedVerdict {
583
+ ok: boolean;
584
+ verdict?: VerdictFile;
585
+ error?: string;
586
+ /** Why the `passes` telemetry ended up unknown/reduced — a note for the
587
+ * log, never a rejection reason. Absent when passes were clean. */
588
+ passes_note?: string;
589
+ }
590
+
591
+ /**
592
+ * Tolerant normalization of the executed-pass telemetry. LLM-written
593
+ * files: digit-string counts coerce, `lens` is accepted for `name`,
594
+ * `candidates`/`candidate_findings` for `findings`. Anything unusable —
595
+ * a non-array, an entry with no name — degrades to unknown (undefined) or is
596
+ * dropped with a note; nothing here can fail the verdict.
597
+ */
598
+ export function normalizePasses(raw: unknown): { passes?: VerdictPassEntry[]; note?: string } {
599
+ if (raw === undefined || raw === null) return { note: "absent" };
600
+ if (!Array.isArray(raw)) return { note: `not an array (${typeof raw})` };
601
+ const count = (val: unknown): number | null => {
602
+ if (typeof val === "number" && Number.isFinite(val)) return Math.trunc(val);
603
+ if (typeof val === "string" && /^\d+$/.test(val)) return Number(val);
604
+ return null;
605
+ };
606
+ const passes: VerdictPassEntry[] = [];
607
+ let dropped = 0;
608
+ let badPhase = 0;
609
+ for (const entry of raw) {
610
+ if (typeof entry !== "object" || entry === null) {
611
+ dropped++;
612
+ continue;
613
+ }
614
+ const e = entry as Record<string, unknown>;
615
+ const name =
616
+ typeof e.name === "string" && e.name.trim()
617
+ ? e.name.trim()
618
+ : typeof e.lens === "string" && e.lens.trim()
619
+ ? e.lens.trim()
620
+ : null;
621
+ if (!name) {
622
+ dropped++;
623
+ continue;
624
+ }
625
+ passes.push({
626
+ name,
627
+ tier: typeof e.tier === "string" && e.tier.trim() ? e.tier.trim() : null,
628
+ ...(typeof e.reason === "string" && e.reason.trim() ? { reason: e.reason.trim() } : {}),
629
+ findings: count(e.findings) ?? count(e.candidates) ?? count(e.candidate_findings),
630
+ survived: count(e.survived),
631
+ killed: count(e.killed),
632
+ downgraded: count(e.downgraded),
633
+ merged: count(e.merged),
634
+ folded: count(e.folded),
635
+ });
636
+ // Which stage of a follow-up round launched the pass. Additive
637
+ // and tolerant — absent stays absent (a delta-stage pass, or a round that
638
+ // is not a follow-up), an unusable spelling is dropped with a note; the
639
+ // entry itself always survives.
640
+ if (e.phase !== undefined && e.phase !== null) {
641
+ const phase = normalizePassPhase(e.phase);
642
+ if (phase) passes[passes.length - 1]!.phase = phase;
643
+ else badPhase++;
644
+ }
645
+ }
646
+ const notes = [
647
+ dropped > 0 ? `${dropped} malformed entr${dropped === 1 ? "y" : "ies"} dropped` : "",
648
+ badPhase > 0 ? `${badPhase} unusable phase${badPhase === 1 ? "" : "s"} ignored` : "",
649
+ ].filter(Boolean);
650
+ return { passes, ...(notes.length > 0 ? { note: notes.join("; ") } : {}) };
651
+ }
652
+
653
+ /** The verdict's `extent`: what the session did with the follow-up
654
+ * plan's optional rows — `ran` | `partial` | `skipped`, case forgiven;
655
+ * anything else is unusable (null). */
656
+ export function normalizeExtentClaim(raw: unknown): "ran" | "partial" | "skipped" | null {
657
+ if (typeof raw !== "string") return null;
658
+ const word = raw.trim().toLowerCase();
659
+ return word === "ran" || word === "partial" || word === "skipped" ? word : null;
660
+ }
661
+
662
+ /** Shape validation for VerdictFile. Anything off → the anomaly path. */
663
+ export function validateVerdict(raw: unknown): ParsedVerdict {
664
+ if (typeof raw !== "object" || raw === null) return { ok: false, error: "not an object" };
665
+ const v = raw as Record<string, unknown>;
666
+ const bad = (m: string): ParsedVerdict => ({ ok: false, error: m });
667
+ // LLM-written files: coerce unambiguous digit-strings ("5263" → 5263) before
668
+ // shape-checking.
669
+ for (const k of ["pr", "round_no"] as const) {
670
+ const val = v[k];
671
+ if (typeof val === "string" && /^\d+$/.test(val)) v[k] = Number(val);
672
+ }
673
+ if (typeof v.pr !== "number" || !Number.isInteger(v.pr)) return bad("pr must be an integer");
674
+ if (typeof v.round_no !== "number" || !Number.isInteger(v.round_no))
675
+ return bad("round_no must be an integer");
676
+ // Guard-5 landing: `outcome: "head_moved"` (or, belt-and-braces, verdict
677
+ // spelled HEAD_MOVED) is a verdict-less round
678
+ // ending — the PR advanced mid-round and the guard refused the stale post.
679
+ // Only pr/round_no/head_sha/finished_at are required; the daemon cancels
680
+ // the round and self-requeues instead of applying a verdict.
681
+ const outcome = typeof v.outcome === "string" ? v.outcome.trim().toLowerCase() : "";
682
+ const verdictSpelled = typeof v.verdict === "string" ? v.verdict.trim().toUpperCase() : "";
683
+ // The envelope every outcome carries: the head the round reviewed and when
684
+ // it finished. spend_usd is a legacy self-reported field — accepted in any
685
+ // shape and IGNORED (models copy the template's example figure; real usage
686
+ // is measured from the session JSONLs) — deleted so no consumer can store
687
+ // it.
688
+ const badEnvelope = (): ParsedVerdict | null => {
689
+ if (typeof v.head_sha !== "string" || v.head_sha.length === 0)
690
+ return bad("head_sha must be a non-empty string");
691
+ if (typeof v.finished_at !== "string" || v.finished_at.length === 0)
692
+ return bad("finished_at must be a non-empty string");
693
+ delete v.spend_usd;
694
+ return null;
695
+ };
696
+ if (outcome === "head_moved" || verdictSpelled === "HEAD_MOVED") {
697
+ v.outcome = "head_moved";
698
+ delete v.verdict;
699
+ return badEnvelope() ?? { ok: true, verdict: raw as unknown as VerdictFile };
700
+ }
701
+ if (outcome) return bad(`bad outcome: ${String(v.outcome)}`);
702
+ // normalizeVerdict also folds the retired "ESCALATE" spelling into
703
+ // NEEDS_HUMAN — the frozen conversation-system.md seed still emits it.
704
+ const verdict = normalizeVerdict(v.verdict);
705
+ if (verdict === null) return bad(`bad verdict: ${String(v.verdict)}`);
706
+ v.verdict = verdict;
707
+ const envelope = badEnvelope();
708
+ if (envelope) return envelope;
709
+ if (v.ai_approved !== undefined && typeof v.ai_approved !== "boolean")
710
+ return bad("ai_approved must be a boolean");
711
+ // LLM-written booleans arrive as strings often enough to coerce the
712
+ // two unambiguous spellings rather than fail the whole file over them.
713
+ if (v.approval_withheld === "true") v.approval_withheld = true;
714
+ if (v.approval_withheld === "false") v.approval_withheld = false;
715
+ if (v.approval_withheld !== undefined && typeof v.approval_withheld !== "boolean")
716
+ return bad("approval_withheld must be a boolean");
717
+ if (v.ledger_snapshot !== undefined && typeof v.ledger_snapshot !== "string")
718
+ return bad("ledger_snapshot must be a string");
719
+ if (v.summary !== undefined && typeof v.summary !== "string")
720
+ return bad("summary must be a string");
721
+ if (typeof v.summary === "string") v.summary = v.summary.trim().slice(0, 800);
722
+ // Executed-pass telemetry: tolerant by design — a session may omit it and
723
+ // malformed shapes degrade to unknown, never to a rejected verdict.
724
+ const norm = normalizePasses(v.passes);
725
+ if (norm.passes) v.passes = norm.passes;
726
+ else delete v.passes;
727
+ // Session-judgement escalations: the same tolerant discipline, for
728
+ // the same reason and one stronger one — escalation is notification beside a
729
+ // verdict, so a malformed `escalations` array must never be able to reject
730
+ // the verdict it rides on. Unreadable ⇒ dropped with a note.
731
+ const esc = normalizeSessionEscalations(v.escalations);
732
+ if (esc.entries.length > 0) v.escalations = esc.entries;
733
+ else delete v.escalations;
734
+ // A follow-up round's pre-approval sweep claim — the same tolerant
735
+ // discipline: an unusable claim is dropped with a note (the daemon then
736
+ // records the sweep unknown), never a rejected verdict.
737
+ let sweepNote: string | undefined;
738
+ if (v.sweep !== undefined) {
739
+ const claim = normalizeSweepClaim(v.sweep);
740
+ if (claim) v.sweep = claim;
741
+ else {
742
+ sweepNote = `sweep claim unusable (${JSON.stringify(v.sweep).slice(0, 40)}) ignored`;
743
+ delete v.sweep;
744
+ }
745
+ }
746
+ // The optional rows' extent — the same tolerant discipline.
747
+ let extentNote: string | undefined;
748
+ if (v.extent !== undefined) {
749
+ const extent = normalizeExtentClaim(v.extent);
750
+ if (extent) v.extent = extent;
751
+ else {
752
+ extentNote = `extent claim unusable (${JSON.stringify(v.extent).slice(0, 40)}) ignored`;
753
+ delete v.extent;
754
+ }
755
+ }
756
+ for (const k of ["sweep_reason", "extent_reason"] as const)
757
+ if (v[k] !== undefined && typeof v[k] !== "string") delete v[k];
758
+ const notes = [norm.note, esc.note, sweepNote, extentNote].filter(Boolean).join("; ");
759
+ return {
760
+ ok: true,
761
+ verdict: raw as unknown as VerdictFile,
762
+ ...(notes ? { passes_note: notes } : {}),
763
+ };
764
+ }
765
+
766
+ export class VerdictWatcher {
767
+ private watcher: FSWatcher | null = null;
768
+ private readonly inFlight = new Set<string>();
769
+ private readonly processedDir: string;
770
+ /** Exec-policy violations found while measuring usage, kept only so an
771
+ * inbox item that happens anyway can carry a line about them —
772
+ * violations alone never create inject spam. */
773
+ private readonly execViolations = new Map<number, string[]>();
774
+ /** Waits logged once per round per process, not once per supervisor scan
775
+ * or deadline tick: the posted-tail wait at a stage limit (`stage:<id>`)
776
+ * and at the deadline (`deadline:<id>`), and an ambiguous POST a stage limit
777
+ * is waiting out (`ambiguous:<id>`). */
778
+ private readonly loggedOnce = new Set<string>();
779
+ /** Rounds whose owed measurement this process is running or about to run —
780
+ * the boot resume never measures one of them a second time. */
781
+ private readonly measuring = new Set<number>();
782
+ private readonly working = new Set<string>();
783
+ /** Background round work, tracked until it ends. */
784
+ private readonly background = new Set<Promise<void>>();
785
+ /** The once-per-watcher resume of measurements an earlier process owed. */
786
+ private owedResumed = false;
787
+ /** `checkDeadlines` never overlaps itself. */
788
+ private deadlinesInFlight = false;
789
+ /** Round transcripts, read off the event loop and kept by offset. */
790
+ private readonly transcripts: TranscriptIndex;
791
+ private stopped = false;
792
+ private escalationRecoveryInFlight = false;
793
+
794
+ constructor(private readonly deps: VerdictDeps) {
795
+ this.processedDir = join(deps.verdictDir, "processed");
796
+ this.transcripts = deps.usage?.transcripts ?? new TranscriptIndex();
797
+ }
798
+
799
+ private now(): Date {
800
+ return (this.deps.now ?? (() => new Date()))();
801
+ }
802
+
803
+ private log(msg: string, extra?: unknown): void {
804
+ (this.deps.log ?? (() => {}))(msg, extra);
805
+ }
806
+
807
+ /** Startup sweep + fs.watch. fs.watch is the fast path only; the sweep (also
808
+ * run by the daemon's periodic tick) is the reliable one. */
809
+ start(): void {
810
+ this.stopped = false;
811
+ mkdirSync(this.deps.verdictDir, { recursive: true });
812
+ void this.sweep();
813
+ this.watcher = watch(this.deps.verdictDir, (_event, filename) => {
814
+ if (!filename) return;
815
+ const name = String(filename);
816
+ if (!VERDICT_FILE_RE.test(name)) return;
817
+ void this.handleFile(name).catch((err) => this.log(`verdicts: ${name} failed`, err));
818
+ });
819
+ }
820
+
821
+ stop(): void {
822
+ this.stopped = true;
823
+ this.watcher?.close();
824
+ this.watcher = null;
825
+ }
826
+
827
+ /** Resolves once this watcher's background work ends. The daemon does not
828
+ * wait here during shutdown: durable obligations resume at the next start.
829
+ * Tests use it before observing background round results. */
830
+ async idle(): Promise<void> {
831
+ while (this.background.size > 0) await Promise.allSettled([...this.background]);
832
+ }
833
+
834
+ /** Process every verdict file sitting in the directory.
835
+ *
836
+ * One PR's files in name order; different PRs side by side. A file still
837
+ * being written is waited out for seconds, so a sweep that walked every file
838
+ * in one line would hold every other PR's verdict behind it — the fs.watch
839
+ * path handles PRs concurrently too. */
840
+ async sweep(): Promise<string[]> {
841
+ this.resumeRoundWork();
842
+ this.detach(
843
+ () =>
844
+ resumeFindingsRecordedHooks({
845
+ store: this.deps.store,
846
+ ...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
847
+ log: this.log.bind(this),
848
+ }),
849
+ "recorded findings hooks",
850
+ );
851
+ this.resumeEscalationDeliveries();
852
+ if (!existsSync(this.deps.verdictDir)) return [];
853
+ const byPr = new Map<string, string[]>();
854
+ const listed = readdirSync(this.deps.verdictDir).sort();
855
+ for (const name of listed) {
856
+ const match = VERDICT_FILE_RE.exec(name);
857
+ if (!match) continue;
858
+ byPr.set(match[1]!, [...(byPr.get(match[1]!) ?? []), name]);
859
+ }
860
+ this.pruneStaleStaging(listed);
861
+ const done: string[] = [];
862
+ await Promise.all(
863
+ [...byPr.values()].map(async (names) => {
864
+ for (const name of names) {
865
+ try {
866
+ if (await this.handleFile(name)) done.push(name);
867
+ } catch (err) {
868
+ this.log(`verdicts: ${name} failed`, err);
869
+ }
870
+ }
871
+ }),
872
+ );
873
+ try {
874
+ await this.resumeInterruptedSettlements();
875
+ } catch (err) {
876
+ this.log("verdicts: resuming interrupted receipt settlements failed", err);
877
+ }
878
+ try {
879
+ this.resumeOwedMeasurements();
880
+ } catch (err) {
881
+ this.log("verdicts: resuming owed round measurements failed", err);
882
+ }
883
+ // A findings record the round tail nudged for lands after that tail ran.
884
+ if (this.deps.findingsDir) {
885
+ try {
886
+ ingestRepairedFindingsRecords(
887
+ {
888
+ store: this.deps.store,
889
+ findingsDir: this.deps.findingsDir,
890
+ log: this.log.bind(this),
891
+ ...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
892
+ },
893
+ this.now(),
894
+ );
895
+ } catch (err) {
896
+ this.log("verdicts: storing repaired findings records failed", err);
897
+ }
898
+ }
899
+ return done.sort();
900
+ }
901
+
902
+ /**
903
+ * Remove staging files (`verdict/<name>.tmp`) that were never committed.
904
+ * Nothing reads them — the watchers' names end
905
+ * in `.json` — so one left by a session that died between its write and
906
+ * `scripts/commit-verdict.sh` would sit in verdict/ forever. Only the two
907
+ * staging shapes, only once a day old, and never a directory.
908
+ */
909
+ private pruneStaleStaging(names: readonly string[]): void {
910
+ const cutoff = this.now().getTime() - STAGED_FILE_MAX_AGE_MS;
911
+ for (const name of names) {
912
+ const match = STAGED_FILE_RE.exec(name);
913
+ if (!match) continue;
914
+ const path = join(this.deps.verdictDir, name);
915
+ try {
916
+ const st = lstatSync(path);
917
+ if (st.isDirectory() || st.mtimeMs > cutoff) continue;
918
+ unlinkSync(path);
919
+ this.deps.store.logEvent("verdict_staging_pruned", Number(match[1]), {
920
+ file: name,
921
+ mtime: new Date(st.mtimeMs).toISOString(),
922
+ });
923
+ this.log(`verdicts: removed ${name}, staged and never committed`);
924
+ } catch {
925
+ // Gone already, or unreadable: the next sweep looks again.
926
+ }
927
+ }
928
+ }
929
+
930
+ /** The round `roundNo` names, whatever its status — never another round:
931
+ * a lookup that names a round this PR does not have answers undefined. With
932
+ * no `roundNo`, the PR's newest running round. */
933
+ private runningRound(pr: number, roundNo?: number): RoundRow | undefined {
934
+ if (roundNo !== undefined) {
935
+ return this.deps.store.db
936
+ .prepare("SELECT * FROM rounds WHERE pr = ? AND round_no = ? ORDER BY id DESC LIMIT 1")
937
+ .get(pr, roundNo) as RoundRow | undefined;
938
+ }
939
+ return this.deps.store.db
940
+ .prepare("SELECT * FROM rounds WHERE pr = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
941
+ .get(pr) as RoundRow | undefined;
942
+ }
943
+
944
+ /** A file reaching a cancelled round is not applied over the cancel:
945
+ * recorded, archived by the caller, nothing else touched. */
946
+ private ignoreAfterCancel(
947
+ round: RoundRow,
948
+ name: string,
949
+ detail: {
950
+ malformed?: string;
951
+ reason?: string;
952
+ head_sha?: string;
953
+ outcome?: string | null;
954
+ verdict?: string | null;
955
+ } = {},
956
+ ): void {
957
+ this.deps.store.logEvent("verdict_after_cancel_ignored", round.pr, {
958
+ round_id: round.id,
959
+ round_no: round.round_no,
960
+ cancel_cause: round.cancel_cause,
961
+ file: name,
962
+ ...detail,
963
+ });
964
+ this.log(
965
+ `verdicts: #${round.pr} round ${round.round_no} was already ${round.status}${round.cancel_cause ? ` (${round.cancel_cause})` : ""} — ${name} is not applied over it`,
966
+ );
967
+ }
968
+
969
+ /**
970
+ * The orphan sweep cancels a round because its session did not survive a
971
+ * restart — not because anything decided against the round. A verdict file
972
+ * the session committed before the restart, which the boot sweep had not
973
+ * settled yet (still waiting on its posting receipt), is that round's
974
+ * decision and still applies — the posted review is settled, never re-run —
975
+ * as long as no later round has started on the PR.
976
+ */
977
+ private orphanVerdictOwed(round: RoundRow): boolean {
978
+ return (
979
+ round.status === "cancelled" &&
980
+ round.cancel_cause === "orphan_sweep" &&
981
+ !this.deps.store.hasLaterRound(round.pr, round.round_no)
982
+ );
983
+ }
984
+
985
+ /** A done round's interrupted apply that a later round of its PR has
986
+ * overtaken: nothing more of the old verdict lands — no transition, no
987
+ * reviewed-head or withheld mark, no successor settled — and it is recorded
988
+ * settled, so it is not resumed again. Its usage is owed over its own
989
+ * window, which ended at its claim (`finished_at`), never now — the later
990
+ * round's transcript is not this one's. The caller awaits the reads. */
991
+ private settleSupersededResume(round: RoundRow, name: string): OwedMeasurement {
992
+ this.deps.store.logEvent("verdict_resume_superseded", round.pr, {
993
+ round_id: round.id,
994
+ round_no: round.round_no,
995
+ file: name,
996
+ });
997
+ const owed = this.deps.store.db.transaction(() => {
998
+ const measurement = this.oweMeasurement(round, round.finished_at);
999
+ this.deps.store.logEvent("verdict_settled", round.pr, {
1000
+ round_id: round.id,
1001
+ round_no: round.round_no,
1002
+ superseded: true,
1003
+ });
1004
+ return measurement;
1005
+ })();
1006
+ this.log(
1007
+ `verdicts: #${round.pr} round ${round.round_no}'s interrupted apply is overtaken by a later round — ${name} lands nothing more`,
1008
+ );
1009
+ return owed;
1010
+ }
1011
+
1012
+ /** A verdict file names a round this PR does not have: archived inert,
1013
+ * recorded, and nothing else touched — no round settled, no lifecycle moved,
1014
+ * no failure opened against whatever round IS running. */
1015
+ private ignoreUnknownRound(pr: number, roundNo: number, name: string, malformed?: string): void {
1016
+ this.deps.store.logEvent("verdict_round_unknown", pr, {
1017
+ round_no: roundNo,
1018
+ file: name,
1019
+ ...(malformed !== undefined ? { malformed } : {}),
1020
+ });
1021
+ this.log(`verdicts: #${pr} has no round ${roundNo} — ${name} is not applied to any round`);
1022
+ }
1023
+
1024
+ /** Whether the apply of this round's verdict ran to its last durable step. */
1025
+ private verdictSettled(pr: number, roundId: number): boolean {
1026
+ return (
1027
+ this.deps.store.db
1028
+ .prepare(
1029
+ "SELECT 1 FROM events WHERE kind = 'verdict_settled' AND pr = ? AND json_extract(payload, '$.round_id') = ? LIMIT 1",
1030
+ )
1031
+ .get(pr, roundId) !== undefined
1032
+ );
1033
+ }
1034
+
1035
+ private archive(name: string): void {
1036
+ mkdirSync(this.processedDir, { recursive: true });
1037
+ try {
1038
+ renameSync(
1039
+ join(this.deps.verdictDir, name),
1040
+ join(this.processedDir, `${this.now().toISOString().replace(/[:.]/g, "-")}-${name}`),
1041
+ );
1042
+ } catch (err) {
1043
+ this.log(`verdicts: could not archive ${name}`, err);
1044
+ }
1045
+ }
1046
+
1047
+ /** Read + validate + apply one verdict file. Returns true when the file was
1048
+ * consumed (applied or quarantined). */
1049
+ async handleFile(name: string): Promise<boolean> {
1050
+ const match = VERDICT_FILE_RE.exec(name);
1051
+ if (!match) return false;
1052
+ if (this.inFlight.has(name)) return false;
1053
+ this.inFlight.add(name);
1054
+ try {
1055
+ const pr = Number(match[1]);
1056
+ const roundNo = Number(match[2]);
1057
+ const path = join(this.deps.verdictDir, name);
1058
+ if (!existsSync(path)) return false;
1059
+
1060
+ // Partial writes: an unparseable file is waited out while its bytes
1061
+ // are still moving — by its mtime or between our own looks — and
1062
+ // declared only once it has sat still, or after a hard window. A session
1063
+ // may write in place (open O_TRUNC, then the bytes), and on a
1064
+ // CPU-starved container the gap between the two can be long. A file
1065
+ // that parses is complete; its shape is judged at once.
1066
+ const read = await readSettledJson(path, resolvePartialWritePolicy(this.deps.partialWrite));
1067
+ if (read.kind === "missing") return false;
1068
+ if (read.reads > 1) {
1069
+ this.deps.store.logEvent("verdict_read_waited", pr, {
1070
+ file: name,
1071
+ round_no: roundNo,
1072
+ reads: read.reads,
1073
+ waited_ms: Math.round(read.waited_ms),
1074
+ outcome: read.kind,
1075
+ });
1076
+ }
1077
+ const parsed: ParsedVerdict =
1078
+ read.kind === "parsed"
1079
+ ? validateVerdict(read.value)
1080
+ : {
1081
+ ok: false,
1082
+ error:
1083
+ `unparseable: ${read.error}` +
1084
+ (read.why === "no_tolerance"
1085
+ ? ""
1086
+ : ` (still unparseable after ${read.reads} read(s) over ${(read.waited_ms / 1000).toFixed(1)}s — ${read.why === "settled" ? "the file had stopped changing" : "the partial-write window ran out"})`),
1087
+ };
1088
+
1089
+ if (!parsed.ok || !parsed.verdict) {
1090
+ await this.handleMalformed(
1091
+ pr,
1092
+ roundNo,
1093
+ name,
1094
+ `malformed verdict file ${name}: ${parsed.error ?? "unknown"}`,
1095
+ );
1096
+ this.archive(name);
1097
+ return true;
1098
+ }
1099
+ // The payload must be the file's own round — the same binding
1100
+ // RecheckDecisionWatcher enforces on its filenames. A payload naming
1101
+ // another PR or round would otherwise act on that other lifecycle while
1102
+ // this round never gets its verdict; it is this round's malformed verdict
1103
+ // instead.
1104
+ if (parsed.verdict.pr !== pr || parsed.verdict.round_no !== roundNo) {
1105
+ await this.handleMalformed(
1106
+ pr,
1107
+ roundNo,
1108
+ name,
1109
+ `malformed verdict file ${name}: payload names PR #${parsed.verdict.pr} round ${parsed.verdict.round_no}, not the filename's`,
1110
+ );
1111
+ this.archive(name);
1112
+ return true;
1113
+ }
1114
+ if (parsed.passes_note) this.log(`verdicts: ${name} passes telemetry: ${parsed.passes_note}`);
1115
+ const applied = await this.apply(parsed.verdict, name);
1116
+ if (applied === "waiting") return false;
1117
+ this.archive(name);
1118
+ return true;
1119
+ } finally {
1120
+ this.inFlight.delete(name);
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * A verdict file that cannot be applied. Before failing loud it asks
1126
+ * whether the round's review already reached GitHub — a posted review is
1127
+ * the evidence, and failing it would hold a PR over "no review was
1128
+ * produced".
1129
+ *
1130
+ * - The round's verdict already settled (a replay of a done round, or a
1131
+ * round guard 2 already closed): inert, like any replay.
1132
+ * - A receipt settlement a restart interrupted (the round is done but its
1133
+ * apply never reached `verdict_settled`): resumed, never failed.
1134
+ * - Running, with a confirmed receipt that proves the decision
1135
+ * (`decidedVerdictFromReceipt`): settled from the receipt — the posted
1136
+ * review is the evidence. No FAILED_LOUD, no self-requeue, so no second
1137
+ * posting.
1138
+ * - Running, refused by guard 2 (a `skipped` record): the guarded no-op.
1139
+ * - Anything else — no receipt, an ambiguous or undecidable one, a legacy
1140
+ * round: the malformed anomaly. A missing receipt still fails loud.
1141
+ */
1142
+ private async handleMalformed(
1143
+ pr: number,
1144
+ roundNo: number,
1145
+ name: string,
1146
+ message: string,
1147
+ ): Promise<void> {
1148
+ const round = this.runningRound(pr, roundNo);
1149
+ if (!round) {
1150
+ this.ignoreUnknownRound(pr, roundNo, name, message);
1151
+ return;
1152
+ }
1153
+ if (round.status !== "running") {
1154
+ if (this.roundTerminallySettled(round)) {
1155
+ this.deps.store.logEvent("verdict_replay_ignored", pr, {
1156
+ round_id: round.id,
1157
+ round_no: roundNo,
1158
+ file: name,
1159
+ malformed: message,
1160
+ });
1161
+ this.log(
1162
+ `verdicts: #${pr} round ${roundNo} already settled (${round.status}) — malformed ${name} replays inertly`,
1163
+ );
1164
+ return;
1165
+ }
1166
+ // The file is still in verdict/ because the round's receipt settlement
1167
+ // was cut short (archive runs only after it): finish that settlement.
1168
+ if (await this.resumeReceiptSettlement(round)) return;
1169
+ // A cancelled round is closed for good, as in `apply`: a malformed file
1170
+ // reaching it afterwards opens no failure over whatever replaced it (an
1171
+ // operator's `u` stays undone by nothing the old session writes).
1172
+ if (round.status === "cancelled") {
1173
+ this.ignoreAfterCancel(round, name, { malformed: message });
1174
+ return;
1175
+ }
1176
+ }
1177
+ let anomaly = message;
1178
+ if (round && round.status === "running" && round.posting_key) {
1179
+ const proof = await this.postingProof(round);
1180
+ if (proof.kind === "confirmed" && proof.receipt) {
1181
+ const decision = decidedVerdictFromReceipt(proof.receipt);
1182
+ if (decision) {
1183
+ await this.settleFromReceipt(
1184
+ round,
1185
+ proof.receipt,
1186
+ decision,
1187
+ "malformed_verdict",
1188
+ message,
1189
+ );
1190
+ return;
1191
+ }
1192
+ // Posted, but nothing on the receipt says what was decided: the loud
1193
+ // path, saying so.
1194
+ anomaly = `${message} (its review was posted — ${proof.receipt.actual_event}${proof.receipt.gh_review_id !== undefined ? `, GitHub review ${proof.receipt.gh_review_id}` : ""} — but the posting receipt does not say what was decided)`;
1195
+ } else if (proof.kind === "skipped" && proof.noop) {
1196
+ await this.settleGuardedNoop(round, proof.noop, "malformed_verdict");
1197
+ return;
1198
+ } else if (
1199
+ proof.kind === "waiting" &&
1200
+ pendingPosting(this.postingReceiptDir(), pr, roundNo, round.posting_key)
1201
+ ) {
1202
+ // The malformed tail cannot cancel independently owned delivery.
1203
+ // Its confirmed receipt will settle the decision on a later tick.
1204
+ return;
1205
+ }
1206
+ }
1207
+ await this.handleAnomaly(pr, roundNo, anomaly, "malformed");
1208
+ }
1209
+
1210
+ /** Has this (non-running) round been closed for good — a verdict (or a
1211
+ * receipt settlement) applied to its last durable step, or a guard-2
1212
+ * no-op? */
1213
+ private roundTerminallySettled(round: RoundRow): boolean {
1214
+ if (round.status === "done") return this.verdictSettled(round.pr, round.id);
1215
+ return round.status === "cancelled" && round.cancel_cause === "no_new_evidence";
1216
+ }
1217
+
1218
+ /**
1219
+ * Hold the round's verdict-file slot — the one `handleFile` holds while it
1220
+ * reads, waits out a partial write and applies — for a settlement that does
1221
+ * not come from the file. A receipt settlement and a verdict
1222
+ * landing in the same moment can then never both apply. Busy ⇒ undefined:
1223
+ * the caller's next tick decides again.
1224
+ */
1225
+ private async withVerdictSlot<T>(
1226
+ pr: number,
1227
+ roundNo: number,
1228
+ fn: () => Promise<T>,
1229
+ ): Promise<T | undefined> {
1230
+ const name = verdictFileName(pr, roundNo);
1231
+ if (this.inFlight.has(name)) return undefined;
1232
+ this.inFlight.add(name);
1233
+ try {
1234
+ return await fn();
1235
+ } finally {
1236
+ this.inFlight.delete(name);
1237
+ }
1238
+ }
1239
+
1240
+ private verdictFilePresent(pr: number, roundNo: number): boolean {
1241
+ return existsSync(join(this.deps.verdictDir, verdictFileName(pr, roundNo)));
1242
+ }
1243
+
1244
+ /**
1245
+ * Close a running round from its confirmed posting receipt:
1246
+ * the review is on GitHub and the verdict file is malformed, never came, or
1247
+ * did not come within the posted-tail grace. Never posts anything — the
1248
+ * whole point is that the review already went out once.
1249
+ *
1250
+ * Only for a receipt that proves the decision (`decidedVerdictFromReceipt`);
1251
+ * an undecidable one stays on the caller's loud path. The round is applied
1252
+ * exactly as its verdict file would have been — the one `apply` path, with
1253
+ * the posting already proven — so the labels, the transition, the
1254
+ * reviewed-head mark, the withheld-approval mark, the successor settlement,
1255
+ * the usage, the ledger and findings-record checks and the held wakes all
1256
+ * happen as usual. Its summary says where the verdict came from; its
1257
+ * executed-pass telemetry is honestly unknown.
1258
+ *
1259
+ * The claim has two halves. A fresh read of the round, followed with no await
1260
+ * between by `apply`'s own read: a round the operator cancelled, a newer
1261
+ * round superseded, or another path settled while the caller awaited GitHub
1262
+ * is never applied over. And `apply`'s `finishRound` is a compare-and-set on
1263
+ * the status that read saw: one that changed while `apply` itself awaited
1264
+ * the label calls is not applied over either — the apply stops there
1265
+ * (`verdict_apply_claim_lost`). The `verdict_settled_from_receipt` event is
1266
+ * written before the apply and carries the verdict it applies, so a restart
1267
+ * in the middle of it is resumed (`resumeReceiptSettlement`), never failed.
1268
+ */
1269
+ private async settleFromReceipt(
1270
+ round: RoundRow,
1271
+ receipt: PostingReceipt,
1272
+ decision: ReceiptDecision,
1273
+ via: ReceiptSettleVia,
1274
+ why: string,
1275
+ ): Promise<"applied" | "replayed"> {
1276
+ const { store } = this.deps;
1277
+ const ghId = receipt.gh_review_id ?? null;
1278
+ const posted = `${receipt.actual_event}${ghId !== null ? `, GitHub review ${ghId}` : ""}`;
1279
+ const plain =
1280
+ via === "malformed_verdict"
1281
+ ? "its verdict file could not be read"
1282
+ : via === "deadline"
1283
+ ? "no verdict file arrived before the round deadline"
1284
+ : via === "settled_without_verdict"
1285
+ ? "the session finished without writing its verdict file"
1286
+ : "the session did not write its verdict file within the grace after posting";
1287
+ const event = {
1288
+ round_id: round.id,
1289
+ round_no: round.round_no,
1290
+ via,
1291
+ why,
1292
+ posting_key: round.posting_key,
1293
+ head_sha: receipt.head_sha,
1294
+ intended_event: receipt.intended_event,
1295
+ actual_event: receipt.actual_event,
1296
+ gh_review_id: ghId,
1297
+ confirmed_at: receipt.confirmed_at ?? null,
1298
+ decided_verdict: decision.verdict,
1299
+ approval_withheld: decision.approval_withheld === true,
1300
+ };
1301
+ // ---- the claim: fresh read → apply, with no await between ---------------
1302
+ const fresh = this.runningRound(round.pr, round.round_no);
1303
+ if (!fresh || fresh.id !== round.id || fresh.status !== "running") {
1304
+ store.logEvent("verdict_settled_from_receipt_replayed", round.pr, {
1305
+ ...event,
1306
+ round_status: fresh?.id === round.id ? fresh.status : "missing",
1307
+ });
1308
+ this.log(
1309
+ `verdicts: #${round.pr} round ${round.round_no} is no longer running (${fresh?.id === round.id ? fresh.status : "missing"}) — its receipt settlement (${via}) is not applied`,
1310
+ );
1311
+ return "replayed";
1312
+ }
1313
+ const reading = decision.approval_withheld
1314
+ ? "a COMMENT the session asked for is the round templates' withheld-approval shape, so it is APPROVE with the approval withheld until CI is green at this head"
1315
+ : `the receipt proves ${decision.verdict}`;
1316
+ const v: VerdictFile = {
1317
+ pr: round.pr,
1318
+ round_no: round.round_no,
1319
+ verdict: decision.verdict,
1320
+ head_sha: receipt.head_sha,
1321
+ ...(decision.approval_withheld ? { approval_withheld: true } : {}),
1322
+ summary:
1323
+ `This round's review was posted (${posted}), but ${plain}, so its verdict was settled from the posted review: ${reading}. ` +
1324
+ "The session's own summary and pass telemetry were not available.",
1325
+ finished_at: this.now().toISOString(),
1326
+ };
1327
+ store.logEvent("verdict_settled_from_receipt", round.pr, { ...event, verdict_file: v });
1328
+ this.log(
1329
+ `verdicts: #${round.pr} round ${round.round_no} settled from its posting receipt (${posted}) as ${decision.verdict}${decision.approval_withheld ? " (approval withheld)" : ""} — ${plain}`,
1330
+ );
1331
+ await this.apply(v, verdictFileName(round.pr, round.round_no), { via, gh_review_id: ghId });
1332
+ return "applied";
1333
+ }
1334
+
1335
+ /** The receipt settlement this round started (its `verdict_settled_from_receipt`
1336
+ * event, with the verdict it was applying), if any. */
1337
+ private receiptSettlementStarted(
1338
+ round: RoundRow,
1339
+ ): { verdict: VerdictFile; via: ReceiptSettleVia; gh_review_id: number | null } | undefined {
1340
+ const row = this.deps.store.db
1341
+ .prepare(
1342
+ "SELECT payload FROM events WHERE kind = 'verdict_settled_from_receipt' AND pr = ? AND json_extract(payload, '$.round_id') = ? ORDER BY id DESC LIMIT 1",
1343
+ )
1344
+ .get(round.pr, round.id) as { payload: string } | undefined;
1345
+ if (!row) return undefined;
1346
+ let payload: { verdict_file?: unknown; via?: unknown; gh_review_id?: unknown };
1347
+ try {
1348
+ payload = JSON.parse(row.payload) as typeof payload;
1349
+ } catch {
1350
+ return undefined;
1351
+ }
1352
+ const parsed = validateVerdict(payload.verdict_file);
1353
+ if (!parsed.ok || !parsed.verdict) return undefined;
1354
+ if (parsed.verdict.pr !== round.pr || parsed.verdict.round_no !== round.round_no)
1355
+ return undefined;
1356
+ if (parsed.verdict.verdict !== "APPROVE" && parsed.verdict.verdict !== "REQUEST_CHANGES")
1357
+ return undefined;
1358
+ return {
1359
+ verdict: parsed.verdict,
1360
+ via: typeof payload.via === "string" ? (payload.via as ReceiptSettleVia) : "deadline",
1361
+ gh_review_id: typeof payload.gh_review_id === "number" ? payload.gh_review_id : null,
1362
+ };
1363
+ }
1364
+
1365
+ /**
1366
+ * Finish a receipt settlement a restart cut short. `apply` finishes the
1367
+ * round row before its last durable steps (the transition, the marks, the
1368
+ * successor) and records `verdict_settled` only after them; a verdict file
1369
+ * survives a restart in that window and resumes the apply, but
1370
+ * a receipt settlement has no file — and on the malformed path the file it
1371
+ * does have is the broken one, which would turn a posted review into
1372
+ * FAILED_LOUD. So the settlement's own event carries the verdict it applies,
1373
+ * and a done round whose apply never settled is re-applied from it, the same
1374
+ * way a surviving file is. Returns false when there is nothing to resume.
1375
+ * The caller holds the round's verdict slot.
1376
+ */
1377
+ private async resumeReceiptSettlement(round: RoundRow): Promise<boolean> {
1378
+ if (round.status !== "done" || this.verdictSettled(round.pr, round.id)) return false;
1379
+ const started = this.receiptSettlementStarted(round);
1380
+ if (!started) return false;
1381
+ this.deps.store.logEvent("verdict_settled_from_receipt_resumed", round.pr, {
1382
+ round_id: round.id,
1383
+ round_no: round.round_no,
1384
+ via: started.via,
1385
+ verdict: started.verdict.verdict,
1386
+ });
1387
+ this.log(
1388
+ `verdicts: #${round.pr} round ${round.round_no} — resuming its interrupted receipt settlement (${started.via})`,
1389
+ );
1390
+ await this.apply(started.verdict, verdictFileName(round.pr, round.round_no), {
1391
+ via: started.via,
1392
+ gh_review_id: started.gh_review_id,
1393
+ });
1394
+ return true;
1395
+ }
1396
+
1397
+ /**
1398
+ * The sweep's half of the resume: receipt settlements with no verdict file
1399
+ * (the deadline and stage-limit paths) that a restart cut short. Bounded to
1400
+ * the last two days of settlements — a restart is minutes, and a done round
1401
+ * older than that is not touched on the strength of an old event.
1402
+ */
1403
+ private async resumeInterruptedSettlements(): Promise<void> {
1404
+ const { store } = this.deps;
1405
+ const cutoff = new Date(this.now().getTime() - 2 * 86_400_000).toISOString();
1406
+ const rows = store.db
1407
+ .prepare(
1408
+ "SELECT DISTINCT pr, json_extract(payload, '$.round_id') AS round_id FROM events WHERE kind = 'verdict_settled_from_receipt' AND ts >= ?",
1409
+ )
1410
+ .all(cutoff) as Array<{ pr: number; round_id: number | null }>;
1411
+ for (const row of rows) {
1412
+ if (row.round_id === null) continue;
1413
+ const round = store.roundsFor(row.pr).find((r) => r.id === row.round_id);
1414
+ if (!round || round.status !== "done" || this.verdictSettled(round.pr, round.id)) continue;
1415
+ // A file still in verdict/ resumes through handleFile.
1416
+ if (this.verdictFilePresent(round.pr, round.round_no)) continue;
1417
+ try {
1418
+ await this.withVerdictSlot(round.pr, round.round_no, () =>
1419
+ this.resumeReceiptSettlement(round),
1420
+ );
1421
+ } catch (err) {
1422
+ this.log(`verdicts: resuming #${round.pr} round ${round.round_no} failed`, err);
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ private postingReceiptDir(): string {
1428
+ return (
1429
+ this.deps.postingReceiptDir ??
1430
+ join(this.deps.root ?? sandboxRoot(), "state", "posting-receipts")
1431
+ );
1432
+ }
1433
+
1434
+ private async postingProof(
1435
+ round: RoundRow,
1436
+ verdict?: VerdictFile,
1437
+ ): Promise<{
1438
+ kind: "legacy" | "confirmed" | "waiting" | "failed" | "skipped";
1439
+ detail?: string;
1440
+ receipt?: PostingReceipt;
1441
+ /** The guarded no-op record, when `kind` is `skipped`. */
1442
+ noop?: GuardedNoopReceipt;
1443
+ /** Why `failed`, for the one caller that may otherwise requeue — a
1444
+ * stage limit:
1445
+ * - `unconfirmed`: nothing says a review went out — no receipt, or a
1446
+ * `prepared` one whose marker reconcile read GitHub and found nothing
1447
+ * (or a dry run, which never posts). Requeueing cannot post twice.
1448
+ * - `ambiguous`: a `prepared` receipt for exactly this round whose POST
1449
+ * may have landed, and the marker reconcile did not complete (the
1450
+ * read threw, or the adapter cannot read reviews). Unknown, never
1451
+ * read as "not posted".
1452
+ * - `contradicted`: evidence of a posting that disagrees with the round
1453
+ * (conflicting linkage, several matching reviews, a receipt or review
1454
+ * that does not match the verdict, a no-op record from the other
1455
+ * mode).
1456
+ * - `exhausted`: a `deferred` receipt whose daemon-owned
1457
+ * retry is over without a confirmed review — its window spent, a
1458
+ * guard refused it, or its handoff is unreadable. The POST was
1459
+ * attempted, so never a requeue: every path fails it loud. */
1460
+ failure?: "unconfirmed" | "ambiguous" | "contradicted" | "exhausted";
1461
+ }> {
1462
+ if (!round.posting_key) return { kind: "legacy" };
1463
+ const expectedHead = verdict?.head_sha ?? round.posting_key.split(":").slice(3).join(":");
1464
+ const receipt = readPostingReceipt(this.postingReceiptDir(), round.pr, round.round_no);
1465
+ const receiptIdentityOk =
1466
+ receipt !== undefined &&
1467
+ receipt.posting_key === round.posting_key &&
1468
+ receipt.pr === round.pr &&
1469
+ receipt.round_no === round.round_no &&
1470
+ receipt.head_sha.toLowerCase() === expectedHead.toLowerCase();
1471
+ // The shell recorded a guard-2 refusal for this exact identity.
1472
+ // It is answered only after the live marker reconcile below has had its
1473
+ // say: the record claims no POST was attempted, and a bot review carrying
1474
+ // this round's marker at this head would prove it wrong — GitHub's truth
1475
+ // wins over the file, exactly as it wins over a missing file.
1476
+ let noop: GuardedNoopReceipt | undefined;
1477
+ let noopModeMismatch = false;
1478
+ if (receiptIdentityOk && receipt && receipt.status === "skipped") {
1479
+ // Same rule a `dryrun` receipt lives under: a record written in the
1480
+ // other mode came from a script whose config disagrees with this
1481
+ // daemon's, and that disagreement is not something to settle quietly.
1482
+ if ((receipt.mode === "dryrun") === this.deps.cfg.dry_run) noop = receipt;
1483
+ else noopModeMismatch = true;
1484
+ }
1485
+ // A deferred POST is posting IN FLIGHT for its whole retry
1486
+ // window. The daemon's retry owns it — the marker read included, so this
1487
+ // proof makes no GitHub read of its own that could race the retry's POST
1488
+ // — and no stage limit, deadline or verdict apply may requeue, fail or
1489
+ // kill the round meanwhile (a rerun would post a second review).
1490
+ if (
1491
+ receiptIdentityOk &&
1492
+ receipt &&
1493
+ receipt.status === "deferred" &&
1494
+ !this.deps.cfg.dry_run &&
1495
+ pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key)
1496
+ )
1497
+ return { kind: "waiting" };
1498
+ if (receiptIdentityOk && receipt && receipt.status !== "skipped") {
1499
+ if (receipt.status === "confirmed" && (!verdict || receiptMatchesVerdict(receipt, verdict))) {
1500
+ const persisted = this.deps.store.confirmRoundPosting(round.id, {
1501
+ posting_key: receipt.posting_key,
1502
+ actual_event: receipt.actual_event,
1503
+ gh_review_id: receipt.gh_review_id!,
1504
+ gh_commit_id: receipt.gh_commit_id!,
1505
+ confirmed_at: receipt.confirmed_at!,
1506
+ });
1507
+ return persisted
1508
+ ? { kind: "confirmed", receipt }
1509
+ : {
1510
+ kind: "failed",
1511
+ detail: `conflicting posting linkage for ${round.posting_key}`,
1512
+ failure: "contradicted",
1513
+ };
1514
+ }
1515
+ if (
1516
+ receipt.status === "dryrun" &&
1517
+ this.deps.cfg.dry_run &&
1518
+ (!verdict || receiptMatchesVerdict(receipt, verdict))
1519
+ ) {
1520
+ const persisted = this.deps.store.confirmRoundPosting(round.id, {
1521
+ posting_key: receipt.posting_key,
1522
+ actual_event: receipt.actual_event,
1523
+ gh_review_id: null,
1524
+ gh_commit_id: receipt.head_sha,
1525
+ confirmed_at: receipt.confirmed_at ?? receipt.prepared_at,
1526
+ });
1527
+ return persisted
1528
+ ? { kind: "confirmed", receipt }
1529
+ : {
1530
+ kind: "failed",
1531
+ detail: `conflicting dry-run posting linkage for ${round.posting_key}`,
1532
+ failure: "contradicted",
1533
+ };
1534
+ }
1535
+ if ((receipt.status === "confirmed" || receipt.status === "dryrun") && verdict) {
1536
+ return {
1537
+ kind: "failed",
1538
+ detail: `posting receipt ${round.posting_key} does not match verdict/head`,
1539
+ failure: "contradicted",
1540
+ };
1541
+ }
1542
+ }
1543
+
1544
+ // The POST may have reached GitHub before the shell could persist its
1545
+ // response. Reconcile by the inert marker; never retry a mutation on an
1546
+ // ambiguous transport outcome.
1547
+ // Whether GitHub was actually read for the marker: a prepared receipt
1548
+ // past its grace is "not posted" only when it was — a read that threw, or
1549
+ // no way to read, leaves it unknown.
1550
+ let reconciled = false;
1551
+ if (!this.deps.cfg.dry_run && this.deps.adapter.listReviewsFull) {
1552
+ try {
1553
+ const marker = postingMarker(round.posting_key);
1554
+ const reviews = await this.deps.adapter.listReviewsFull(round.pr);
1555
+ reconciled = true;
1556
+ const matches = reviews.filter(
1557
+ (review) =>
1558
+ ownLogins(this.deps.cfg.github).includes(review.user) &&
1559
+ review.commit_id?.toLowerCase() === expectedHead.toLowerCase() &&
1560
+ review.body.includes(marker),
1561
+ );
1562
+ if (matches.length > 1)
1563
+ return {
1564
+ kind: "failed",
1565
+ detail: `multiple bot reviews match ${round.posting_key}`,
1566
+ failure: "contradicted",
1567
+ };
1568
+ if (matches.length === 1) {
1569
+ const match = matches[0]!;
1570
+ // Only the three submitted states say what was posted. Any other —
1571
+ // a review a person retracted, whose state no longer says whether
1572
+ // it approved or blocked, or one still pending — is evidence the
1573
+ // round's posting cannot be read off, never a COMMENT (the posting
1574
+ // outbox reads them the same way).
1575
+ const state = match.state.toUpperCase();
1576
+ const actualEvent: PostingReceipt["actual_event"] | undefined =
1577
+ state === "APPROVED"
1578
+ ? "APPROVE"
1579
+ : state === "CHANGES_REQUESTED"
1580
+ ? "REQUEST_CHANGES"
1581
+ : state === "COMMENTED"
1582
+ ? "COMMENT"
1583
+ : undefined;
1584
+ if (!actualEvent)
1585
+ return {
1586
+ kind: "failed",
1587
+ detail: `GitHub review ${match.id} carries ${round.posting_key} in state ${match.state}, which does not say what was posted`,
1588
+ failure: "contradicted",
1589
+ };
1590
+ const verifiedIntent =
1591
+ receiptIdentityOk && receipt && receipt.status !== "skipped" ? receipt : undefined;
1592
+ const synthetic: PostingReceipt = {
1593
+ version: 1,
1594
+ posting_key: round.posting_key,
1595
+ marker,
1596
+ pr: round.pr,
1597
+ round_no: round.round_no,
1598
+ head_sha: expectedHead,
1599
+ intended_event: verifiedIntent?.intended_event ?? actualEvent,
1600
+ actual_event: actualEvent,
1601
+ ...(verifiedIntent?.status === "deferred"
1602
+ ? { delivered_by: "daemon_retry" as const }
1603
+ : {}),
1604
+ status: "confirmed",
1605
+ payload_sha256: "0".repeat(64),
1606
+ prepared_at: match.submitted_at ?? this.now().toISOString(),
1607
+ confirmed_at: match.submitted_at ?? this.now().toISOString(),
1608
+ gh_review_id: match.id,
1609
+ gh_commit_id: match.commit_id,
1610
+ gh_state: match.state,
1611
+ ...(verifiedIntent ? {} : { reconciled: true }),
1612
+ ...(isPersonMode(this.deps.cfg.github) ? { person_mode: true as const } : {}),
1613
+ };
1614
+ if (verdict && !receiptMatchesVerdict(synthetic, verdict)) {
1615
+ return {
1616
+ kind: "failed",
1617
+ detail: `GitHub review ${match.id} event does not match verdict`,
1618
+ failure: "contradicted",
1619
+ };
1620
+ }
1621
+ const persisted = this.deps.store.confirmRoundPosting(round.id, {
1622
+ posting_key: round.posting_key,
1623
+ actual_event: actualEvent,
1624
+ gh_review_id: match.id,
1625
+ gh_commit_id: match.commit_id,
1626
+ confirmed_at: synthetic.confirmed_at!,
1627
+ });
1628
+ if (!persisted)
1629
+ return {
1630
+ kind: "failed",
1631
+ detail: `conflicting reconciled posting linkage for ${round.posting_key}`,
1632
+ failure: "contradicted",
1633
+ };
1634
+ this.deps.store.logEvent("posting_receipt_reconciled", round.pr, {
1635
+ round_no: round.round_no,
1636
+ posting_key: round.posting_key,
1637
+ gh_review_id: match.id,
1638
+ });
1639
+ if (noop) {
1640
+ // The file said "never posted"; GitHub says otherwise. The posted
1641
+ // review is the truth and is linked above; the record is noted as
1642
+ // contradicted so the audit trail explains the disagreement.
1643
+ this.deps.store.logEvent("posting_guarded_noop_contradicted", round.pr, {
1644
+ round_no: round.round_no,
1645
+ posting_key: round.posting_key,
1646
+ gh_review_id: match.id,
1647
+ skipped_at: noop.skipped_at,
1648
+ });
1649
+ }
1650
+ return { kind: "confirmed", receipt: synthetic };
1651
+ }
1652
+ } catch (error) {
1653
+ this.log(
1654
+ `posting receipt reconciliation failed for #${round.pr} round ${round.round_no}`,
1655
+ error,
1656
+ );
1657
+ }
1658
+ }
1659
+
1660
+ if (noop) return { kind: "skipped", noop };
1661
+ if (noopModeMismatch) {
1662
+ return {
1663
+ kind: "failed",
1664
+ detail: `guarded no-op record for ${round.posting_key} was written in ${this.deps.cfg.dry_run ? "live" : "dry-run"} mode; this daemon is not`,
1665
+ failure: "contradicted",
1666
+ };
1667
+ }
1668
+
1669
+ // Delivery debt outlives the session and its deadline. The independent
1670
+ // outbox worker reconciles/retries it; never rerun lenses or archive a
1671
+ // waiting verdict merely because GitHub's outage outlasted the grace.
1672
+ if (
1673
+ !this.deps.cfg.dry_run &&
1674
+ pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key)
1675
+ )
1676
+ return { kind: "waiting" };
1677
+ // A deferred POST whose retry is over (the outbox job is failed or gone)
1678
+ // and whose marker the read above did not find: attempted, never
1679
+ // confirmed. Loud on every path — never a requeue.
1680
+ if (receiptIdentityOk && receipt && receipt.status === "deferred") {
1681
+ const ledger = this.deps.store.postingRetry(round.posting_key);
1682
+ return {
1683
+ kind: "failed",
1684
+ detail: `deferred posting for ${round.posting_key} was not delivered${ledger ? ` (${ledger.status} after ${ledger.attempts} attempt(s)${ledger.last_error ? `: ${ledger.last_error}` : ""})` : " (no retry on record)"}`,
1685
+ failure: "exhausted",
1686
+ };
1687
+ }
1688
+
1689
+ const origin =
1690
+ receiptIdentityOk && receipt && receipt.status !== "skipped"
1691
+ ? receipt.prepared_at
1692
+ : round.started_at;
1693
+ const graceS =
1694
+ this.deps.postingReceiptGraceS ??
1695
+ this.deps.cfg.daemon.posting_receipt_grace_s ??
1696
+ DEFAULT_POSTING_RECEIPT_GRACE_S;
1697
+ if (this.now().getTime() - Date.parse(origin) < graceS * 1000) return { kind: "waiting" };
1698
+ const postMayHaveLanded =
1699
+ !this.deps.cfg.dry_run &&
1700
+ !reconciled &&
1701
+ receiptIdentityOk &&
1702
+ receipt !== undefined &&
1703
+ receipt.status === "prepared";
1704
+ return {
1705
+ kind: "failed",
1706
+ detail: `no confirmed posting receipt for ${round.posting_key} after ${graceS}s`,
1707
+ failure: postMayHaveLanded ? "ambiguous" : "unconfirmed",
1708
+ };
1709
+ }
1710
+
1711
+ /**
1712
+ * Posting guard 2 refused this round's review — the head was
1713
+ * already reviewed and no author reply had landed since — and the shell
1714
+ * recorded that refusal as an identity-bound no-op before exiting. This is
1715
+ * successful terminal bookkeeping, not a verdict and not a failure:
1716
+ *
1717
+ * - the round closes as `cancelled` with cause `no_new_evidence` — never
1718
+ * `done` with the verdict the session wrote, because that verdict was
1719
+ * never posted, and never `infrastructure_failure`, because nothing
1720
+ * failed;
1721
+ * - the PR returns to the state its standing review put it in, read off
1722
+ * the live mark through the one standing-review interpretation; the
1723
+ * mark itself, GitHub and the withheld-approval mark are left exactly
1724
+ * as they were;
1725
+ * - round-end housekeeping runs as after a verdict: a fresh-head
1726
+ * successor is promoted, otherwise the wakes held during the round
1727
+ * flow;
1728
+ * - no `review_failures` row, no retry budget, no Slack FAILED_LOUD, no
1729
+ * replacement round, no escalation.
1730
+ *
1731
+ * Consumed once: the claim is a fresh read of the round row followed
1732
+ * synchronously by the cancel — no await between them, so the deadline
1733
+ * sweep and the verdict sweep (separate timers, each of which may have
1734
+ * awaited a GitHub read in `postingProof`) cannot both win. The loser sees
1735
+ * a round that is no longer running and archives its file without touching
1736
+ * anything.
1737
+ *
1738
+ * Returns `failed` — after handing the round to the ordinary loud path —
1739
+ * when the record's standing mark no longer describes the live one. That is
1740
+ * state changing under the record, which is ambiguous, and ambiguous stays
1741
+ * loud.
1742
+ */
1743
+ private async settleGuardedNoop(
1744
+ round: RoundRow,
1745
+ noop: GuardedNoopReceipt,
1746
+ via: ReceiptSettleVia,
1747
+ verdict?: VerdictFile,
1748
+ ): Promise<"settled" | "replayed" | "failed"> {
1749
+ const { store, cfg, adapter } = this.deps;
1750
+ // ---- the claim: fresh read → cancel, synchronously -----------------------
1751
+ const fresh = this.runningRound(round.pr, round.round_no);
1752
+ if (!fresh || fresh.id !== round.id || fresh.status !== "running") {
1753
+ store.logEvent("posting_guarded_noop_replayed", round.pr, {
1754
+ round_id: round.id,
1755
+ round_no: round.round_no,
1756
+ posting_key: round.posting_key,
1757
+ via,
1758
+ round_status: fresh?.status ?? "missing",
1759
+ });
1760
+ this.log(
1761
+ `verdicts: #${round.pr} round ${round.round_no} guarded no-op already settled (${fresh?.status ?? "missing"}) — ${via} ignored`,
1762
+ );
1763
+ return "replayed";
1764
+ }
1765
+ const measure = measureModeFor(via);
1766
+ const mark = store.reviewedHead(round.pr);
1767
+ if (!guardedNoopMatchesMark(noop, mark)) {
1768
+ await this.failPostingProof(
1769
+ round,
1770
+ `guarded no-op record for ${round.posting_key} no longer matches the live reviewed-head mark`,
1771
+ "posting_receipt",
1772
+ measure,
1773
+ );
1774
+ return "failed";
1775
+ }
1776
+ const standingInfo = standingReview(mark);
1777
+ const standing: StandingDecidedVerdict | null =
1778
+ standingInfo.kind === "decided" ? standingInfo.verdict : null;
1779
+ const head7 = noop.head_sha.slice(0, 7);
1780
+ const { owed, restored } = store.db.transaction(() => {
1781
+ store.markRoundCancelled(
1782
+ round.id,
1783
+ `posting refused by guard 2 — ${head7} already reviewed with no author reply since; ` +
1784
+ `standing ${standing ?? "review (verdict unknown)"} stands, nothing was posted`,
1785
+ "no_new_evidence",
1786
+ );
1787
+ // ---- claimed; the restoration below lands in this same turn --------------
1788
+ // The round ran a full review and burned its tokens: measured like every
1789
+ // other terminal path, never hidden behind a benign cause. Its window
1790
+ // closes here, and the measurement is owed from here, durably; the reads
1791
+ // (async — seconds, at most the usage budget) run last, after the
1792
+ // restored lifecycle and the held-wake flush, so they
1793
+ // never widen the window before either: no round dispatched while they
1794
+ // run can receive this round's held wakes. A restart while they run
1795
+ // resumes them.
1796
+ //
1797
+ // The restored lifecycle and the settlement event land synchronously
1798
+ // with the cancel, in one SQLite transaction: a replay of a cancelled round
1799
+ // is inert, so a restart or failed write must never expose partial settlement
1800
+ // (the PR never stranded in `reviewing`). The successor is promoted only
1801
+ // once this round's in-progress label is off, so that removal can never
1802
+ // strip the label the successor's dispatch adds; a restart between the
1803
+ // two leaves the durable successor obligation to reconcile's
1804
+ // successor-obligations step.
1805
+ const owed = this.oweMeasurement(round);
1806
+
1807
+ if (standingInfo.kind === "unknown") {
1808
+ // A COMMENT-only mark: guard 2 was right to refuse, and nothing here
1809
+ // may guess what that comment decided. Tracked, never an approve, with
1810
+ // an anomaly naming the shape.
1811
+ store.logAnomaly(
1812
+ "standing_review_unknown",
1813
+ round.pr,
1814
+ round.round_no,
1815
+ `round ${round.round_no}'s review was refused by posting guard 2 at ${head7} (already reviewed, no author reply since), ` +
1816
+ `but the reviewed-head mark carries no verdict (posted_event=${standingInfo.posted_event ?? "null"}). ` +
1817
+ `Nothing was posted and no verdict was guessed; the PR is tracked until the author pushes or replies.`,
1818
+ );
1819
+ }
1820
+ const restored = restoreStandingLifecycle(store, round.pr, standing, {
1821
+ botLogin: botAccountLogin(cfg.github),
1822
+ now: () => this.now(),
1823
+ reason: standing
1824
+ ? `posting-guarded-noop:${standing}`
1825
+ : "posting-guarded-noop:standing-unknown",
1826
+ });
1827
+ // The receipt proves this head already reviewed: a successor owed at it
1828
+ // is answered (as a verdict's apply and dispatch's skip answer it), a
1829
+ // fresher one stays for the promotion below.
1830
+ store.settleSuccessor(round.pr, noop.head_sha);
1831
+ store.logEvent("posting_guarded_noop", round.pr, {
1832
+ stage: "posting",
1833
+ via,
1834
+ round_id: round.id,
1835
+ round_no: round.round_no,
1836
+ posting_key: round.posting_key,
1837
+ head_sha: noop.head_sha,
1838
+ guard: noop.skip_reason,
1839
+ skipped_at: noop.skipped_at,
1840
+ mode: noop.mode,
1841
+ intended_event: noop.intended_event,
1842
+ unposted_verdict: verdict?.verdict ?? null,
1843
+ standing_verdict: standing,
1844
+ standing_source: standingInfo.kind === "decided" ? standingInfo.source : null,
1845
+ restored_state: restored,
1846
+ trigger_ts: round.trigger_ts,
1847
+ mark: snapshotMark(mark!),
1848
+ });
1849
+ return { owed, restored };
1850
+ })();
1851
+ this.log(
1852
+ `verdicts: #${round.pr} round ${round.round_no} closed as a guarded no-op — guard 2 refused at ${head7}, ` +
1853
+ `standing ${standing ?? "unknown"} stands, PR ${restored} (via ${via})`,
1854
+ );
1855
+ // ---- everything below may await ------------------------------------------
1856
+ await this.safe(
1857
+ () => adapter.removeLabel(round.pr, cfg.github.in_progress_label),
1858
+ `remove ${cfg.github.in_progress_label} on #${round.pr}`,
1859
+ );
1860
+ // Round-end housekeeping, the same as a verdict's: a fresh-head successor
1861
+ // takes the session slot; otherwise the wakes held during the round flow.
1862
+ const successorPromoted = store.promoteSuccessor(round.pr);
1863
+ if (successorPromoted)
1864
+ this.log(
1865
+ `verdicts: #${round.pr} preserved fresh-head successor after skipped round ${round.round_no}`,
1866
+ );
1867
+ if (!successorPromoted) await this.flushWakes(round.pr);
1868
+ await this.closeMeasurement(owed, measure);
1869
+ return "settled";
1870
+ }
1871
+
1872
+ /**
1873
+ * A round whose posting cannot be proven: cancelled, and its review owed as
1874
+ * a FAILED_LOUD incident. The claim is a fresh read that the round is still
1875
+ * running, followed synchronously by the cancel and the incident — every
1876
+ * caller has awaited GitHub (the posting proof) since it read the round, and
1877
+ * a round some other path ended meanwhile (an operator's `u`, a verdict, a
1878
+ * receipt settlement) owes nothing here: false, and nothing is touched.
1879
+ *
1880
+ * `resumed`: the caller is a done round's interrupted apply whose resumed
1881
+ * posting proof failed. That round is done but never settled, its PR left
1882
+ * where the interrupted apply stopped, and nothing else will repair it — so
1883
+ * it stays loud: the incident opens, with nothing to cancel.
1884
+ */
1885
+ private async failPostingProof(
1886
+ round: RoundRow,
1887
+ detail: string,
1888
+ stage = "posting_receipt",
1889
+ measure: MeasureMode = "inline",
1890
+ resumed = false,
1891
+ ): Promise<boolean> {
1892
+ const { store, cfg, adapter } = this.deps;
1893
+ const fresh = this.runningRound(round.pr, round.round_no);
1894
+ const resumedDone =
1895
+ resumed &&
1896
+ fresh?.id === round.id &&
1897
+ fresh.status === "done" &&
1898
+ !this.verdictSettled(round.pr, round.id);
1899
+ if (!resumedDone && !this.stillRunning(round)) {
1900
+ store.logEvent("posting_failure_round_ended", round.pr, {
1901
+ round_id: round.id,
1902
+ round_no: round.round_no,
1903
+ round_status: fresh?.id === round.id ? fresh.status : "missing",
1904
+ stage,
1905
+ detail,
1906
+ });
1907
+ this.log(
1908
+ `verdicts: #${round.pr} round ${round.round_no} ended while its posting was proven — ${stage} opens no failure over it`,
1909
+ );
1910
+ return false;
1911
+ }
1912
+ const { owed, failure } = store.db
1913
+ .transaction(() => {
1914
+ if (!resumedDone)
1915
+ store.markRoundCancelled(
1916
+ round.id,
1917
+ `posting receipt failure — ${detail}`,
1918
+ "infrastructure_failure",
1919
+ );
1920
+ // This round ran a full review and burned its tokens — the receipt is
1921
+ // what failed, not the work. It stays in `SUM(est_usd) FROM rounds` like
1922
+ // every other row, so it must be measured here too. Its window closes now
1923
+ // and no successor is running, so nothing double-counts. The cancel and
1924
+ // the FAILED_LOUD incident land in one turn — a restart inside any await
1925
+ // below must not leave a cancelled round with no loud failure behind it —
1926
+ // and the label call and the reads (async) run after them, the reads
1927
+ // owed durably from here, so a restart while they run resumes them.
1928
+ const owed = this.oweMeasurement(round);
1929
+ const command = store.sessionCommandForRound(round.pr, round.round_no);
1930
+ const successor = store.successorFor(round.pr);
1931
+ const failure = store.openReviewFailure({
1932
+ pr: round.pr,
1933
+ command_id: command?.id ?? null,
1934
+ round_id: round.id,
1935
+ round_no: round.round_no,
1936
+ head_sha: successor?.head_sha ?? store.getPr(round.pr)?.head_sha ?? "",
1937
+ stage,
1938
+ detail,
1939
+ attempts: command?.attempt ?? 1,
1940
+ retry_cap: 0,
1941
+ });
1942
+ store.logAnomaly(
1943
+ "review_failed_loud",
1944
+ round.pr,
1945
+ round.round_no,
1946
+ `posting receipt: ${detail}; work remains owed as ${failure.incident_id}`,
1947
+ );
1948
+ return { owed, failure };
1949
+ })
1950
+ .immediate();
1951
+ await this.safe(
1952
+ () => adapter.removeLabel(round.pr, cfg.github.in_progress_label),
1953
+ `remove ${cfg.github.in_progress_label} on #${round.pr}`,
1954
+ );
1955
+ await this.announceSafe((a) =>
1956
+ a.anomaly({
1957
+ pr: round.pr,
1958
+ round_no: round.round_no,
1959
+ kind: "failed_loud",
1960
+ deadline_min: cfg.daemon.round_deadline_min,
1961
+ incident_id: failure.incident_id,
1962
+ }),
1963
+ );
1964
+ await this.closeMeasurement(owed, measure);
1965
+ return true;
1966
+ }
1967
+
1968
+ /**
1969
+ * `failPostingProof` for the sweeps that are not the verdict's own path —
1970
+ * the round deadline and the round-stage limits — after their posting
1971
+ * proof awaited GitHub. Inside the verdict-file slot, so a verdict that
1972
+ * arrived meanwhile wins: one on disk, or one the watcher is applying (the
1973
+ * slot busy), is left to the watcher and nothing is failed under it.
1974
+ * `verdict`: a verdict owns the round; `ended`: the round ended; `failed`:
1975
+ * FAILED_LOUD.
1976
+ */
1977
+ private async failPostingProofUnlessVerdict(
1978
+ round: RoundRow,
1979
+ detail: string,
1980
+ stage: string,
1981
+ ): Promise<"verdict" | "ended" | "failed"> {
1982
+ const outcome = await this.withVerdictSlot(round.pr, round.round_no, async () => {
1983
+ if (this.verdictFilePresent(round.pr, round.round_no)) return "verdict" as const;
1984
+ return (await this.failPostingProof(round, detail, stage, "background"))
1985
+ ? ("failed" as const)
1986
+ : ("ended" as const);
1987
+ });
1988
+ return outcome ?? "verdict";
1989
+ }
1990
+
1991
+ /** The handoff: posting receipt, labels, round record, transition.
1992
+ *
1993
+ * `proven`: the caller already holds this round's confirmed posting
1994
+ * receipt and built `v` from it (`settleFromReceipt`, or the resume
1995
+ * of one a restart cut short). The receipt is not proven a second time — a
1996
+ * second marker reconcile could fail on a transient GitHub read and turn a
1997
+ * posted review into a loud failure — and the durable events say where the
1998
+ * verdict came from. */
1999
+ private async apply(
2000
+ v: VerdictFile,
2001
+ name: string,
2002
+ proven?: { via: ReceiptSettleVia; gh_review_id: number | null },
2003
+ ): Promise<"applied" | "waiting" | "failed"> {
2004
+ const { cfg, store, adapter } = this.deps;
2005
+ const round = this.runningRound(v.pr, v.round_no);
2006
+ // The status this apply claims the round in: `finishRound` below lands
2007
+ // only while the row still has it, so an operator cancel or another
2008
+ // terminal path that lands while this awaits GitHub is never overwritten.
2009
+ const claimedStatus = round?.status;
2010
+ // A receipt settlement from the deadline tick or the round-stage supervisor
2011
+ // hands its measurement to the background; a verdict file's is awaited.
2012
+ const measure: MeasureMode = proven ? measureModeFor(proven.via) : "inline";
2013
+
2014
+ // A file naming a round this PR never had is nobody's verdict: it may
2015
+ // neither settle another round nor move the PR's lifecycle.
2016
+ if (!round) {
2017
+ this.ignoreUnknownRound(v.pr, v.round_no, name);
2018
+ return "applied";
2019
+ }
2020
+
2021
+ // A round already cancelled is closed for good, whoever cancelled it — an
2022
+ // operator's `u`, a self-requeue that queued its replacement, a
2023
+ // supersession, the orphan sweep. A verdict file that reaches it
2024
+ // afterwards (an archive that failed, a session finishing behind the
2025
+ // cancel) is inert: finishing the round would apply the old verdict over
2026
+ // whatever replaced it. The same rule `finishRound`'s claim enforces for a
2027
+ // cancel landing DURING the apply. A guard-2 no-op (`no_new_evidence`)
2028
+ // keeps its own replay path below, and so does the one cancel that is not
2029
+ // a decision about the round (`orphanVerdictOwed`).
2030
+ if (
2031
+ round.status === "cancelled" &&
2032
+ round.cancel_cause !== "no_new_evidence" &&
2033
+ !this.orphanVerdictOwed(round)
2034
+ ) {
2035
+ this.ignoreAfterCancel(round, name, {
2036
+ head_sha: v.head_sha,
2037
+ outcome: v.outcome ?? null,
2038
+ verdict: v.verdict ?? null,
2039
+ });
2040
+ return "applied";
2041
+ }
2042
+
2043
+ // A done round whose apply a restart cut short resumes below — unless a
2044
+ // later round of the PR has started since: that round owns the lifecycle,
2045
+ // the marks and the successor now, and the old verdict is not applied
2046
+ // over it.
2047
+ if (
2048
+ round.status === "done" &&
2049
+ !this.verdictSettled(v.pr, round.id) &&
2050
+ store.hasLaterRound(v.pr, v.round_no)
2051
+ ) {
2052
+ await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
2053
+ return "applied";
2054
+ }
2055
+
2056
+ // Guard-5 landing: the session was refused at posting time because the
2057
+ // PR advanced mid-round, and said so explicitly instead of idling to
2058
+ // deadline expiry. Mapped exactly like the needs_human_incomplete redo:
2059
+ // immediate cancel + capped self-requeue (the same per-head counter,
2060
+ // keyed on the fresh head the PR row now carries) — and the session is
2061
+ // not killed: it is healthy, it did the
2062
+ // right thing. No verdict applies, no label lands, no reviewed-head mark
2063
+ // is stamped (nothing was reviewed-and-decided at the live head). Past
2064
+ // the cap the anomaly path terminalizes — never an approve.
2065
+ if (v.outcome === "head_moved") {
2066
+ // The round already ended — this file is a replay of a report that was
2067
+ // handled (or of a round some other path closed). Nothing is owed, and
2068
+ // nothing is reported again.
2069
+ if (round.status !== "running") {
2070
+ store.logEvent("verdict_replay_ignored", v.pr, {
2071
+ round_id: round.id,
2072
+ round_no: v.round_no,
2073
+ round_status: round.status,
2074
+ outcome: "head_moved",
2075
+ file: name,
2076
+ });
2077
+ return "applied";
2078
+ }
2079
+ store.logEvent("head_moved_reported", v.pr, {
2080
+ round_id: round.id,
2081
+ round_no: v.round_no,
2082
+ reviewed_head: v.head_sha,
2083
+ file: name,
2084
+ });
2085
+ if (await this.selfRequeue(round, "head_moved")) {
2086
+ await this.safe(
2087
+ () => adapter.removeLabel(v.pr, cfg.github.in_progress_label),
2088
+ `remove ${cfg.github.in_progress_label} on #${v.pr}`,
2089
+ );
2090
+ this.log(
2091
+ `verdicts: #${v.pr} round ${v.round_no} ended head_moved — cancelled and requeued at the fresh head`,
2092
+ );
2093
+ return "applied";
2094
+ }
2095
+ await this.handleAnomaly(
2096
+ v.pr,
2097
+ v.round_no,
2098
+ `round ${v.round_no} reported head_moved (reviewed ${v.head_sha.slice(0, 7)}, the PR advanced mid-round) but could not be self-requeued — cap exhausted or no running round`,
2099
+ "overdue",
2100
+ );
2101
+ return "applied";
2102
+ }
2103
+
2104
+ // A required pass (pass-baseline or a risk lens, marked by the plan
2105
+ // builder) that failed and never completed: the posting route refused to
2106
+ // post, so the round is redone through the capped self-requeue instead
2107
+ // of applying a verdict reached without it.
2108
+ if (round && round.status === "running") {
2109
+ const requeued = await this.requeueOnRequiredPassGap(v, round, proven !== undefined);
2110
+ if (requeued) return "applied";
2111
+ }
2112
+
2113
+ // NEEDS_HUMAN is never an escape hatch for an unfinished review. One with
2114
+ // no executed-pass evidence (e.g. "the configured passes were
2115
+ // unavailable") did not do the review; redo it through the capped
2116
+ // self-requeue. Past the cap (or with no running round to cancel) the
2117
+ // verdict applies below like any other — the cap guarantees this can
2118
+ // never loop. No reviewed-head mark is recorded on
2119
+ // this path, so the redo round can dispatch and post at the same head.
2120
+ // A round whose review already went out — a posting receipt other than a
2121
+ // guard-2 refusal, confirmed or still owned by the daemon's delivery — is
2122
+ // not redone (a redo would post again): it applies below through its
2123
+ // posting proof, as the required-pass gap does.
2124
+ if (
2125
+ v.verdict === "NEEDS_HUMAN" &&
2126
+ round.status === "running" &&
2127
+ (!v.passes || v.passes.length === 0)
2128
+ ) {
2129
+ const posting = this.reviewWentOut(v.pr, v.round_no, proven !== undefined);
2130
+ store.logEvent("needs_human_incomplete", v.pr, {
2131
+ round_id: round.id,
2132
+ round_no: v.round_no,
2133
+ head_sha: v.head_sha,
2134
+ passes_reported: v.passes ? 0 : null,
2135
+ ...(posting ? { posting_receipt: posting } : {}),
2136
+ });
2137
+ if (!posting && (await this.selfRequeue(round, "needs_human_incomplete"))) {
2138
+ await this.safe(
2139
+ () => adapter.removeLabel(v.pr, cfg.github.in_progress_label),
2140
+ `remove ${cfg.github.in_progress_label} on #${v.pr}`,
2141
+ );
2142
+ this.log(
2143
+ `verdicts: #${v.pr} round ${v.round_no} NEEDS_HUMAN without executed passes — requeued, not parked`,
2144
+ );
2145
+ return "applied";
2146
+ }
2147
+ }
2148
+
2149
+ // A round already done has had its verdict applied: this file is a
2150
+ // replay — an archive that failed and left it in verdict/,
2151
+ // or a session re-emitting an old round's file. Applying it again would
2152
+ // re-run every side effect below and stamp the old head over a newer
2153
+ // reviewed-head mark. Inert, exactly like the guarded-noop replay.
2154
+ //
2155
+ // "Done" alone is not proof: finishRound runs before the rest of the
2156
+ // apply (the transition, the reviewed-head and withheld marks, the
2157
+ // successor), across several awaits. A daemon killed in that window leaves
2158
+ // a done round with the file still in verdict/, and resuming it is the only
2159
+ // way the rest ever lands. So the replay is inert only once the apply
2160
+ // recorded that it settled (`verdict_settled`, written after the last
2161
+ // durable step); a done round without it resumes.
2162
+ if (
2163
+ round &&
2164
+ round.round_no === v.round_no &&
2165
+ round.status === "done" &&
2166
+ this.verdictSettled(v.pr, round.id)
2167
+ ) {
2168
+ store.logEvent("verdict_replay_ignored", v.pr, {
2169
+ round_id: round.id,
2170
+ round_no: v.round_no,
2171
+ head_sha: v.head_sha,
2172
+ file: name,
2173
+ });
2174
+ this.log(`verdicts: #${v.pr} round ${v.round_no} is already done — ${name} replays inertly`);
2175
+ return "applied";
2176
+ }
2177
+
2178
+ if (round && !proven) {
2179
+ const proof = await this.postingProof(round, v);
2180
+ if (proof.kind === "waiting") {
2181
+ store.logEvent("verdict_waiting_posting_receipt", v.pr, {
2182
+ round_no: v.round_no,
2183
+ posting_key: round.posting_key,
2184
+ });
2185
+ return "waiting";
2186
+ }
2187
+ if (proof.kind === "skipped") {
2188
+ // Guard 2 refused the post. The verdict in this file was never
2189
+ // published and is never applied — no label, no transition from it, no
2190
+ // reviewed-head mark, no escalation. The file is archived by the caller.
2191
+ return (await this.settleGuardedNoop(round, proof.noop!, "verdict", v)) === "failed"
2192
+ ? "failed"
2193
+ : "applied";
2194
+ }
2195
+ if (proof.kind === "failed") {
2196
+ // An orphan-swept round's verdict that its posting cannot prove is
2197
+ // not applied — and opens no incident over the requeue that stands.
2198
+ if (round.status === "cancelled") {
2199
+ this.ignoreAfterCancel(round, name, {
2200
+ reason: `posting proof failed: ${proof.detail ?? "?"}`,
2201
+ });
2202
+ return "applied";
2203
+ }
2204
+ // A round ended while the proof awaited GitHub (an operator's `u`)
2205
+ // owes nothing here: the claim inside refuses it.
2206
+ return (await this.failPostingProof(
2207
+ round,
2208
+ proof.detail ?? "posting receipt invalid",
2209
+ "posting_receipt",
2210
+ "inline",
2211
+ claimedStatus === "done",
2212
+ ))
2213
+ ? "failed"
2214
+ : "applied";
2215
+ }
2216
+ // The daemon's retry delivered this round's APPROVE after the
2217
+ // session finished, and guard 4 softened it to a COMMENT on the gate.
2218
+ // The posted review withheld the approval, so the verdict does too —
2219
+ // what the session would have written had the softening happened
2220
+ // under it; the withheld mark then re-queues on green CI (cigate.ts).
2221
+ if (
2222
+ proof.kind === "confirmed" &&
2223
+ proof.receipt &&
2224
+ retrySoftenedApproval(proof.receipt) &&
2225
+ v.verdict === "APPROVE" &&
2226
+ v.approval_withheld !== true
2227
+ ) {
2228
+ v = { ...v, approval_withheld: true };
2229
+ store.logEvent("posting_retry_approval_withheld", v.pr, {
2230
+ round_no: v.round_no,
2231
+ posting_key: round.posting_key,
2232
+ gh_review_id: proof.receipt.gh_review_id ?? null,
2233
+ });
2234
+ }
2235
+ if (proof.kind === "legacy")
2236
+ store.logEvent("posting_receipt_legacy_unknown", v.pr, { round_no: v.round_no });
2237
+ }
2238
+
2239
+ // Collect optional CI evidence before claiming the apply. Once the
2240
+ // lifecycle is terminal, all current-decision marks publish in one turn;
2241
+ // a later round must never be overwritten after an old network await.
2242
+ let withheldCi: string | null = null;
2243
+ if (v.head_sha && v.approval_withheld && v.verdict === "APPROVE" && adapter.listChecks) {
2244
+ try {
2245
+ withheldCi = classifyCi(await adapter.listChecks(v.head_sha));
2246
+ } catch (err) {
2247
+ this.log(`verdicts: checks read for withheld mark failed on #${v.pr}`, err);
2248
+ }
2249
+ }
2250
+
2251
+ const reportLostClaim = (): void => {
2252
+ if (!round) return;
2253
+ const now = this.runningRound(v.pr, v.round_no);
2254
+ const nowStatus = now?.id === round.id ? now.status : "missing";
2255
+ store.logEvent("verdict_apply_claim_lost", v.pr, {
2256
+ round_id: round.id,
2257
+ round_no: v.round_no,
2258
+ claimed_status: claimedStatus ?? null,
2259
+ round_status: nowStatus,
2260
+ verdict: v.verdict,
2261
+ head_sha: v.head_sha,
2262
+ file: name,
2263
+ ...(proven
2264
+ ? { source: "posting_receipt", via: proven.via, gh_review_id: proven.gh_review_id }
2265
+ : {}),
2266
+ });
2267
+ this.log(
2268
+ `verdicts: #${v.pr} round ${v.round_no} went ${claimedStatus ?? "?"} → ${nowStatus} while its verdict was being applied — ${v.verdict} is not applied over it`,
2269
+ );
2270
+ };
2271
+ const ownsLabelAdmission = (): boolean =>
2272
+ !round ||
2273
+ (this.runningRound(v.pr, v.round_no)?.status === claimedStatus &&
2274
+ (claimedStatus === "running" || !store.hasLaterRound(v.pr, v.round_no)));
2275
+ const stopLostLabelApply = async (): Promise<void> => {
2276
+ if (round && claimedStatus !== "running" && store.hasLaterRound(v.pr, v.round_no)) {
2277
+ if (claimedStatus === "done")
2278
+ await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
2279
+ else
2280
+ this.ignoreAfterCancel(round, name, {
2281
+ reason: "a later round started while the verdict was applied",
2282
+ });
2283
+ } else reportLostClaim();
2284
+ };
2285
+ if (!ownsLabelAdmission()) {
2286
+ await stopLostLabelApply();
2287
+ return "applied";
2288
+ }
2289
+ await this.safe(
2290
+ () => adapter.removeLabel(v.pr, cfg.github.in_progress_label),
2291
+ `remove ${cfg.github.in_progress_label} on #${v.pr}`,
2292
+ );
2293
+ if (!ownsLabelAdmission()) {
2294
+ await stopLostLabelApply();
2295
+ return "applied";
2296
+ }
2297
+ await this.safe(
2298
+ () => adapter.addLabel(v.pr, cfg.github.reviewed_label),
2299
+ `add ${cfg.github.reviewed_label} on #${v.pr}`,
2300
+ );
2301
+
2302
+ // spend_usd from the file is fiction (template echo) — never stored; the
2303
+ // measured figures land via recordRoundUsage below.
2304
+ // Captured before applyEvent: the transition context writes the verdict's
2305
+ // (possibly stale) head back onto pr_state, which would mask a mid-round
2306
+ // advance (see the chain below).
2307
+ const headBeforeApply = store.getPr(v.pr)?.head_sha ?? null;
2308
+ // The claim: finished only while the round still has the status this apply
2309
+ // read before its awaits (the posting proof, the two label calls). Lost ⇒
2310
+ // whatever landed meanwhile — an operator's `u`, a supersession — stands,
2311
+ // and nothing below runs. Before the successor record, so a lost claim
2312
+ // leaves no obligation behind either.
2313
+ //
2314
+ // An orphan-swept round's claim is `cancelled`, and a resumed apply's is
2315
+ // `done` — neither of which a cancel landing meanwhile can change — but a
2316
+ // round of the PR can have started while this awaited (the requeue the
2317
+ // sweep queued, a promoted successor). Re-asked here, in the claim's own
2318
+ // turn: a later round owns the PR, and the old verdict is not applied over it.
2319
+ if (round && claimedStatus !== "running" && store.hasLaterRound(v.pr, v.round_no)) {
2320
+ if (claimedStatus === "done")
2321
+ await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
2322
+ else
2323
+ this.ignoreAfterCancel(round, name, {
2324
+ reason: "a later round started while the verdict was applied",
2325
+ });
2326
+ return "applied";
2327
+ }
2328
+ if (round && !store.finishRound(round.id, v.verdict, null, v.summary ?? null, claimedStatus)) {
2329
+ reportLostClaim();
2330
+ return "applied";
2331
+ }
2332
+ if (round && v.head_sha && headBeforeApply && headBeforeApply !== v.head_sha) {
2333
+ store.recordSuccessor({
2334
+ pr: v.pr,
2335
+ predecessor_round_id: round.id,
2336
+ head_sha: headBeforeApply,
2337
+ provenance: "system",
2338
+ reason: "verdict-observed-head-advance",
2339
+ });
2340
+ }
2341
+ // Executed-pass telemetry: persist what actually ran beside the plan, and
2342
+ // flag plan≠executed. Best-effort side channel — nothing in it may disturb
2343
+ // the verdict.
2344
+ // A follow-up round's pre-approval sweep — what became of it, read from
2345
+ // the claim and the executed telemetry — before the pass
2346
+ // telemetry, which compares the sweep's passes only when it ran.
2347
+ const sweepStatus = round ? this.recordSweepTelemetry(v, round, proven !== undefined) : null;
2348
+ // The verdict's lifecycle event lands in the same turn as the claim above:
2349
+ // nothing may await between them, or a round the pump starts meanwhile
2350
+ // would be moved under its feet. The telemetry comes after.
2351
+ applyEvent(
2352
+ store,
2353
+ v.pr,
2354
+ { type: "verdict", verdict: v.verdict },
2355
+ { head_sha: v.head_sha },
2356
+ {
2357
+ botLogin: botAccountLogin(cfg.github),
2358
+ now: () => this.now(),
2359
+ reason: `verdict:${v.verdict}`,
2360
+ },
2361
+ );
2362
+ store.logEvent("verdict_applied", v.pr, {
2363
+ file: name,
2364
+ round_id: round?.id ?? null,
2365
+ round_no: v.round_no,
2366
+ head_sha: v.head_sha,
2367
+ verdict: v.verdict,
2368
+ ai_approved: v.ai_approved ?? null,
2369
+ ...(proven
2370
+ ? {
2371
+ source: "posting_receipt",
2372
+ via: proven.via,
2373
+ gh_review_id: proven.gh_review_id,
2374
+ }
2375
+ : {}),
2376
+ });
2377
+ // The reviewed-head mark: this head has now been reviewed and decided.
2378
+ // Read by the no-new-evidence guard at dispatch and at posting time, and
2379
+ // by the post-approval delta recheck.
2380
+ if (v.head_sha) {
2381
+ const posted = store.reviewedHead(v.pr);
2382
+ if (
2383
+ posted?.head_sha.toLowerCase() === v.head_sha.toLowerCase() &&
2384
+ posted.round_no === v.round_no &&
2385
+ posted.posted_event !== null
2386
+ )
2387
+ store.recordReviewedVerdict(v.pr, v.head_sha, v.verdict);
2388
+ else
2389
+ store.recordReviewedHead(v.pr, {
2390
+ head_sha: v.head_sha,
2391
+ verdict: v.verdict,
2392
+ round_no: v.round_no,
2393
+ });
2394
+ }
2395
+ store.recoverReviewFailure(v.pr, "verdict-applied", {
2396
+ round_no: v.round_no,
2397
+ head_sha: v.head_sha,
2398
+ });
2399
+ // The withheld-approval mark, session route. The session posted
2400
+ // its review as a COMMENT because CI was not green at this head; the mark
2401
+ // is what notices when CI goes green and re-queues the PR (cigate.ts).
2402
+ // Anything else clears it: the verdict here supersedes whatever earlier
2403
+ // round withheld an approval.
2404
+ if (v.head_sha && v.approval_withheld && v.verdict === "APPROVE") {
2405
+ store.recordApprovalWithheld(v.pr, {
2406
+ head_sha: v.head_sha,
2407
+ reason: "red-ci",
2408
+ ci_state: withheldCi,
2409
+ });
2410
+ store.logEvent("approval_withheld_red_ci", v.pr, {
2411
+ head_sha: v.head_sha,
2412
+ source: proven ? "posting_receipt" : "verdict",
2413
+ ci_state: withheldCi,
2414
+ });
2415
+ } else {
2416
+ store.clearApprovalWithheld(v.pr);
2417
+ }
2418
+ if (round) await this.recordPassTelemetry(v, round, sweepStatus);
2419
+ // Slack, in-thread and never broadcast. duration_s is computed
2420
+ // inside finishRound, so it is read back off the row rather than recomputed.
2421
+ const finished = round ? store.roundsFor(v.pr).find((r) => r.id === round.id) : undefined;
2422
+ await this.announceSafe((a) =>
2423
+ a.verdict({
2424
+ pr: v.pr,
2425
+ round_no: v.round_no,
2426
+ verdict: v.verdict,
2427
+ ...(v.verdict === "APPROVE" && v.approval_withheld ? { approval_withheld: true } : {}),
2428
+ summary: finished?.summary ?? v.summary ?? null,
2429
+ duration_s: finished?.duration_s ?? null,
2430
+ }),
2431
+ );
2432
+ // A head that advanced while this round ran was recorded as a
2433
+ // durable successor obligation before terminalization. An old-head verdict
2434
+ // can never erase it; a verdict covering the owed head settles it. Queue
2435
+ // publication and obligation retention are owned by StateStore.
2436
+ if (v.head_sha && headBeforeApply && headBeforeApply !== v.head_sha) {
2437
+ store.logEvent("mid_round_head_advance", v.pr, {
2438
+ round_no: v.round_no,
2439
+ reviewed_head: v.head_sha,
2440
+ current_head: headBeforeApply,
2441
+ verdict: v.verdict,
2442
+ });
2443
+ }
2444
+ if (v.head_sha) store.settleSuccessor(v.pr, v.head_sha);
2445
+ const successorPromoted = round ? store.promoteSuccessor(v.pr) : false;
2446
+ if (successorPromoted) {
2447
+ this.log(`verdicts: #${v.pr} preserved fresh-head successor after round ${v.round_no}`);
2448
+ }
2449
+ // Every durable step of the apply has landed: from here a replay of this
2450
+ // file is inert (the done-round guard above reads this).
2451
+ //
2452
+ // The round's usage window closed at its claim (`finished_at`), and its
2453
+ // measurement is owed from here — in the same synchronous turn as
2454
+ // `verdict_settled`. Both writes share one SQLite transaction. Its reads (async — seconds, at most the usage
2455
+ // budget) run at the very end of the apply, after the compaction request
2456
+ // and the held-wake flush: a round dispatched while they run must never
2457
+ // receive this round's held wakes (rule 9), and nothing in between reads
2458
+ // the tokens. A restart while they run leaves the row owed, and the next
2459
+ // start measures it.
2460
+ let owed: OwedMeasurement | undefined;
2461
+ if (round) {
2462
+ // Debt and replay suppression share a commit. A persistence failure
2463
+ // leaves the done apply eligible for replay, never silently unmeasured.
2464
+ owed = store.db.transaction(() => {
2465
+ const measurement = this.oweMeasurement(round, finished?.finished_at);
2466
+ this.enqueueTail(
2467
+ v.pr,
2468
+ v.round_no,
2469
+ round.id,
2470
+ v.head_sha,
2471
+ tailPlannedLenses(round, sweepStatus),
2472
+ );
2473
+ if (cfg.escalation.enabled)
2474
+ enqueueRoundWork(store, {
2475
+ key: `escalations:${round.id}`,
2476
+ kind: "escalations",
2477
+ pr: v.pr,
2478
+ round_no: v.round_no,
2479
+ payload: v,
2480
+ due_at: this.now().toISOString(),
2481
+ });
2482
+ store.logEvent("verdict_settled", v.pr, { round_id: round.id, round_no: v.round_no });
2483
+ return measurement;
2484
+ })();
2485
+ }
2486
+ // Escalation — off unless the operator switched it on, and it runs here,
2487
+ // after the verdict has been applied, the label moved and the
2488
+ // PR transitioned. That ordering is the invariant, not a convenience: an
2489
+ // escalation is additive notification beside a decided review, so it can
2490
+ // neither withhold, defer nor replace one. `deliverEscalations` returns a
2491
+ // delivery record and nothing else — there is no channel back into the
2492
+ // verdict — and the whole call is wrapped, so even a throw lands as a log
2493
+ // line on an already-applied verdict.
2494
+ if (round && cfg.escalation.enabled) await this.runRoundWorkKey(`escalations:${round.id}`);
2495
+ else await this.safe(() => this.deliverEscalations(v), `escalations pr-${v.pr}`);
2496
+ // (The worker compacts its own conversation before it exits.)
2497
+ // Ledger freshness: after compaction, so ledger-pin's mirror has had its
2498
+ // chance — and delayed, because the compaction is only requested: it is
2499
+ // still running when apply continues. Best-effort fire-and-forget —
2500
+ // nothing here may disturb the verdict.
2501
+ //
2502
+ // The reasoning-record check rides the same settle window, for the same
2503
+ // reason: it may nudge. It runs after the ledger check and is likewise
2504
+ // fire-and-forget — by the time either runs the verdict is applied, the
2505
+ // round row is finished and the PR has transitioned, so a record that
2506
+ // never arrives costs one anomaly row and one nudge and cannot hold the
2507
+ // round open.
2508
+ await this.scheduleRoundTail(
2509
+ v.pr,
2510
+ v.round_no,
2511
+ round?.id ?? null,
2512
+ v.head_sha,
2513
+ round ? tailPlannedLenses(round, sweepStatus) : "",
2514
+ );
2515
+ // The round is over. Fresh-head review work wins the session command slot;
2516
+ // held conversational wakes stay behind it and flush at the successor's
2517
+ // terminal path. Without a successor, ordinary FIFO wake delivery resumes.
2518
+ if (!successorPromoted) await this.flushWakes(v.pr);
2519
+ if (owed) await this.closeMeasurement(owed, measure);
2520
+ this.log(`verdicts: #${v.pr} round ${v.round_no} → ${v.verdict}`);
2521
+ return "applied";
2522
+ }
2523
+
2524
+ /** The round-end ledger and findings-record checks, after the settle
2525
+ * delay — shared by a verdict and a receipt settlement. */
2526
+ private tailDelayMs(): number {
2527
+ return this.deps.ledgerCheckDelayMs ?? 90_000;
2528
+ }
2529
+
2530
+ private enqueueTail(
2531
+ pr: number,
2532
+ roundNo: number,
2533
+ roundId: number | null,
2534
+ headSha: string,
2535
+ lenses: string,
2536
+ ): string {
2537
+ const key = `tail:${roundId ?? `${pr}:${roundNo}`}`;
2538
+ enqueueRoundWork(this.deps.store, {
2539
+ key,
2540
+ kind: "tail",
2541
+ pr,
2542
+ round_no: roundNo,
2543
+ payload: { roundId, headSha, lenses },
2544
+ due_at: new Date(this.now().getTime() + Math.max(0, this.tailDelayMs())).toISOString(),
2545
+ });
2546
+ return key;
2547
+ }
2548
+
2549
+ private async scheduleRoundTail(
2550
+ pr: number,
2551
+ roundNo: number,
2552
+ roundId: number | null,
2553
+ headSha: string,
2554
+ lenses: string,
2555
+ ): Promise<void> {
2556
+ const key = this.enqueueTail(pr, roundNo, roundId, headSha, lenses);
2557
+ const delay = this.tailDelayMs();
2558
+ if (delay <= 0) await this.runRoundWorkKey(key);
2559
+ else {
2560
+ const timer = setTimeout(() => {
2561
+ this.detach(() => this.runRoundWorkKey(key), `round tail ${key}`);
2562
+ }, delay);
2563
+ timer.unref?.();
2564
+ }
2565
+ }
2566
+
2567
+ private async runRoundWorkKey(key: string): Promise<void> {
2568
+ for (const kind of ["tail", "escalations"] as const) {
2569
+ const work = pendingRoundWork(this.deps.store, kind, this.now().toISOString()).find(
2570
+ (row) => row.key === key,
2571
+ );
2572
+ if (work) await this.runRoundWork(work);
2573
+ }
2574
+ }
2575
+
2576
+ private async runRoundWork(work: RoundWork): Promise<void> {
2577
+ if (this.working.has(work.key)) return;
2578
+ this.working.add(work.key);
2579
+ try {
2580
+ if (work.kind === "tail") {
2581
+ const payload = JSON.parse(work.payload) as {
2582
+ roundId: number | null;
2583
+ headSha: string;
2584
+ lenses: string;
2585
+ };
2586
+ await this.checkLedger(work.pr, work.round_no, payload.headSha);
2587
+ await this.checkFindingsRecord(
2588
+ work.pr,
2589
+ work.round_no,
2590
+ payload.roundId,
2591
+ payload.headSha,
2592
+ payload.lenses.split(",").filter(Boolean),
2593
+ );
2594
+ } else {
2595
+ const parsed = validateVerdict(JSON.parse(work.payload));
2596
+ if (!parsed.ok || !parsed.verdict || parsed.verdict.outcome === "head_moved")
2597
+ throw new Error(`unreadable escalation intent ${work.key}`);
2598
+ await this.deliverEscalations(parsed.verdict);
2599
+ }
2600
+ finishRoundWork(this.deps.store, work.key, this.now().toISOString());
2601
+ } catch (err) {
2602
+ this.log(`verdicts: owed ${work.kind} for #${work.pr} round ${work.round_no} failed`, err);
2603
+ } finally {
2604
+ this.working.delete(work.key);
2605
+ }
2606
+ }
2607
+
2608
+ private resumeRoundWork(): void {
2609
+ for (const kind of ["tail", "escalations"] as const)
2610
+ for (const work of pendingRoundWork(this.deps.store, kind, this.now().toISOString()))
2611
+ this.detach(() => this.runRoundWork(work), `owed ${kind} ${work.key}`);
2612
+ }
2613
+
2614
+ // ---- escalation (off by default) ---------------------------------------
2615
+
2616
+ /**
2617
+ * Assemble the round's facts, evaluate the operator's deterministic rules,
2618
+ * add whatever the session asked for, and hand the lot to the one delivery
2619
+ * path.
2620
+ *
2621
+ * Everything here is a no-op when `[escalation] enabled = false`: the first
2622
+ * line returns before a single fact is read, so a default install makes no
2623
+ * extra API call, writes no row and logs no event.
2624
+ */
2625
+ private escalationDeliveryDeps(): EscalationDeps | null {
2626
+ const { adapter, store, cfg } = this.deps;
2627
+ if (!cfg.escalation.enabled || !adapter.createReview) return null;
2628
+ const bot = cfg.github.bot_login.toLowerCase();
2629
+ return {
2630
+ cfg: cfg.escalation,
2631
+ store,
2632
+ dryRun: cfg.dry_run,
2633
+ isDraining: () => this.stopped,
2634
+ adapter: {
2635
+ createReview: adapter.createReview.bind(adapter),
2636
+ ...(adapter.requestEscalationReview
2637
+ ? { requestEscalationReview: adapter.requestEscalationReview.bind(adapter) }
2638
+ : {}),
2639
+ ...(adapter.listReviewsFull
2640
+ ? {
2641
+ findEscalationComment: async (
2642
+ pr: number,
2643
+ head: string,
2644
+ marker: string,
2645
+ body: string,
2646
+ ) => {
2647
+ const reviews = await adapter.listReviewsFull!(pr);
2648
+ const found = reviews.find(
2649
+ (review) =>
2650
+ review.user.toLowerCase() === bot &&
2651
+ review.commit_id.toLowerCase() === head.toLowerCase() &&
2652
+ review.state === "COMMENTED" &&
2653
+ typeof review.submitted_at === "string" &&
2654
+ review.submitted_at.length > 0 &&
2655
+ Number.isSafeInteger(review.id) &&
2656
+ review.id > 0 &&
2657
+ review.body === body &&
2658
+ review.body.split(/\r?\n/).includes(marker),
2659
+ );
2660
+ return found ? { id: found.id } : null;
2661
+ },
2662
+ }
2663
+ : {}),
2664
+ },
2665
+ log: this.log.bind(this),
2666
+ };
2667
+ }
2668
+
2669
+ /** Optional recovery never holds a fresh verdict behind a GitHub read. */
2670
+ private resumeEscalationDeliveries(): void {
2671
+ if (this.stopped || this.escalationRecoveryInFlight) return;
2672
+ const deps = this.escalationDeliveryDeps();
2673
+ if (!deps || deps.dryRun) return;
2674
+ this.escalationRecoveryInFlight = true;
2675
+ this.detach(async () => {
2676
+ try {
2677
+ await resumeEscalations(deps);
2678
+ } finally {
2679
+ this.escalationRecoveryInFlight = false;
2680
+ }
2681
+ }, "escalation delivery recovery");
2682
+ }
2683
+
2684
+ private async deliverEscalations(v: MeritsVerdictFile): Promise<void> {
2685
+ const cfg = this.deps.cfg.escalation;
2686
+ if (!cfg.enabled) return;
2687
+ const { store, adapter } = this.deps;
2688
+ if (!adapter.createReview) return; // no gated posting route ⇒ nothing to deliver through
2689
+
2690
+ // --- the facts. Every read is best-effort: a fact we cannot see means
2691
+ // the rules keyed on it do not fire, never that the round is disturbed.
2692
+ const facts: EscalationFacts = {
2693
+ changed_paths: await this.escalationPaths(v.pr),
2694
+ labels: await this.escalationLabels(v.pr),
2695
+ findings: this.escalationFindings(
2696
+ v.pr,
2697
+ v.round_no,
2698
+ cfg.rules.some((r) => r.min_severity !== ""),
2699
+ ),
2700
+ failing_checks: await this.escalationFailingChecks(v.pr, v.head_sha),
2701
+ };
2702
+
2703
+ // The two groups stay separate because they have different trust: the
2704
+ // first is the operator's configured rules, the second is whatever the
2705
+ // model wrote in its verdict file. Flattening them into one list and
2706
+ // re-deriving trust from a field inside the payload would let a session
2707
+ // relabel itself `origin: "rule"`.
2708
+ //
2709
+ // A rule that fired but produced nothing deliverable is logged, never
2710
+ // silent: the operator configured it, it matched, and they would otherwise
2711
+ // have no way to see why nobody was told.
2712
+ const ruleRequests = evaluateEscalationRules(cfg, facts, (rule, refusal) => {
2713
+ store.logEvent("escalation_rule_refused", v.pr, {
2714
+ rule: rule.name,
2715
+ to: rule.to,
2716
+ refusal,
2717
+ round_no: v.round_no,
2718
+ });
2719
+ this.log(
2720
+ `escalation: rule "${rule.name}" fired on #${v.pr} but produced nothing — ${refusal}`,
2721
+ );
2722
+ });
2723
+ const sessionCandidates = cfg.session_judgement ? (v.escalations ?? []) : [];
2724
+ if (ruleRequests.length === 0 && sessionCandidates.length === 0) return;
2725
+ await deliverEscalations(this.escalationDeliveryDeps()!, {
2726
+ pr: v.pr,
2727
+ verdict: v.verdict,
2728
+ head_sha: v.head_sha,
2729
+ round_no: v.round_no,
2730
+ // The path-in-diff check's input. An unreadable
2731
+ // listing is `[]`, which refuses every path-bearing scope rather than
2732
+ // waving it through — the safe direction, same as every other fact.
2733
+ changed_paths: facts.changed_paths,
2734
+ // Whether the round's own review actually reached GitHub, from the
2735
+ // posting receipt: the note must not claim a posting no confirmed
2736
+ // receipt shows (a dry run, a legacy round, a review the marker
2737
+ // reconcile proved without a local receipt). A guard-2 refusal never
2738
+ // gets here — it settles as a guarded no-op, with no escalation.
2739
+ review_posted: this.roundReviewConfirmed(v.pr, v.round_no, v.head_sha),
2740
+ ruleRequests,
2741
+ sessionCandidates,
2742
+ });
2743
+ }
2744
+
2745
+ /**
2746
+ * Did the round's own review confirm as posted (posting receipt)? Read-only,
2747
+ * total: an unreadable or absent receipt is `false`, and the note then says
2748
+ * "decided" without claiming "posted".
2749
+ */
2750
+ private roundReviewConfirmed(pr: number, roundNo: number, headSha: string): boolean {
2751
+ try {
2752
+ const receipt = readPostingReceipt(this.postingReceiptDir(), pr, roundNo);
2753
+ if (!receipt || receipt.pr !== pr || receipt.round_no !== roundNo) return false;
2754
+ if (receipt.head_sha.toLowerCase() !== headSha.toLowerCase()) return false;
2755
+ return receipt.status === "confirmed";
2756
+ } catch {
2757
+ return false;
2758
+ }
2759
+ }
2760
+
2761
+ private async escalationPaths(pr: number): Promise<string[]> {
2762
+ if (!this.deps.adapter.listPrFiles) return [];
2763
+ try {
2764
+ return (await this.deps.adapter.listPrFiles(pr)).map((f) => f.filename).filter(Boolean);
2765
+ } catch (err) {
2766
+ this.log(`escalation: file listing failed on #${pr}`, err);
2767
+ return [];
2768
+ }
2769
+ }
2770
+
2771
+ private async escalationLabels(pr: number): Promise<string[]> {
2772
+ if (!this.deps.adapter.getPr) return [];
2773
+ try {
2774
+ return (await this.deps.adapter.getPr(pr)).labels ?? [];
2775
+ } catch (err) {
2776
+ this.log(`escalation: PR read failed on #${pr}`, err);
2777
+ return [];
2778
+ }
2779
+ }
2780
+
2781
+ /**
2782
+ * The findings the round posted, from the reasoning record. Absent or
2783
+ * unreadable ⇒ severity-keyed rules simply do not fire.
2784
+ *
2785
+ * That silence is logged when it matters: a `needed`
2786
+ * caller — one whose config actually has a `min_severity` rule — gets an
2787
+ * `escalation_findings_unavailable` event, so a severity rule that never
2788
+ * fires is visible in the event log instead of being invisible. Without a
2789
+ * severity rule the record is not consulted for anything and the absence is
2790
+ * not worth a line.
2791
+ */
2792
+ private escalationFindings(
2793
+ pr: number,
2794
+ roundNo: number,
2795
+ needed: boolean,
2796
+ ): Array<{ severity: string; path?: string | null; title?: string | null }> {
2797
+ const dir = this.deps.findingsDir;
2798
+ if (!dir) {
2799
+ if (needed) {
2800
+ this.deps.store.logEvent("escalation_findings_unavailable", pr, {
2801
+ round_no: roundNo,
2802
+ reason: "no findings dir configured",
2803
+ });
2804
+ }
2805
+ return [];
2806
+ }
2807
+ const parsed = readFindingsRecord(findingsRecordPath(dir, pr, roundNo), pr, roundNo);
2808
+ if (!parsed.record) {
2809
+ if (needed) {
2810
+ this.log(
2811
+ `escalation: no reasoning record for #${pr} round ${roundNo} — severity rules cannot fire`,
2812
+ );
2813
+ this.deps.store.logEvent("escalation_findings_unavailable", pr, {
2814
+ round_no: roundNo,
2815
+ reason: parsed.status,
2816
+ });
2817
+ }
2818
+ return [];
2819
+ }
2820
+ return parsed.record.findings
2821
+ .filter((f) => f.disposition === "posted" && f.severity)
2822
+ .map((f) => ({ severity: String(f.severity), path: f.path, title: f.title ?? f.reason }));
2823
+ }
2824
+
2825
+ /** Check names whose latest run at the head has the classified outcome
2826
+ * "failed" (pending and inert runs are not listed) — the same
2827
+ * latest-run-per-name rule the CI gate uses, so an auto-cancelled
2828
+ * superseded run cannot escalate to a person forever. */
2829
+ private async escalationFailingChecks(pr: number, head: string): Promise<string[]> {
2830
+ if (!this.deps.adapter.listChecks || !head) return [];
2831
+ try {
2832
+ return latestRunPerName(await this.deps.adapter.listChecks(head))
2833
+ .filter((c) => checkOutcome(c) === "failed")
2834
+ .map((c) => c.name);
2835
+ } catch (err) {
2836
+ this.log(`escalation: checks read failed on #${pr}`, err);
2837
+ return [];
2838
+ }
2839
+ }
2840
+
2841
+ // ---- ledger freshness ------------------------------------------------
2842
+
2843
+ private ledgerPathFor(pr: number): string {
2844
+ const custom = this.deps.ledgerPathFor;
2845
+ if (custom) return custom(pr);
2846
+ const root = this.deps.root ?? sandboxRoot();
2847
+ return join(root, "state", "ledgers", `pr-${pr}.md`);
2848
+ }
2849
+
2850
+ /** Enforce the end-of-round ledger rewrite: header must read
2851
+ * `LEDGER PR #<n> @ <head>`. Stale ⇒ anomaly + one capped re-nudge. */
2852
+ private async checkLedger(pr: number, roundNo: number, headSha: string): Promise<void> {
2853
+ if (!headSha) return;
2854
+ await enforceLedgerFreshness(
2855
+ {
2856
+ store: this.deps.store,
2857
+ ledgerPathFor: (n) => this.ledgerPathFor(n),
2858
+ inject: (n, message) => this.deps.hooks.inject(n, message),
2859
+ log: this.log.bind(this),
2860
+ },
2861
+ { pr, roundNo, headSha },
2862
+ );
2863
+ }
2864
+
2865
+ // ---- the durable reasoning record ------------------------------------
2866
+
2867
+ /** Parse `state/findings/pr-<n>-round-<k>.json` into rows. Missing or
2868
+ * unparseable ⇒ anomaly + exactly one re-nudge, never a held-open round. */
2869
+ private async checkFindingsRecord(
2870
+ pr: number,
2871
+ roundNo: number,
2872
+ roundId: number | null,
2873
+ headSha: string,
2874
+ planned: readonly string[],
2875
+ ): Promise<void> {
2876
+ const findingsDir = this.deps.findingsDir;
2877
+ if (!findingsDir) return;
2878
+ await enforceFindingsRecord(
2879
+ {
2880
+ store: this.deps.store,
2881
+ findingsDir,
2882
+ inject: (n, message) => this.deps.hooks.inject(n, message),
2883
+ log: this.log.bind(this),
2884
+ ...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
2885
+ },
2886
+ { pr, roundNo, roundId, headSha, planned },
2887
+ );
2888
+ }
2889
+
2890
+ // ---- follow-up sweep telemetry ---------------------------------------
2891
+
2892
+ /**
2893
+ * Record what became of a follow-up round's pre-approval sweep
2894
+ * (`rounds.sweep_status`) and return it; null on a round that is not a
2895
+ * follow-up (nothing written). `ran` needs both the session's claim and
2896
+ * sweep-phase passes in its executed telemetry — a claim with no telemetry
2897
+ * behind it is `unknown`, as is a verdict settled from the posting receipt
2898
+ * (no telemetry at all): an unknown sweep never counts as a full look, so
2899
+ * the next round owes it again. An idempotent overwrite after the
2900
+ * `finishRound` claim, like the pass telemetry, so a resumed apply redoes
2901
+ * it. An APPROVE with the sweep owed and not run is the quiet
2902
+ * `followup_sweep_skipped` anomaly — the review is already on GitHub, so
2903
+ * this is telemetry, never a veto. Wrapped: nothing here may disturb the
2904
+ * verdict.
2905
+ */
2906
+ private recordSweepTelemetry(
2907
+ v: VerdictFile,
2908
+ round: RoundRow,
2909
+ receiptSettled: boolean,
2910
+ ): SweepStatus | null {
2911
+ const { store } = this.deps;
2912
+ try {
2913
+ if (round.scope !== "delta" || round.sweep_owed === null) return null;
2914
+ const owed = round.sweep_owed === 1;
2915
+ const claim = v.sweep ?? null;
2916
+ const sweepPasses = v.passes ? v.passes.filter((p) => p.phase === "sweep").length : null;
2917
+ const { status, note } = sweepStatusOf({
2918
+ owed,
2919
+ claim,
2920
+ sweepPasses,
2921
+ receiptSettled,
2922
+ verdict: v.verdict,
2923
+ });
2924
+ store.setRoundMeta(round.id, { sweep_status: status });
2925
+ store.logEvent("followup_sweep_recorded", v.pr, {
2926
+ round_id: round.id,
2927
+ round_no: v.round_no,
2928
+ owed,
2929
+ claimed: claim,
2930
+ sweep_passes: sweepPasses,
2931
+ status,
2932
+ ...(note ? { note } : {}),
2933
+ ...(v.sweep_reason ? { reason: v.sweep_reason.slice(0, 500) } : {}),
2934
+ });
2935
+ if (note) this.log(`verdicts: #${v.pr} round ${v.round_no} sweep ${status}: ${note}`);
2936
+ // What the session did with the optional rows, and why.
2937
+ if (v.extent !== undefined)
2938
+ store.logEvent("followup_extent_decided", v.pr, {
2939
+ round_id: round.id,
2940
+ round_no: v.round_no,
2941
+ extent: v.extent,
2942
+ ...(v.extent_reason ? { reason: v.extent_reason.slice(0, 500) } : {}),
2943
+ });
2944
+ // A sweep the session decided to skip is a decision, not an anomaly:
2945
+ // only an owed sweep left un-run without one is.
2946
+ if (sweepSkippedOnApprove(v.verdict, owed, status)) {
2947
+ store.logAnomaly(
2948
+ "followup_sweep_skipped",
2949
+ v.pr,
2950
+ v.round_no,
2951
+ `APPROVE with the pre-approval sweep owed and ${status}` +
2952
+ `${claim ? ` (claimed ${claim})` : " (no claim)"}` +
2953
+ `${sweepPasses === null ? "; executed passes unknown" : `; ${sweepPasses} sweep-phase passes`}`,
2954
+ );
2955
+ }
2956
+ return status;
2957
+ } catch (err) {
2958
+ this.log(`verdicts: sweep telemetry failed for #${v.pr}`, err);
2959
+ return null;
2960
+ }
2961
+ }
2962
+
2963
+ // ---- executed-pass telemetry -----------------------------------------
2964
+
2965
+ /**
2966
+ * Persist the verdict's executed-pass report and compare it with the plan
2967
+ * (`rounds.lenses`). Missing telemetry ⇒ logged as unknown; plan≠executed ⇒
2968
+ * logged anomaly + companion-inbox item. Never blocks the verdict — everything
2969
+ * here is wrapped, and the verdict was already applied by the caller.
2970
+ */
2971
+ private async recordPassTelemetry(
2972
+ v: VerdictFile,
2973
+ round: RoundRow,
2974
+ sweepStatus: SweepStatus | null = null,
2975
+ ): Promise<void> {
2976
+ const { store } = this.deps;
2977
+ try {
2978
+ if (!v.passes) {
2979
+ // The session reported nothing usable: the record stays NULL =
2980
+ // unknown, distinct from [] = "ran zero passes".
2981
+ store.logEvent("verdict_passes_unknown", v.pr, {
2982
+ round_id: round.id,
2983
+ round_no: v.round_no,
2984
+ });
2985
+ this.log(
2986
+ `verdicts: #${v.pr} round ${v.round_no} executed passes unknown (no telemetry in verdict)`,
2987
+ );
2988
+ return;
2989
+ }
2990
+ store.recordExecutedPasses(round.id, v.passes);
2991
+ const executed = v.passes.map((p) => p.name);
2992
+ const legacyPlanned = round.lenses ? round.lenses.split(",").filter(Boolean) : [];
2993
+ const plan = round.pass_plan ? (JSON.parse(round.pass_plan) as PlanRow[]) : null;
2994
+ const addable = round.addable_passes
2995
+ ? (JSON.parse(round.addable_passes) as RecordedAddablePass[])
2996
+ : null;
2997
+ // A follow-up round runs two stages. The delta stage's passes (phase
2998
+ // "delta", or none) answer to `pass_plan`; the sweep's (phase "sweep")
2999
+ // to `sweep_plan` — and a sweep lens is missing only when the sweep ran,
3000
+ // so a round that decided before the sweep raises nothing. Every other
3001
+ // round compares every pass against its plan.
3002
+ const followUp = round.scope === "delta";
3003
+ const deltaPasses = followUp ? v.passes.filter((p) => p.phase !== "sweep") : v.passes;
3004
+ const sweepPasses = followUp ? v.passes.filter((p) => p.phase === "sweep") : [];
3005
+ const main = comparePlan(deltaPasses, plan, addable, legacyPlanned, true);
3006
+ let sweep: PlanComparison | null = null;
3007
+ if (sweepPasses.length > 0 || sweepStatus === "ran") {
3008
+ const sweepPlan = round.sweep_plan ? (JSON.parse(round.sweep_plan) as PlanRow[]) : [];
3009
+ // What the sweep may add: the round's addable set and the delta's own
3010
+ // plan rows, minus what the sweep already plans.
3011
+ const inSweep = new Set(sweepPlan.map((p) => p.lens));
3012
+ const sweepAddable = [
3013
+ ...(addable ?? []),
3014
+ ...(plan ?? []).map((p) => ({ lens: p.lens, tier: p.tier })),
3015
+ ].filter((p) => !inSweep.has(p.lens));
3016
+ sweep = comparePlan(sweepPasses, sweepPlan, sweepAddable, [], sweepStatus === "ran");
3017
+ }
3018
+ const tag = (names: string[]): string[] => names.map((n) => `sweep:${n}`);
3019
+ const planned = main.planned;
3020
+ const missing = [...main.missing, ...tag(sweep?.missing ?? [])];
3021
+ const unknownAdditions = [...main.unknownAdditions, ...tag(sweep?.unknownAdditions ?? [])];
3022
+ const missingReason = [...main.missingReason, ...tag(sweep?.missingReason ?? [])];
3023
+ const wrongTier = [...main.wrongTier, ...tag(sweep?.wrongTier ?? [])];
3024
+ const allowedAdditions = [
3025
+ ...main.allowedAdditions,
3026
+ ...(sweep?.allowedAdditions ?? []).map((a) => ({ ...a, name: `sweep:${a.name}` })),
3027
+ ];
3028
+
3029
+ if (allowedAdditions.length > 0) {
3030
+ store.logEvent("pass_plan_conformant_addition", v.pr, {
3031
+ round_id: round.id,
3032
+ round_no: v.round_no,
3033
+ additions: allowedAdditions,
3034
+ });
3035
+ }
3036
+ const violations =
3037
+ missing.length + unknownAdditions.length + missingReason.length + wrongTier.length;
3038
+ if (violations === 0) return;
3039
+ store.logEvent("pass_plan_mismatch", v.pr, {
3040
+ round_id: round.id,
3041
+ round_no: v.round_no,
3042
+ planned,
3043
+ executed,
3044
+ missing,
3045
+ added: unknownAdditions,
3046
+ unknown_additions: unknownAdditions,
3047
+ missing_reason: missingReason,
3048
+ wrong_tier: wrongTier,
3049
+ allowed_additions: allowedAdditions,
3050
+ ...(sweep ? { sweep_planned: sweep.planned, sweep_status: sweepStatus } : {}),
3051
+ });
3052
+ this.log(`verdicts: #${v.pr} round ${v.round_no} pass plan mismatch`, {
3053
+ planned,
3054
+ executed,
3055
+ missing,
3056
+ unknownAdditions,
3057
+ missingReason,
3058
+ wrongTier,
3059
+ });
3060
+ store.logAnomaly(
3061
+ "pass_plan_mismatch",
3062
+ v.pr,
3063
+ v.round_no,
3064
+ `pass policy violated` +
3065
+ `${missing.length ? `; missing ${missing.join(",")}` : ""}` +
3066
+ `${unknownAdditions.length ? `; unknown ${unknownAdditions.join(",")}` : ""}` +
3067
+ `${missingReason.length ? `; no reason ${missingReason.join(",")}` : ""}` +
3068
+ `${wrongTier.length ? `; wrong tier ${wrongTier.join(",")}` : ""}`,
3069
+ [
3070
+ `PASS-PLAN MISMATCH — PR #${v.pr} round ${v.round_no}`,
3071
+ `Planned: ${planned.join(", ") || "(none)"}`,
3072
+ ...(sweep
3073
+ ? [
3074
+ `Pre-approval sweep (${sweepStatus ?? "unknown"}): ${sweep.planned.join(", ") || "(none planned)"}`,
3075
+ ]
3076
+ : []),
3077
+ `Executed: ${executed.join(", ") || "(none — solo/degraded round)"}`,
3078
+ ...(missing.length ? [`Missing planned passes: ${missing.join(", ")}`] : []),
3079
+ ...(unknownAdditions.length
3080
+ ? [`Not in the dispatched addable set: ${unknownAdditions.join(", ")}`]
3081
+ : []),
3082
+ ...(missingReason.length
3083
+ ? [`Allowed additions missing structured reason: ${missingReason.join(", ")}`]
3084
+ : []),
3085
+ ...(wrongTier.length ? [`Passes at a non-authorized tier: ${wrongTier.join(", ")}`] : []),
3086
+ "The verdict stands — this is telemetry, not a veto.",
3087
+ ].join("\n"),
3088
+ );
3089
+ } catch (err) {
3090
+ this.log(`verdicts: pass telemetry failed for #${v.pr}`, err);
3091
+ }
3092
+ }
3093
+
3094
+ // ---- measured usage --------------------------------------------------
3095
+
3096
+ private sessionDirFor(pr: number): string {
3097
+ const custom = this.deps.usage?.sessionDirFor;
3098
+ if (custom) return custom(pr);
3099
+ const root = this.deps.root ?? sandboxRoot();
3100
+ return join(root, this.deps.cfg.pi.session_dir, `pr-${pr}`);
3101
+ }
3102
+
3103
+ /**
3104
+ * Did git's configuration change while this round ran? And did this
3105
+ * system's own policy surface (config.toml, scripts/, dist/, extensions/,
3106
+ * the seeds)?
3107
+ *
3108
+ * The write veto in the bash guard reads command strings, and five write
3109
+ * primitives on the guard's own read-only allowlist go straight past it —
3110
+ * `T=.git/config; cat p >> $T` can never be seen by a token scan, because
3111
+ * the target is a variable. This check does not look at commands at all: the
3112
+ * fingerprint was taken when `dispatch` prepared the worktree, and any
3113
+ * difference now is a write the daemon did not make. The two families raise
3114
+ * different anomalies, because they mean different things: a git change
3115
+ * taints this round; a policy change rewrites the boundary every later round
3116
+ * in the container runs under.
3117
+ *
3118
+ * Runs inside `measureUsage`, which is deliberately the one place that fires
3119
+ * on both the verdict path and the anomaly/overdue path — a round that ends
3120
+ * badly is exactly when this most wants checking.
3121
+ *
3122
+ * Never throws and never fails a round: an unavailable fingerprint at either
3123
+ * end leaves the round unchecked, and a NULL baseline is never read as
3124
+ * "changed" (unknown is not evidence of loss). The re-hash is async with a
3125
+ * loop turn after every file — it walks dist/, scripts/ and the
3126
+ * rest of the policy surface — and bounded by `integrityBudgetMs`: a read
3127
+ * that never returns leaves the round unchecked (`round_integrity_unchecked`),
3128
+ * never a round end that does not finish.
3129
+ */
3130
+ private async checkRoundIntegrity(pr: number, roundId: number): Promise<void> {
3131
+ const { cfg, store } = this.deps;
3132
+ try {
3133
+ const before = this.integrityBaseline(roundId);
3134
+ if (!before) return; // no baseline — never judged
3135
+ const root = this.deps.root ?? sandboxRoot();
3136
+ const budgetMs = Math.max(0, this.deps.integrityBudgetMs ?? INTEGRITY_BUDGET_MS);
3137
+ const rehash = await withinBudget(
3138
+ (this.deps.integrityFingerprint ?? fingerprintRoundIntegrity)(
3139
+ roundIntegrityOpts(githubCloneDir(cfg.github), root, prWorktreeRel(pr)),
3140
+ ),
3141
+ budgetMs,
3142
+ );
3143
+ if (rehash.kind === "budget") {
3144
+ // Unchecked, never "unchanged": the baseline stays, nothing is judged.
3145
+ store.logEvent("round_integrity_unchecked", pr, {
3146
+ round_id: roundId,
3147
+ stage: "round_end",
3148
+ reason: "budget",
3149
+ budget_ms: budgetMs,
3150
+ });
3151
+ this.log(
3152
+ `verdicts: #${pr} round integrity UNCHECKED — the re-hash did not finish within ${budgetMs} ms`,
3153
+ );
3154
+ return;
3155
+ }
3156
+ const after = rehash.value;
3157
+ const { git, policy } = splitIntegrityChanges(integrityChanges(before, after), root);
3158
+ if (git.length > 0) {
3159
+ store.logEvent("git_config_modified", pr, { round_id: roundId, changes: git });
3160
+ const message = gitConfigChangeMessage(pr, git);
3161
+ store.logAnomaly("git_config_modified", pr, null, message, message);
3162
+ this.log(`verdicts: #${pr} GIT CONFIG CHANGED DURING THE ROUND`, git);
3163
+ }
3164
+ if (policy.length > 0) {
3165
+ store.logEvent("policy_surface_modified", pr, { round_id: roundId, changes: policy });
3166
+ const message = policySurfaceChangeMessage(pr, policy);
3167
+ store.logAnomaly("policy_surface_modified", pr, null, message, message);
3168
+ this.log(`verdicts: #${pr} POLICY SURFACE CHANGED DURING THE ROUND`, policy);
3169
+ }
3170
+ } catch (err) {
3171
+ this.log(`verdicts: #${pr} round integrity check unavailable`, err);
3172
+ }
3173
+ }
3174
+
3175
+ /** Measure real token usage for the round window from the pi session JSONLs,
3176
+ * estimate USD from config pricing, and store both. Also the post-hoc
3177
+ * exec-policy audit. Best-effort: no failure here disturbs the verdict.
3178
+ *
3179
+ * The transcripts are read once, async and chunked, through the watcher's
3180
+ * `TranscriptIndex` (each file resumed from where the last round end left
3181
+ * it; a session file older than the round not read at all), and the
3182
+ * integrity re-hash is async too, so neither stalls the event loop. An
3183
+ * in-scope transcript that cannot be read, or a read past `budgetMs`,
3184
+ * leaves the usage unknown
3185
+ * (nothing recorded — the columns stay NULL, never a partial sum) with a
3186
+ * `round_usage_unknown` event, and the audit reports what it did read. */
3187
+ private async measureUsage(
3188
+ pr: number,
3189
+ roundId: number,
3190
+ startedAt: string,
3191
+ finishedAt: string,
3192
+ integrity: "check" | "interrupted" = "check",
3193
+ ): Promise<boolean> {
3194
+ const { cfg, store } = this.deps;
3195
+ let persisted = false;
3196
+ try {
3197
+ const bounded = await withinBudget(
3198
+ this.transcripts.measure({
3199
+ sessionDir: this.sessionDirFor(pr),
3200
+ startedAt,
3201
+ finishedAt,
3202
+ }),
3203
+ this.usageBudgetMs(),
3204
+ );
3205
+ const measured = bounded.kind === "done" ? bounded.value : "budget";
3206
+ const usage = measured === "budget" ? null : measured.usage;
3207
+ if (usage !== null) {
3208
+ // pi's own per-record usage.cost.total is the primary source (verified
3209
+ // live on the VM); the config price table is only the fallback for
3210
+ // windows whose records carried no cost object.
3211
+ const pricing: Record<string, ModelPricing> =
3212
+ this.deps.usage?.pricing ?? cfg.models.pricing ?? {};
3213
+ // The worker's own total (session, in-process lenses, warm-ups) when it closed
3214
+ // its report; the transcript's figure for a crashed or re-run worker and for
3215
+ // the tmux routes, which report nothing.
3216
+ const reported = store.workerReportedUsd(roundId);
3217
+ const est = reported ?? usage.cost_usd ?? estimateUsd(usage.models, pricing);
3218
+ store.recordRoundUsage(roundId, {
3219
+ tokens_in: usage.tokens_in,
3220
+ tokens_out: usage.tokens_out,
3221
+ tokens_cache_read: usage.tokens_cache_read,
3222
+ est_usd: est,
3223
+ });
3224
+ this.log(
3225
+ `verdicts: #${pr} round usage ${usage.tokens_in}in/${usage.tokens_out}out/${usage.tokens_cache_read}cache` +
3226
+ ` est=${est === null ? "unpriced" : `$${est.toFixed(2)}`}${usage.passes_included ? " (+passes)" : ""}${usage.children_included ? " (+children)" : ""}`,
3227
+ );
3228
+ } else if (measured === "budget") {
3229
+ store.logEvent("round_usage_unknown", pr, {
3230
+ round_id: roundId,
3231
+ reason: "budget",
3232
+ budget_ms: this.usageBudgetMs(),
3233
+ audit: "incomplete",
3234
+ });
3235
+ this.log(
3236
+ `verdicts: #${pr} round usage UNKNOWN — the transcripts were not read within ${this.usageBudgetMs()} ms; nothing recorded, the exec-policy audit did not run`,
3237
+ );
3238
+ } else {
3239
+ store.logEvent("round_usage_unknown", pr, {
3240
+ round_id: roundId,
3241
+ reason: "unreadable",
3242
+ unreadable: measured.unreadable.slice(0, UNREADABLE_LISTED),
3243
+ unreadable_count: measured.unreadable.length,
3244
+ audit: "incomplete",
3245
+ });
3246
+ this.log(
3247
+ `verdicts: #${pr} round usage UNKNOWN — ${measured.unreadable.length} transcript path(s) unreadable; nothing recorded, the exec-policy audit covers only what was read`,
3248
+ measured.unreadable.slice(0, UNREADABLE_LISTED),
3249
+ );
3250
+ }
3251
+
3252
+ persisted = true;
3253
+ // Classified, so the durable event says which policy was crossed.
3254
+ if (integrity === "check") await this.checkRoundIntegrity(pr, roundId);
3255
+ else this.integrityInterrupted(pr, roundId);
3256
+
3257
+ const violations = measured === "budget" ? [] : measured.violations;
3258
+ if (violations.length > 0) {
3259
+ const commands = violations.map((v) => v.command);
3260
+ store.logEvent("exec_policy_violation", pr, { commands, violations });
3261
+ this.execViolations.set(
3262
+ pr,
3263
+ violations.map((v) => `${v.kind}: ${v.command}`),
3264
+ );
3265
+ this.log(`verdicts: #${pr} exec-policy violations`, violations);
3266
+ }
3267
+ } catch (err) {
3268
+ this.log(`verdicts: usage measurement failed for #${pr}`, err);
3269
+ }
3270
+ return persisted;
3271
+ }
3272
+
3273
+ /**
3274
+ * A round end fixes its usage window and owes the measurement. The reads
3275
+ * can take minutes, and a deploy's drain sees `running_rounds == 0` the
3276
+ * moment the round row is terminal, so the obligation must be durable: a
3277
+ * restart mid-read would otherwise leave NULL tokens with no
3278
+ * `round_usage_unknown`, reading as "never measured" rather than "unknown".
3279
+ * `rounds.usage_owed_to` carries the window's end from the same synchronous
3280
+ * turn as the round end's terminal write until the measurement has
3281
+ * finished, whatever it found, and the next start resumes what is still owed
3282
+ * (`resumeOwedMeasurements`). The durable write must succeed before a
3283
+ * terminal replay marker is published; a failure leaves apply replayable.
3284
+ */
3285
+ private oweMeasurement(
3286
+ round: Pick<RoundRow, "id" | "pr" | "round_no" | "started_at">,
3287
+ /** The window's end when it is not now: the round's terminal write
3288
+ * landed turns earlier (a verdict's apply stamps `finished_at` at its
3289
+ * claim and owes the measurement only once every durable step landed). */
3290
+ endedAt?: string | null,
3291
+ ): OwedMeasurement {
3292
+ const measuredTo = endedAt ?? this.now().toISOString();
3293
+ this.deps.store.oweRoundMeasurement(round.id, measuredTo);
3294
+ this.measuring.add(round.id);
3295
+ return {
3296
+ pr: round.pr,
3297
+ roundId: round.id,
3298
+ roundNo: round.round_no,
3299
+ startedAt: round.started_at,
3300
+ measuredTo,
3301
+ };
3302
+ }
3303
+
3304
+ /** Run an owed measurement now (`inline`) or hand it to the background. */
3305
+ private async closeMeasurement(owed: OwedMeasurement, mode: MeasureMode): Promise<void> {
3306
+ if (mode === "background") {
3307
+ this.detach(
3308
+ () => this.runMeasurement(owed, "check"),
3309
+ `measurement of #${owed.pr} round ${owed.roundNo}`,
3310
+ );
3311
+ return;
3312
+ }
3313
+ await this.runMeasurement(owed, "check");
3314
+ }
3315
+
3316
+ /** The measurement, then the obligation cleared — whatever it found
3317
+ * (`measureUsage` explicitly reports that usage or unknown was persisted). */
3318
+ private async runMeasurement(
3319
+ owed: OwedMeasurement,
3320
+ integrity: "check" | "interrupted",
3321
+ ): Promise<void> {
3322
+ try {
3323
+ const persisted = await this.measureUsage(
3324
+ owed.pr,
3325
+ owed.roundId,
3326
+ owed.startedAt,
3327
+ owed.measuredTo,
3328
+ integrity,
3329
+ );
3330
+ if (persisted) this.deps.store.settleRoundMeasurement(owed.roundId);
3331
+ } catch (err) {
3332
+ this.log(
3333
+ `verdicts: #${owed.pr} round ${owed.roundNo} — could not settle its owed measurement`,
3334
+ err,
3335
+ );
3336
+ } finally {
3337
+ this.measuring.delete(owed.roundId);
3338
+ }
3339
+ }
3340
+
3341
+ private detach(fn: () => Promise<void>, what: string): void {
3342
+ const job: Promise<void> = fn()
3343
+ .catch((err: unknown) => this.log(`verdicts: ${what} failed`, err))
3344
+ .finally(() => this.background.delete(job));
3345
+ this.background.add(job);
3346
+ }
3347
+
3348
+ /**
3349
+ * Measurements an earlier process owed and did not finish — a restart,
3350
+ * usually a deploy, cut them short. Once
3351
+ * per watcher, from its first sweep; this process's own are in `measuring`.
3352
+ *
3353
+ * Each is measured again over its stored window, in the background: the
3354
+ * transcripts are append-only and the window's end is fixed, so the answer
3355
+ * is the one the interrupted read would have given. The integrity re-check
3356
+ * is not re-run — a deploy rewrites dist/ and scripts/, the policy surface,
3357
+ * so a re-hash after one would blame the round for the deploy; the round is
3358
+ * recorded unchecked (`round_integrity_unchecked`, `interrupted`) instead.
3359
+ * A window older than `OWED_MEASUREMENT_RESUME_MAX_MS` is not re-read at
3360
+ * all — the pi-global archiver may have moved its store files since, and a
3361
+ * partial sum must never read as the round's cost — so its usage, if it was
3362
+ * not recorded before the restart, is recorded unknown.
3363
+ */
3364
+ private resumeOwedMeasurements(): void {
3365
+ if (this.owedResumed) return;
3366
+ const { store } = this.deps;
3367
+ const owed = store.owedRoundMeasurements();
3368
+ this.owedResumed = true;
3369
+ for (const round of owed) {
3370
+ try {
3371
+ this.resumeOwedMeasurement(round);
3372
+ } catch (err) {
3373
+ this.log(
3374
+ `verdicts: resuming #${round.pr} round ${round.round_no}'s measurement failed`,
3375
+ err,
3376
+ );
3377
+ }
3378
+ }
3379
+ }
3380
+
3381
+ private resumeOwedMeasurement(round: RoundRow): void {
3382
+ const { store } = this.deps;
3383
+ if (round.usage_owed_to === null || this.measuring.has(round.id)) return;
3384
+ const job: OwedMeasurement = {
3385
+ pr: round.pr,
3386
+ roundId: round.id,
3387
+ roundNo: round.round_no,
3388
+ startedAt: round.started_at,
3389
+ measuredTo: round.usage_owed_to,
3390
+ };
3391
+ const age = this.now().getTime() - Date.parse(job.measuredTo);
3392
+ if (!Number.isFinite(age) || age > OWED_MEASUREMENT_RESUME_MAX_MS) {
3393
+ if (round.tokens_in === null) {
3394
+ store.logEvent("round_usage_unknown", round.pr, {
3395
+ round_id: round.id,
3396
+ reason: "interrupted",
3397
+ measured_to: job.measuredTo,
3398
+ audit: "incomplete",
3399
+ });
3400
+ }
3401
+ this.integrityInterrupted(round.pr, round.id);
3402
+ store.settleRoundMeasurement(round.id);
3403
+ this.log(
3404
+ `verdicts: #${round.pr} round ${round.round_no} — its measurement was cut short by a restart and its window (to ${job.measuredTo}) is too old to re-read; ${round.tokens_in === null ? "usage UNKNOWN" : "usage as recorded"}, audit incomplete`,
3405
+ );
3406
+ return;
3407
+ }
3408
+ this.measuring.add(round.id);
3409
+ store.logEvent("round_usage_resumed", round.pr, {
3410
+ round_id: round.id,
3411
+ round_no: round.round_no,
3412
+ started_at: job.startedAt,
3413
+ measured_to: job.measuredTo,
3414
+ });
3415
+ this.log(
3416
+ `verdicts: #${round.pr} round ${round.round_no} — resuming the measurement a restart cut short (window to ${job.measuredTo})`,
3417
+ );
3418
+ this.detach(
3419
+ () => this.runMeasurement(job, "interrupted"),
3420
+ `resumed measurement of #${round.pr} round ${round.round_no}`,
3421
+ );
3422
+ }
3423
+
3424
+ /** The round's integrity baseline, when dispatch took one that can be
3425
+ * judged against; undefined otherwise — such a round is never judged. */
3426
+ private integrityBaseline(roundId: number): IntegrityFingerprint | undefined {
3427
+ const before = this.deps.store.roundGitConfig(roundId) as IntegrityFingerprint | undefined;
3428
+ return before && typeof before.digest === "string" ? before : undefined;
3429
+ }
3430
+
3431
+ /** The round-end integrity re-check did not run for this round because a
3432
+ * restart cut its measurement short — recorded, never read as "unchanged".
3433
+ * Only a round with a baseline could have been judged at all. */
3434
+ private integrityInterrupted(pr: number, roundId: number): void {
3435
+ if (!this.integrityBaseline(roundId)) return;
3436
+ this.deps.store.logEvent("round_integrity_unchecked", pr, {
3437
+ round_id: roundId,
3438
+ stage: "round_end",
3439
+ reason: "interrupted",
3440
+ });
3441
+ }
3442
+
3443
+ /** How long a round end waits for its transcripts. A measure cut by it
3444
+ * goes on in the background (its reads fill the index for the next round
3445
+ * end); its late answer is dropped. */
3446
+ private usageBudgetMs(): number {
3447
+ return Math.max(0, this.deps.usage?.budgetMs ?? USAGE_BUDGET_MS);
3448
+ }
3449
+
3450
+ /** Malformed verdict or blown deadline: inbox anomaly, round marked, the
3451
+ * PR's lifecycle returned to its standing-verdict state (never an approve,
3452
+ * never a park), and the review still OWED as a FAILED_LOUD incident
3453
+ * (`openReviewFailure`) that holds the PR out of the pump — and its held
3454
+ * wakes behind it — until the operator presses `u`.
3455
+ *
3456
+ * Only a round still running owes that: every caller awaited something
3457
+ * since it read the round (a liveness probe, a posting proof, a requeue
3458
+ * that lost its cancel), and a round a verdict, a receipt settlement or an
3459
+ * operator ended meanwhile is left as it stands — no transition, no
3460
+ * incident over whatever replaced it. The read, the mark, the transition
3461
+ * and the incident land in one turn, before the first await. */
3462
+ private async handleAnomaly(
3463
+ pr: number,
3464
+ roundNo: number | null,
3465
+ message: string,
3466
+ kind: "malformed" | "overdue",
3467
+ ): Promise<void> {
3468
+ const { cfg, store, adapter } = this.deps;
3469
+ const round = this.runningRound(pr, roundNo ?? undefined);
3470
+ if (round && round.status !== "running") {
3471
+ store.logEvent("anomaly_round_ended", pr, {
3472
+ round_id: round.id,
3473
+ round_no: round.round_no,
3474
+ round_status: round.status,
3475
+ kind,
3476
+ message,
3477
+ });
3478
+ this.log(
3479
+ `verdicts: #${pr} round ${round.round_no} is already ${round.status} — the ${kind} anomaly opens nothing over it`,
3480
+ );
3481
+ return;
3482
+ }
3483
+ let owed: OwedMeasurement | null = null;
3484
+ if (round) {
3485
+ store.markRoundOverdue(round.id);
3486
+ // Measure the round's tokens on this path too, not only on the verdict
3487
+ // path: `markRoundOverdue` deliberately keeps the row in spend, and the
3488
+ // stranded-round operator message says "Its spend still counts." — an
3489
+ // unmeasured overdue or malformed round would contribute zero to every
3490
+ // aggregate. The window ends now, which is the `finished_at`
3491
+ // markRoundOverdue just stamped. The reads are async (seconds, at most
3492
+ // the usage budget), so they run below, after the transition and the
3493
+ // FAILED_LOUD incident — a restart while they run must never leave an
3494
+ // overdue round under a PR still `reviewing` with nothing owed — and
3495
+ // before the anomaly, whose inbox item carries the violations they
3496
+ // find. Owed durably from here; awaited on every path, the deadline
3497
+ // tick's included, because that inject reads what it finds.
3498
+ owed = this.oweMeasurement(round);
3499
+ }
3500
+
3501
+ const standing = standingVerdict(store.reviewedHead(pr));
3502
+ applyEvent(
3503
+ store,
3504
+ pr,
3505
+ { type: "round_overdue", standing },
3506
+ {},
3507
+ { botLogin: botAccountLogin(cfg.github), now: () => this.now(), reason: `anomaly:${kind}` },
3508
+ );
3509
+ const command = round ? store.sessionCommandForRound(pr, round.round_no) : undefined;
3510
+ const successor = store.successorFor(pr);
3511
+ const failure = store.openReviewFailure({
3512
+ pr,
3513
+ command_id: command?.id ?? null,
3514
+ round_id: round?.id ?? null,
3515
+ round_no: roundNo,
3516
+ head_sha: successor?.head_sha ?? store.getPr(pr)?.head_sha ?? "",
3517
+ stage: kind === "overdue" ? "round_deadline" : "malformed_verdict",
3518
+ detail: message,
3519
+ attempts: command?.attempt ?? 1,
3520
+ retry_cap: cfg.daemon.max_self_requeues ?? DEFAULT_MAX_SELF_REQUEUES,
3521
+ });
3522
+ store.logEvent(kind === "overdue" ? "round_overdue" : "verdict_malformed", pr, {
3523
+ round_id: round?.id ?? null,
3524
+ round_no: roundNo,
3525
+ message,
3526
+ });
3527
+ // The in-progress label must not stick, but the "Automatic Review" label is
3528
+ // deliberately not applied — no review was soundly produced.
3529
+ await this.safe(
3530
+ () => adapter.removeLabel(pr, cfg.github.in_progress_label),
3531
+ `remove ${cfg.github.in_progress_label} on #${pr}`,
3532
+ );
3533
+ if (owed) await this.closeMeasurement(owed, "inline");
3534
+ // Piggyback pending exec-policy findings on a wake that happens anyway;
3535
+ // violations by themselves never trigger an inject (no spam).
3536
+ const pendingViolations = this.execViolations.get(pr);
3537
+ this.execViolations.delete(pr);
3538
+ // The durable anomaly record — and the one wake funnel: both kinds here
3539
+ // are WAKE classes, so the store's sink writes the
3540
+ // inbox item (dedup'd per kind+pr) from the message passed along.
3541
+ store.logAnomaly(
3542
+ kind,
3543
+ pr,
3544
+ roundNo,
3545
+ message,
3546
+ [
3547
+ `ANOMALY (${kind}) — PR #${pr}${roundNo !== null ? ` round ${roundNo}` : ""}`,
3548
+ message,
3549
+ `No review was produced: work remains owed as ${failure.incident_id} (never an approve). No reviewer was requested on GitHub.`,
3550
+ "Press u on the board to clear the FAILED_LOUD hold and rerun, or restart the session deliberately.",
3551
+ ...(pendingViolations && pendingViolations.length > 0
3552
+ ? [
3553
+ `Also: exec-policy violations were audited on this PR — ${pendingViolations.join(" · ")}`,
3554
+ ]
3555
+ : []),
3556
+ ].join("\n"),
3557
+ );
3558
+ await this.announceSafe((a) =>
3559
+ a.anomaly({
3560
+ pr,
3561
+ round_no: roundNo,
3562
+ kind,
3563
+ deadline_min: cfg.daemon.round_deadline_min,
3564
+ incident_id: failure.incident_id,
3565
+ }),
3566
+ );
3567
+ // FAILED_LOUD review debt owns the session ordering until an operator
3568
+ // presses u. Held conversation cannot overtake work that is still owed.
3569
+ this.log(`verdicts: anomaly (${kind}) on #${pr}`, message);
3570
+ }
3571
+
3572
+ /**
3573
+ * Deadline-time recovery for a round whose prompt provably never arrived:
3574
+ * re-deliver the order and give the round a fresh deadline.
3575
+ *
3576
+ * The extension is what makes this a recovery rather than a formality — a
3577
+ * round already past its deadline would otherwise be re-judged on the very
3578
+ * next tick, before the session could possibly have finished. The clock is
3579
+ * reset from now, because the review has not started yet: none of the
3580
+ * elapsed time was spent reviewing.
3581
+ *
3582
+ * False ⇒ nothing was delivered (budget spent, or no saved prompt), and the
3583
+ * caller escalates.
3584
+ */
3585
+ private async recoverNeverDelivered(round: RoundRow): Promise<boolean> {
3586
+ return (await this.redeliverPrompt(round, "deadline")) === "redelivered";
3587
+ }
3588
+
3589
+ /**
3590
+ * Re-deliver a round's own dispatched order — same round, same session,
3591
+ * the exact bytes — bounded by `max_prompt_redeliveries`. The one
3592
+ * redelivery of both the deadline path and the pre-deadline lost-prompt
3593
+ * net. A delivered order gets a fresh deadline from now, wherever it was
3594
+ * caught: the review has not started, so none of the elapsed time was spent
3595
+ * reviewing, and a prompt caught minutes before its deadline must not be
3596
+ * judged overdue before the session could have finished. The silent mark
3597
+ * is cleared, so the next escalation must be re-earned.
3598
+ *
3599
+ * `unavailable`: no hook, or the budget is spent — nothing attempted.
3600
+ * `failed`: attempted (the attempt counted), nothing delivered.
3601
+ */
3602
+ private async redeliverPrompt(
3603
+ round: RoundRow,
3604
+ at: "deadline" | "before_deadline",
3605
+ ): Promise<"redelivered" | "failed" | "unavailable"> {
3606
+ const { cfg, store } = this.deps;
3607
+ const hooks = this.deps.hooks;
3608
+ const cap = cfg.daemon.max_prompt_redeliveries ?? DEFAULT_MAX_PROMPT_REDELIVERIES;
3609
+ if (!hooks.redeliverRound || round.redeliveries >= cap) return "unavailable";
3610
+ const attempt = store.countRedelivery(round.id);
3611
+ let redelivered = false;
3612
+ try {
3613
+ redelivered = await hooks.redeliverRound.call(hooks, round.pr, round.round_no);
3614
+ } catch (err) {
3615
+ this.log(`verdicts: #${round.pr} round ${round.round_no} redelivery (${at}) failed`, err);
3616
+ return "failed";
3617
+ }
3618
+ if (!redelivered) {
3619
+ this.log(
3620
+ `verdicts: #${round.pr} round ${round.round_no} has no saved prompt to redeliver (${at})`,
3621
+ );
3622
+ return "failed";
3623
+ }
3624
+ store.extendRoundDeadline(round.id, cfg.daemon.round_deadline_min);
3625
+ store.clearRoundSilent(round.id);
3626
+ store.logEvent("round_prompt_redelivered", round.pr, {
3627
+ round_id: round.id,
3628
+ round_no: round.round_no,
3629
+ attempt,
3630
+ cap,
3631
+ at,
3632
+ deadline_extended_min: cfg.daemon.round_deadline_min,
3633
+ });
3634
+ this.log(
3635
+ `verdicts: REDELIVER (${at}) #${round.pr} round ${round.round_no} attempt ${attempt}/${cap} — the prompt never reached the session; same round, same session, nothing cancelled or killed`,
3636
+ );
3637
+ return "redelivered";
3638
+ }
3639
+
3640
+ /**
3641
+ * Deliver wakes that are held with no round left to flush them.
3642
+ *
3643
+ * `flushWakes` runs at a round's settled end — a verdict landing or a
3644
+ * guarded no-op — on the assumption that every round ends through one of
3645
+ * them. Three cancellations do not: `superseded`, a startup orphan that is
3646
+ * not requeued (the requeue needs `state === "reviewing"` and no later
3647
+ * round), and a self-requeue whose successor is cancelled the same way. The
3648
+ * wakes held while such a round was in flight — author replies included —
3649
+ * then sit in `pending_wakes` with nothing to flush them, and that table has
3650
+ * no expiry.
3651
+ *
3652
+ * A PR holding wakes with no round running and no review owed is
3653
+ * unambiguous: the reason to hold is gone, so deliver. A PR whose review
3654
+ * is owed FAILED_LOUD (the anomaly path, an exhausted recovery) keeps them
3655
+ * held — conversation never overtakes owed review work; the operator's `u`
3656
+ * resolves the debt, and the next sweep delivers them. A paused PR holding
3657
+ * only deferred rechecks keeps them too, until the pause lifts. Runs on the
3658
+ * deadline tick, which is already the daemon's "is anything stuck?" sweep.
3659
+ */
3660
+ private async flushStrandedWakes(): Promise<void> {
3661
+ const { store } = this.deps;
3662
+ for (const pr of store.prsWithStrandedWakes()) {
3663
+ if (store.getPr(pr)?.state === "closed") {
3664
+ // The session is archived — there is nothing to wake. Drop them rather
3665
+ // than leave rows nothing will ever read.
3666
+ const dropped = store.dropHeldWakes(pr);
3667
+ if (dropped > 0) store.logEvent("wakes_dropped_closed", pr, { dropped });
3668
+ continue;
3669
+ }
3670
+ // Owed FAILED_LOUD review work keeps its wakes behind it.
3671
+ if (store.openReviewFailure(pr)) continue;
3672
+ if (store.blockingSessionCommand(`pr-${pr}`)) continue;
3673
+ // A takeover holds the session: its release flushes what was parked.
3674
+ if (store.hostLease(pr)) continue;
3675
+ // A paused PR holding only deferred rechecks waits for the resume,
3676
+ // quietly: the next tick after the pause lifts delivers them.
3677
+ if (pauseDefersAllHeldWakes(store, pr)) continue;
3678
+ store.logEvent("wakes_stranded", pr, { held: store.heldWakes(pr).length });
3679
+ this.log(`verdicts: #${pr} held wakes with no round running — flushing`);
3680
+ await this.flushWakes(pr);
3681
+ }
3682
+ }
3683
+
3684
+ /** Deliver the wakes held while this PR's round was in flight. */
3685
+ private async flushWakes(pr: number): Promise<void> {
3686
+ await this.safe(
3687
+ () =>
3688
+ flushHeldWakes(
3689
+ {
3690
+ store: this.deps.store,
3691
+ inject: (n, message) => this.deps.hooks.inject(n, message),
3692
+ log: this.log.bind(this),
3693
+ },
3694
+ pr,
3695
+ ),
3696
+ `flush held wakes on #${pr}`,
3697
+ );
3698
+ }
3699
+
3700
+ /**
3701
+ * Stuck-round triage: is this overdue round one the daemon should requeue
3702
+ * itself? "Stuck" is overdue (the caller already established that) and
3703
+ * session dead (or live with a kill hook) and no verdict file on disk.
3704
+ * - no `sessionAlive` hook or a hook error ⇒ `not_stuck`: the anomaly path
3705
+ * stands.
3706
+ * - session dead but the round's verdict file is sitting unconsumed ⇒
3707
+ * `verdict_pending`: the sweep will consume it — neither a requeue nor an
3708
+ * anomaly may race a real verdict (the restart-orphan/self-requeue
3709
+ * idempotency hinges on never double-owning a round that already decided).
3710
+ */
3711
+ private async stuckDisposition(
3712
+ round: RoundRow,
3713
+ ): Promise<"not_stuck" | "verdict_pending" | "dead" | "alive_stuck" | "never_delivered"> {
3714
+ const alive = this.deps.hooks.sessionAlive;
3715
+ if (!alive) return "not_stuck";
3716
+ let dead: boolean;
3717
+ try {
3718
+ dead = !(await alive.call(this.deps.hooks, round.pr));
3719
+ } catch {
3720
+ return "not_stuck";
3721
+ }
3722
+ if (this.verdictFilePresent(round.pr, round.round_no)) return "verdict_pending";
3723
+ if (dead) return "dead";
3724
+ // A live session that never received the order is not wedged — it is
3725
+ // idle, waiting for work that was lost in transit. Killing it destroys a
3726
+ // healthy conversation to fix a file that failed to arrive, so the receipt
3727
+ // is checked before `alive_stuck` is concluded.
3728
+ if (round.prompt_marker && this.deps.hooks.roundPromptAccepted) {
3729
+ try {
3730
+ const startedMs = Date.parse(round.started_at);
3731
+ const accepted = Number.isFinite(startedMs)
3732
+ ? await this.deps.hooks.roundPromptAccepted.call(
3733
+ this.deps.hooks,
3734
+ round.pr,
3735
+ round.round_no,
3736
+ startedMs,
3737
+ )
3738
+ : null;
3739
+ if (accepted === false) return "never_delivered";
3740
+ } catch {
3741
+ // Unreadable evidence falls through to the judgement below.
3742
+ }
3743
+ }
3744
+ // Live but past the deadline with nothing on disk: recover, don't park —
3745
+ // the session is killed and the round requeued through the same capped
3746
+ // path. Without a kill hook the anomaly path stands — never inject a
3747
+ // fresh round into a wedged live session.
3748
+ return this.deps.hooks.killSession ? "alive_stuck" : "not_stuck";
3749
+ }
3750
+
3751
+ /** Feed command-stage infrastructure failures into the same
3752
+ * capped cancellation→queue lifecycle as overdue/dead rounds. There is no
3753
+ * merits verdict and no GitHub mutation. A transient inability to stop an
3754
+ * active session is retryable; budget exhaustion is distinct durable fact. */
3755
+ async recoverInfrastructureFailure(
3756
+ pr: number,
3757
+ roundNo: number,
3758
+ stage: InfrastructureStage,
3759
+ detail: string,
3760
+ ): Promise<RoundRecoveryDisposition> {
3761
+ const round = this.runningRound(pr, roundNo);
3762
+ if (!round || round.status !== "running") return "requeued";
3763
+ if (round.posting_key) {
3764
+ const answered = await this.receiptAwareStage(round, stage, detail);
3765
+ if (answered) return answered;
3766
+ // The posting proof awaited GitHub: a round an operator or a verdict
3767
+ // ended meanwhile is not recovered — and its session, which may now be
3768
+ // the replacement round's, is not killed.
3769
+ if (!this.stillRunning(round)) return "requeued";
3770
+ }
3771
+ this.deps.store.logEvent("round_infrastructure_failure", pr, {
3772
+ round_id: round.id,
3773
+ round_no: roundNo,
3774
+ stage,
3775
+ detail,
3776
+ });
3777
+ const head = this.deps.store.getPr(pr)?.head_sha ?? "";
3778
+ const cap = this.selfRequeueCap();
3779
+ if (this.selfRequeueSpent(pr, head)) {
3780
+ const mode =
3781
+ stage === "settled_without_verdict" ? "infrastructure_settled" : "infrastructure_active";
3782
+ if (mode === "infrastructure_active" && !this.deps.hooks.killSession) return "retryable";
3783
+ // A session that could not be stopped, or a verdict that won before the
3784
+ // fence, leaves the round to the next check and the sweep.
3785
+ if ((await this.fenceForRecovery(round, mode)) !== "fenced") return "retryable";
3786
+ const command = this.deps.store.sessionCommandForRound(pr, roundNo);
3787
+ if (!command) return "retryable";
3788
+ const successor = this.deps.store.successorFor(pr);
3789
+ const reason = `round ${roundNo} failed a bounded daemon→Pi infrastructure stage before any merits verdict`;
3790
+ const failure = this.deps.store.terminalizeInfrastructureReviewFailure({
3791
+ pr,
3792
+ command_id: command.id,
3793
+ round_id: round.id,
3794
+ round_no: roundNo,
3795
+ head_sha: successor?.head_sha ?? this.deps.store.getPr(pr)?.head_sha ?? head,
3796
+ stage,
3797
+ detail,
3798
+ attempts: cap,
3799
+ retry_cap: cap,
3800
+ summary: `infrastructure recovery exhausted after ${cap} self-requeue(s) — ${reason}`,
3801
+ });
3802
+ if (!failure) {
3803
+ // An operator/verdict/concurrent recovery won the exact terminal CAS.
3804
+ return this.deps.store.reviewFailureForCommand(command.id)?.work_owed === 1
3805
+ ? "exhausted"
3806
+ : "requeued";
3807
+ }
3808
+ // The round's window closes now, and its measurement is owed from now;
3809
+ // it runs after the incident's durable rows, so no await sits between
3810
+ // the terminal CAS and them (the measurement reads its transcripts
3811
+ // async), and in the background: this is the round-stage supervisor's
3812
+ // serialized scan.
3813
+ const owed = this.oweMeasurement(round);
3814
+ this.deps.store.logEvent("self_requeue_cap_exhausted", pr, {
3815
+ round_id: round.id,
3816
+ round_no: roundNo,
3817
+ head_sha: head,
3818
+ attempts: cap,
3819
+ cap,
3820
+ mode,
3821
+ });
3822
+ this.deps.store.logEvent("round_infrastructure_recovery_exhausted", pr, {
3823
+ round_id: round.id,
3824
+ round_no: roundNo,
3825
+ head_sha: head,
3826
+ attempts: cap,
3827
+ mode,
3828
+ });
3829
+ this.deps.store.logAnomaly(
3830
+ "review_failed_loud",
3831
+ pr,
3832
+ roundNo,
3833
+ `${stage}: ${detail}; work remains owed as ${failure.incident_id}; ${
3834
+ failure.next_action === "retry_wait" && failure.next_retry_at
3835
+ ? `automatic retry scheduled for ${failure.next_retry_at} (u retries immediately)`
3836
+ : "press u to recover"
3837
+ }`,
3838
+ );
3839
+ // No round owns the review any more: the in-progress label comes off,
3840
+ // as on every other terminal path.
3841
+ await this.safe(
3842
+ () => this.deps.adapter.removeLabel(pr, this.deps.cfg.github.in_progress_label),
3843
+ `remove ${this.deps.cfg.github.in_progress_label} on #${pr}`,
3844
+ );
3845
+ await this.closeMeasurement(owed, "background");
3846
+ await this.announceSafe((a) =>
3847
+ a.anomaly({
3848
+ pr,
3849
+ round_no: roundNo,
3850
+ kind: "failed_loud",
3851
+ deadline_min: this.deps.cfg.daemon.round_deadline_min,
3852
+ incident_id: failure.incident_id,
3853
+ }),
3854
+ );
3855
+ return "exhausted";
3856
+ }
3857
+ if (stage !== "settled_without_verdict" && !this.deps.hooks.killSession) return "retryable";
3858
+ const recovered = await this.selfRequeue(
3859
+ round,
3860
+ stage === "settled_without_verdict" ? "infrastructure_settled" : "infrastructure_active",
3861
+ this.preFanoutRecovery(round, stage),
3862
+ );
3863
+ return recovered ? "requeued" : "retryable";
3864
+ }
3865
+
3866
+ /**
3867
+ * Every stage limit asks the posting receipt first. The limits are
3868
+ * wall-clock bounds on a session that is supposed to be reviewing; once its
3869
+ * review is confirmed on GitHub, it is not — it is writing its ledger, its
3870
+ * findings record and its verdict file, and on a CPU-starved container that
3871
+ * tail can outlive the finalize grace. Cancelling and requeueing it then
3872
+ * would post a second review on the same head.
3873
+ *
3874
+ * So, for a round whose review is on GitHub (or was refused by guard 2 —
3875
+ * equally final):
3876
+ * - a parent that has settled will write nothing more: settle now;
3877
+ * - otherwise the tail gets `[daemon] round_posted_tail_grace_min` from the
3878
+ * posting — `posted_wait`: nothing is cancelled, killed or requeued, and
3879
+ * a verdict file that lands in that window is applied by the sweep as
3880
+ * usual;
3881
+ * - past it, settle from the receipt when it proves the decision
3882
+ * (`decidedVerdictFromReceipt`), and fail loud (`posted_without_verdict`,
3883
+ * the shape a posted round without a verdict always had) when it does
3884
+ * not. Never a requeue, so never a second posting.
3885
+ * A POST still inside its ambiguous transport window (a `prepared` receipt
3886
+ * within `posting_receipt_grace_s`) is waited out too: killing the poster
3887
+ * there is how a review lands on GitHub with nothing local to say so.
3888
+ *
3889
+ * Before any of that, and whatever the receipt says — receipt or none — a
3890
+ * verdict file already on disk (or one the watcher is still reading) defers
3891
+ * the limit to the sweep: the session has finished, and cancelling the round
3892
+ * under its own verdict would only make that verdict land on a cancelled
3893
+ * round. The deferral is bounded by the sweep, which consumes the file within
3894
+ * its partial-write window (applied, or malformed and archived) every
3895
+ * `VERDICT_SWEEP_S`.
3896
+ *
3897
+ * Returns null for everything else — no receipt, an unreadable or
3898
+ * mismatched one, a legacy round — and the caller's own path runs:
3899
+ * unknown is not evidence of a posting.
3900
+ */
3901
+ private async receiptAwareStage(
3902
+ round: RoundRow,
3903
+ stage: InfrastructureStage,
3904
+ detail: string,
3905
+ ): Promise<RoundRecoveryDisposition | null> {
3906
+ const { store, cfg } = this.deps;
3907
+ // A verdict file on disk (or one the watcher is still reading) is the
3908
+ // sweep's, whatever the receipt says.
3909
+ if (
3910
+ this.verdictFilePresent(round.pr, round.round_no) ||
3911
+ this.inFlight.has(verdictFileName(round.pr, round.round_no))
3912
+ )
3913
+ return "retryable";
3914
+ const proof = await this.postingProof(round);
3915
+ if (proof.kind === "confirmed" || proof.kind === "skipped") {
3916
+ const graceMin =
3917
+ cfg.daemon.round_posted_tail_grace_min ?? DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN;
3918
+ if (
3919
+ stage !== "settled_without_verdict" &&
3920
+ this.inPostedTailGrace(round, proof, stage, detail)
3921
+ )
3922
+ return "posted_wait";
3923
+ const settled = await this.withVerdictSlot(
3924
+ round.pr,
3925
+ round.round_no,
3926
+ async (): Promise<RoundRecoveryDisposition> => {
3927
+ // The verdict may have landed while this awaited GitHub: it wins.
3928
+ if (this.verdictFilePresent(round.pr, round.round_no)) return "retryable";
3929
+ if (proof.kind === "skipped") {
3930
+ // Refused by guard 2 and no verdict file. The round is over
3931
+ // and nothing is owed; the delivered command stays as it is.
3932
+ return (await this.settleGuardedNoop(round, proof.noop!, stage)) === "failed"
3933
+ ? "failed_loud"
3934
+ : "settled";
3935
+ }
3936
+ const receipt = proof.receipt!;
3937
+ const why =
3938
+ stage === "settled_without_verdict"
3939
+ ? detail
3940
+ : `${detail}; no verdict file ${graceMin} min after the review was posted`;
3941
+ const decision = decidedVerdictFromReceipt(receipt);
3942
+ if (decision) {
3943
+ await this.settleFromReceipt(round, receipt, decision, stage, why);
3944
+ return "settled";
3945
+ }
3946
+ // Posted, the session has written nothing, and the receipt does not
3947
+ // say what was decided: loud, as a posted round without a verdict
3948
+ // always was — and never a requeue, which would post a second time.
3949
+ if (!this.stillRunning(round)) return "settled";
3950
+ await this.failPostingProof(
3951
+ round,
3952
+ `GitHub review ${receipt.gh_review_id ?? "confirmed"} was posted (${receipt.actual_event}) but ${stage === "settled_without_verdict" ? "the session settled before writing its verdict" : `the session wrote no verdict within ${graceMin} min of posting`}, and the posting receipt does not say what was decided`,
3953
+ "posted_without_verdict",
3954
+ "background",
3955
+ );
3956
+ return "failed_loud";
3957
+ },
3958
+ );
3959
+ return settled ?? "retryable";
3960
+ }
3961
+ if (stage === "settled_without_verdict") {
3962
+ // The parent stopped, so an ambiguous receipt gets its grace and then
3963
+ // fails loud — never a requeue that could post a second time. Nothing
3964
+ // went out (`unconfirmed`: no receipt, or GitHub read and no marker) is
3965
+ // the caller's capped requeue, as at every other stage: a session that
3966
+ // stopped on a failed pass, posting nothing, is a retry, not an incident.
3967
+ if (proof.kind === "waiting") return "retryable";
3968
+ // The proof awaited GitHub: a round an operator ended meanwhile is
3969
+ // theirs, as on the fail-loud path below.
3970
+ if (proof.kind === "failed" && proof.failure === "unconfirmed")
3971
+ return this.stillRunning(round) ? null : "settled";
3972
+ if (proof.kind === "failed") {
3973
+ return stageFailure(
3974
+ await this.failPostingProofUnlessVerdict(
3975
+ round,
3976
+ proof.detail ?? "settled without a posting receipt",
3977
+ "posting_receipt",
3978
+ ),
3979
+ );
3980
+ }
3981
+ return null;
3982
+ }
3983
+ if (
3984
+ proof.kind === "waiting" &&
3985
+ (this.postInFlight(round) ||
3986
+ (round.posting_key &&
3987
+ pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key)))
3988
+ ) {
3989
+ store.logEvent("round_stage_posting_in_flight", round.pr, {
3990
+ round_id: round.id,
3991
+ round_no: round.round_no,
3992
+ stage,
3993
+ detail,
3994
+ });
3995
+ return "retryable";
3996
+ }
3997
+ if (proof.kind === "failed" && proof.failure === "ambiguous") {
3998
+ // A POST for exactly this round may have landed — its receipt is still
3999
+ // `prepared` past its grace — and GitHub could not be read for its
4000
+ // marker. A requeue here could post twice; so could failing loud, whose
4001
+ // `u` reruns a round that cannot see the posting either. So the limit
4002
+ // waits for a read that can decide: the next check finds the marker (and
4003
+ // settles from it) or finds none (and requeues). Bounded by the round
4004
+ // deadline, where the same evidence fails loud.
4005
+ if (!this.loggedOnce.has(`ambiguous:${round.id}`)) {
4006
+ this.loggedOnce.add(`ambiguous:${round.id}`);
4007
+ store.logEvent("round_stage_posting_ambiguous", round.pr, {
4008
+ round_id: round.id,
4009
+ round_no: round.round_no,
4010
+ stage,
4011
+ detail,
4012
+ posting_key: round.posting_key,
4013
+ });
4014
+ this.log(
4015
+ `verdicts: #${round.pr} round ${round.round_no} hit ${stage} with a POST whose outcome is unknown (receipt still prepared, GitHub unreadable) — waiting for a read that can decide; nothing cancelled, killed or requeued`,
4016
+ );
4017
+ }
4018
+ return "retryable";
4019
+ }
4020
+ if (
4021
+ proof.kind === "failed" &&
4022
+ (proof.failure === "contradicted" || proof.failure === "exhausted")
4023
+ ) {
4024
+ // Evidence of a posting that disagrees with the round (conflicting
4025
+ // linkage, several matching reviews): a requeue could only add another.
4026
+ // Loud, as the verdict and deadline paths are on the same evidence.
4027
+ return stageFailure(
4028
+ await this.failPostingProofUnlessVerdict(
4029
+ round,
4030
+ proof.detail ?? "posting receipt contradicted",
4031
+ "posting_receipt",
4032
+ ),
4033
+ );
4034
+ }
4035
+ // Nothing says a review went out (no receipt, or GitHub read and no
4036
+ // marker): the caller's own path.
4037
+ return null;
4038
+ }
4039
+
4040
+ /** Is this the same round, still running? A fresh read, for the paths that
4041
+ * awaited GitHub since they found it. */
4042
+ private stillRunning(round: RoundRow): boolean {
4043
+ const fresh = this.runningRound(round.pr, round.round_no);
4044
+ return fresh !== undefined && fresh.id === round.id && fresh.status === "running";
4045
+ }
4046
+
4047
+ /**
4048
+ * Inside the posted-tail grace: the round's review is on GitHub (or guard
4049
+ * 2 refused it), and the session is still writing its ledger, findings
4050
+ * record and verdict file — not wedged. True while `now` is before the
4051
+ * posting + `[daemon] round_posted_tail_grace_min`, logged once per round
4052
+ * and limit. Shared by the stage limits and the round deadline, so neither
4053
+ * settles a round (and lets the supervisor kill the session) mid-tail.
4054
+ */
4055
+ private inPostedTailGrace(
4056
+ round: RoundRow,
4057
+ proof: { kind: string; receipt?: PostingReceipt; noop?: GuardedNoopReceipt },
4058
+ limit: InfrastructureStage | "round_deadline",
4059
+ detail: string,
4060
+ ): boolean {
4061
+ const postedAt =
4062
+ proof.kind === "confirmed"
4063
+ ? (proof.receipt?.confirmed_at ?? proof.receipt?.prepared_at)
4064
+ : proof.noop?.skipped_at;
4065
+ if (postedAt === undefined) return false;
4066
+ const graceMin =
4067
+ this.deps.cfg.daemon.round_posted_tail_grace_min ?? DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN;
4068
+ const until = Date.parse(postedAt) + graceMin * 60_000;
4069
+ if (!Number.isFinite(until) || this.now().getTime() >= until) return false;
4070
+ const key = `${limit === "round_deadline" ? "deadline" : "stage"}:${round.id}`;
4071
+ if (!this.loggedOnce.has(key)) {
4072
+ this.loggedOnce.add(key);
4073
+ this.deps.store.logEvent("round_stage_posted_wait", round.pr, {
4074
+ round_id: round.id,
4075
+ round_no: round.round_no,
4076
+ stage: limit,
4077
+ detail,
4078
+ posting: proof.kind,
4079
+ posted_at: postedAt,
4080
+ wait_until: new Date(until).toISOString(),
4081
+ });
4082
+ this.log(
4083
+ `verdicts: #${round.pr} round ${round.round_no} hit ${limit} after its review was ${proof.kind === "confirmed" ? "posted" : "refused by guard 2"} — ` +
4084
+ `not a failure; the session's tail has until ${new Date(until).toISOString()}, nothing cancelled or killed`,
4085
+ );
4086
+ }
4087
+ return true;
4088
+ }
4089
+
4090
+ /** A `prepared` receipt for exactly this round: a POST whose outcome is not
4091
+ * known yet. */
4092
+ private postInFlight(round: RoundRow): boolean {
4093
+ const receipt = readPostingReceipt(this.postingReceiptDir(), round.pr, round.round_no);
4094
+ return (
4095
+ receipt !== undefined &&
4096
+ receipt.status === "prepared" &&
4097
+ receipt.posting_key === round.posting_key &&
4098
+ receipt.pr === round.pr &&
4099
+ receipt.round_no === round.round_no
4100
+ );
4101
+ }
4102
+
4103
+ /** Only exact command-correlated pre-fanout failures get a changed-behavior
4104
+ * successor instruction. The retry epoch follows an automatic predecessor;
4105
+ * ordinary fresh work naturally starts again at epoch zero. */
4106
+ private preFanoutRecovery(
4107
+ round: RoundRow,
4108
+ stage: InfrastructureStage,
4109
+ ): { stage: "first_pass_start"; source_command_id: string; retry_epoch: number } | undefined {
4110
+ if (stage !== "first_pass_start") return undefined;
4111
+ const command = this.deps.store.sessionCommandForRound(round.pr, round.round_no);
4112
+ if (!command) return undefined;
4113
+ return {
4114
+ stage,
4115
+ source_command_id: command.id,
4116
+ retry_epoch:
4117
+ this.deps.store.roundRecoveryInstruction(round.pr, round.round_no)?.retry_epoch ?? 0,
4118
+ };
4119
+ }
4120
+
4121
+ /**
4122
+ * Whether this round's review already went out: a posting the caller proved
4123
+ * from GitHub (`proven`), or a posting receipt other than a guard-2 refusal
4124
+ * — confirmed, or still owned by the daemon's delivery. A round whose
4125
+ * review went out is never redone, whatever else is wrong with it: a redo
4126
+ * would post a second review. The evidence's name (`proven` or the
4127
+ * receipt's status), or undefined when nothing went out.
4128
+ */
4129
+ private reviewWentOut(pr: number, roundNo: number, proven: boolean): string | undefined {
4130
+ if (proven) return "proven";
4131
+ const receipt = readPostingReceipt(this.postingReceiptDir(), pr, roundNo);
4132
+ return receipt !== undefined && receipt.status !== "skipped" ? receipt.status : undefined;
4133
+ }
4134
+
4135
+ /**
4136
+ * The verdict-time belt for required passes (src/requiredpasses.ts). Every
4137
+ * failed pass of the round is recorded (`round_pass_failures`) — the gap is
4138
+ * internal, never on the PR. When a required pass is missing and nothing
4139
+ * was posted for the round, the round is cancelled and requeued within
4140
+ * `max_self_requeues` (true: the caller returns); past the cap it ends on
4141
+ * the anomaly path, never with a verdict applied over the gap. A gap whose
4142
+ * review already went out (`reviewWentOut`) is logged and applied as
4143
+ * usual — a requeue would post a second review. Unknown evidence never requeues.
4144
+ */
4145
+ private async requeueOnRequiredPassGap(
4146
+ v: VerdictFile,
4147
+ round: RoundRow,
4148
+ proven: boolean,
4149
+ ): Promise<boolean> {
4150
+ const { cfg, store, adapter } = this.deps;
4151
+ const progress = readRoundProgress(this.deps.root ?? sandboxRoot(), v.pr);
4152
+ const failures = failedPasses(progress, v.round_no);
4153
+ if (failures.length > 0)
4154
+ store.logEvent("round_pass_failures", v.pr, {
4155
+ round_id: round.id,
4156
+ round_no: v.round_no,
4157
+ failed: failures,
4158
+ });
4159
+ const gap = requiredPassGap(
4160
+ parsePlan(round.pass_plan),
4161
+ progress,
4162
+ v.round_no,
4163
+ parsePlan(round.sweep_plan),
4164
+ );
4165
+ if (gap.state !== "missing") return false;
4166
+ const missing = describeGap(gap.missing);
4167
+ const posted = this.reviewWentOut(v.pr, v.round_no, proven);
4168
+ if (posted) {
4169
+ store.logEvent("required_pass_missing_after_post", v.pr, {
4170
+ round_id: round.id,
4171
+ round_no: v.round_no,
4172
+ receipt: posted,
4173
+ missing,
4174
+ });
4175
+ return false;
4176
+ }
4177
+ store.logEvent("round_required_pass_missing", v.pr, {
4178
+ round_id: round.id,
4179
+ round_no: v.round_no,
4180
+ head_sha: v.head_sha,
4181
+ missing,
4182
+ });
4183
+ if (await this.selfRequeue(round, "required_pass_missing")) {
4184
+ await this.safe(
4185
+ () => adapter.removeLabel(v.pr, cfg.github.in_progress_label),
4186
+ `remove ${cfg.github.in_progress_label} on #${v.pr}`,
4187
+ );
4188
+ this.log(
4189
+ `verdicts: #${v.pr} round ${v.round_no} missing required pass(es) ${missing} — requeued, nothing posted`,
4190
+ );
4191
+ return true;
4192
+ }
4193
+ await this.handleAnomaly(
4194
+ v.pr,
4195
+ v.round_no,
4196
+ `round ${v.round_no} is missing required pass(es) ${missing} and could not be self-requeued — cap exhausted or no running round; nothing was posted`,
4197
+ "overdue",
4198
+ );
4199
+ return true;
4200
+ }
4201
+
4202
+ /**
4203
+ * Stop a round's session before recovering it, then look for a verdict
4204
+ * that won while it was being stopped — the one fencing protocol the
4205
+ * self-requeue and the exhausted infrastructure recovery share. A wedged
4206
+ * (`alive_stuck`) or active-infrastructure session is killed; any other
4207
+ * has nothing left to stop. After a kill the read is definitive: no verdict
4208
+ * can appear once the session is gone; a settled parent's writes are over.
4209
+ * - `kill_failed`: the session could not be stopped (logged) — nothing
4210
+ * may be recovered over a session that may still write;
4211
+ * - `verdict_won`: a verdict file is on disk (logged) — it is the sweep's;
4212
+ * - `posting_won`: posting or its durable retry won; the ordinary receipt
4213
+ * settlement sweep owns it, never a replacement review;
4214
+ * - `fenced`: recover.
4215
+ */
4216
+ private async fenceForRecovery(
4217
+ round: RoundRow,
4218
+ mode: SelfRequeueMode,
4219
+ ): Promise<"kill_failed" | "verdict_won" | "posting_won" | "fenced"> {
4220
+ const { store } = this.deps;
4221
+ const kill = this.deps.hooks.killSession;
4222
+ if ((mode === "alive_stuck" || mode === "infrastructure_active") && kill) {
4223
+ try {
4224
+ await kill.call(this.deps.hooks, round.pr);
4225
+ } catch (error) {
4226
+ store.logEvent("session_recovery_kill_failed", round.pr, {
4227
+ round_id: round.id,
4228
+ round_no: round.round_no,
4229
+ mode,
4230
+ error: String(error),
4231
+ });
4232
+ return "kill_failed";
4233
+ }
4234
+ }
4235
+ const fencedModes: SelfRequeueMode[] = [
4236
+ "alive_stuck",
4237
+ "infrastructure_active",
4238
+ "infrastructure_settled",
4239
+ ];
4240
+ if (fencedModes.includes(mode) && this.verdictFilePresent(round.pr, round.round_no)) {
4241
+ store.logEvent("round_stage_verdict_won", round.pr, {
4242
+ round_id: round.id,
4243
+ round_no: round.round_no,
4244
+ mode,
4245
+ });
4246
+ return "verdict_won";
4247
+ }
4248
+ if (fencedModes.includes(mode) && round.posting_key) {
4249
+ const proof = await this.postingProof(round);
4250
+ if (
4251
+ proof.kind === "confirmed" ||
4252
+ proof.kind === "skipped" ||
4253
+ (proof.kind === "waiting" &&
4254
+ (this.postInFlight(round) ||
4255
+ pendingPosting(
4256
+ this.postingReceiptDir(),
4257
+ round.pr,
4258
+ round.round_no,
4259
+ round.posting_key,
4260
+ ))) ||
4261
+ (proof.kind === "failed" &&
4262
+ (proof.failure === "ambiguous" ||
4263
+ proof.failure === "contradicted" ||
4264
+ proof.failure === "exhausted"))
4265
+ ) {
4266
+ store.logEvent("round_stage_posting_won", round.pr, {
4267
+ round_id: round.id,
4268
+ round_no: round.round_no,
4269
+ mode,
4270
+ proof: proof.kind,
4271
+ });
4272
+ return "posting_won";
4273
+ }
4274
+ }
4275
+ return "fenced";
4276
+ }
4277
+
4278
+ /**
4279
+ * Self-requeue a stuck round. Returns true when the round was handled here
4280
+ * (cancelled + PR re-queued for a fresh dispatch through the normal pump —
4281
+ * same move as the overseer's rerun-round and the startup orphan requeue);
4282
+ * false when the per-head cap is exhausted (or the old session could not be
4283
+ * stopped) and the caller must take the anomaly path instead.
4284
+ *
4285
+ * Session-side only by construction: no label, no review, no gate call —
4286
+ * dry-run and live behave identically. The cancelled round keeps a NULL
4287
+ * verdict, so `priorRoundCount`/review_no never age (a failed round must
4288
+ * not age the PR). Held wakes stay held: the requeued round is about to
4289
+ * dispatch, and its own round-end paths flush them.
4290
+ */
4291
+ private selfRequeueCap(): number {
4292
+ return this.deps.cfg.daemon.max_self_requeues ?? DEFAULT_MAX_SELF_REQUEUES;
4293
+ }
4294
+
4295
+ /** True when the per-head self-requeue budget has no attempt left. */
4296
+ private selfRequeueSpent(pr: number, head: string): boolean {
4297
+ return this.deps.store.selfRequeueCount(pr, head) >= this.selfRequeueCap();
4298
+ }
4299
+
4300
+ private async selfRequeue(
4301
+ round: RoundRow,
4302
+ mode: SelfRequeueMode = "dead",
4303
+ recovery?: { stage: "first_pass_start"; source_command_id: string; retry_epoch: number },
4304
+ ): Promise<boolean> {
4305
+ const { store } = this.deps;
4306
+ const row = store.getPr(round.pr);
4307
+ const head = row?.head_sha ?? "";
4308
+ const cap = this.selfRequeueCap();
4309
+ const attempt = store.selfRequeueCount(round.pr, head) + 1;
4310
+ const exhausted = this.selfRequeueSpent(round.pr, head);
4311
+ const infrastructure = mode === "infrastructure_active" || mode === "infrastructure_settled";
4312
+ if (exhausted) {
4313
+ store.logEvent("self_requeue_cap_exhausted", round.pr, {
4314
+ round_id: round.id,
4315
+ round_no: round.round_no,
4316
+ head_sha: head,
4317
+ attempts: attempt - 1,
4318
+ cap,
4319
+ mode,
4320
+ });
4321
+ return false;
4322
+ }
4323
+ const reason =
4324
+ mode === "dead"
4325
+ ? `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict and its session is dead`
4326
+ : mode === "alive_stuck"
4327
+ ? `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict; its session was alive but wedged and has been killed for a fresh rebirth`
4328
+ : mode === "lost_prompt"
4329
+ ? `round ${round.round_no}'s prompt left the inject dir but its receipt never appeared in the session transcript since the round started (${round.started_at}) — the prompt was dropped in delivery and redelivery is spent or impossible; redispatching`
4330
+ : mode === "required_pass_missing"
4331
+ ? `round ${round.round_no} ended with a required pass (pass-baseline or a risk lens) failed and never completed — the session posted nothing; redispatching`
4332
+ : mode === "head_moved"
4333
+ ? `round ${round.round_no} ended head_moved (guard 5): the PR advanced mid-round and the session correctly posted nothing — redispatching against the fresh head; the session is healthy and was not killed`
4334
+ : mode === "infrastructure_active" || mode === "infrastructure_settled"
4335
+ ? `round ${round.round_no} failed a bounded daemon→Pi infrastructure stage before any merits verdict — redispatching through the ordinary pump`
4336
+ : `round ${round.round_no} verdicted NEEDS_HUMAN without executing any passes — the review was not done, so it is redone rather than handed to a person`;
4337
+ if (mode === "infrastructure_active" && !this.deps.hooks.killSession) {
4338
+ store.logEvent("session_recovery_kill_unavailable", round.pr, {
4339
+ round_id: round.id,
4340
+ round_no: round.round_no,
4341
+ mode,
4342
+ });
4343
+ return false;
4344
+ }
4345
+ const fence = await this.fenceForRecovery(round, mode);
4346
+ if (fence === "kill_failed") {
4347
+ this.log(
4348
+ `verdicts: refusing ${mode} requeue for #${round.pr}; old session could not be stopped`,
4349
+ );
4350
+ return false;
4351
+ }
4352
+ // The verdict that won the fence is the 10s sweep's, never cancelled or
4353
+ // quarantined here. A wedged session's (`alive_stuck`) is handled — the
4354
+ // deadline path neither requeues nor fails the round; an infrastructure
4355
+ // stage's is not, and the stage is checked again.
4356
+ if (fence === "verdict_won" || fence === "posting_won") return mode === "alive_stuck";
4357
+ if (infrastructure && this.runningRound(round.pr, round.round_no)?.status !== "running")
4358
+ return true;
4359
+ const cause = SELF_REQUEUE_CAUSE[mode];
4360
+ const { publication, owed } = store.db
4361
+ .transaction(() => {
4362
+ const publication = store.cancelRoundAndQueueRecovery({
4363
+ pr: round.pr,
4364
+ round_id: round.id,
4365
+ round_no: round.round_no,
4366
+ summary: `self-requeue ${attempt}/${cap} — ${reason}`,
4367
+ cause,
4368
+ fallback_head_sha: head,
4369
+ trigger_ts: this.now().toISOString(),
4370
+ ...(recovery
4371
+ ? {
4372
+ recovery: {
4373
+ stage: recovery.stage,
4374
+ source_command_id: recovery.source_command_id,
4375
+ immediate_attempt: attempt,
4376
+ retry_epoch: recovery.retry_epoch,
4377
+ },
4378
+ }
4379
+ : {}),
4380
+ });
4381
+ // The cancel and the owed measurement commit together: a kill between
4382
+ // them cannot leave a cancelled round that nothing ever measures.
4383
+ return { publication, owed: publication ? this.oweMeasurement(round) : null };
4384
+ })
4385
+ .immediate();
4386
+ if (!publication || !owed) return false;
4387
+ // A cancelled round burned real tokens and stays in every spend aggregate
4388
+ // (`SUM(est_usd) FROM rounds` has no status filter), so it must be
4389
+ // measured. The window's end is fixed here, and the measurement owed from
4390
+ // here; the successor's window begins later, so the two never overlap.
4391
+ // The reads (async) run after this requeue's durable rows below, so a
4392
+ // successor dispatched while they run never sees the cancel without its
4393
+ // `round_self_requeued` row. From the deadline tick and the round-stage
4394
+ // supervisor they run in the background.
4395
+ if (isLoudCancelCause(cause)) {
4396
+ // The durable twin of the inject below. A round that stopped dead
4397
+ // inside its deadline window is a real defect; the redeploy orphan
4398
+ // sweep writes nothing here. `round_stopped_dead` is deliberately absent
4399
+ // from anomalies.ts WAKE_KINDS: the bespoke SELF-REQUEUE inject a few
4400
+ // lines down is this record's wake, and listing it there would inject
4401
+ // the same event twice.
4402
+ store.logAnomaly(
4403
+ "round_stopped_dead",
4404
+ round.pr,
4405
+ round.round_no,
4406
+ `round ${round.round_no} stopped dead inside its deadline window (${round.started_at} → ${round.deadline_at}) and was cancelled; self-requeue attempt ${attempt}/${cap}`,
4407
+ );
4408
+ }
4409
+ store.logEvent("round_self_requeued", round.pr, {
4410
+ cancel_cause: cause,
4411
+ round_id: round.id,
4412
+ round_no: round.round_no,
4413
+ head_sha: head,
4414
+ attempt,
4415
+ cap,
4416
+ mode,
4417
+ reason,
4418
+ });
4419
+ await this.closeMeasurement(owed, measureModeForRequeue(mode));
4420
+ if (!publication.queued) {
4421
+ store.logEvent("round_self_requeue_terminal", round.pr, {
4422
+ round_id: round.id,
4423
+ round_no: round.round_no,
4424
+ reason: "pr_closed",
4425
+ });
4426
+ return true;
4427
+ }
4428
+ this.log(
4429
+ `verdicts: SELF-REQUEUE #${round.pr} round ${round.round_no} attempt ${attempt}/${cap} — ${reason}`,
4430
+ );
4431
+ await this.safe(
4432
+ async () =>
4433
+ void (await this.deps.writeInbox?.(
4434
+ [
4435
+ `SELF-REQUEUE (recovery) — PR #${round.pr} round ${round.round_no}, attempt ${attempt}/${cap}`,
4436
+ reason,
4437
+ "The daemon cancelled the round and re-queued the PR for a fresh dispatch through the normal path. No GitHub state was touched.",
4438
+ "This is a recovery path, not a normal one — if it recurs on the same PR, investigate the cause the line above names.",
4439
+ ].join("\n"),
4440
+ "anomaly",
4441
+ )),
4442
+ "inbox self-requeue",
4443
+ );
4444
+ return true;
4445
+ }
4446
+
4447
+ /**
4448
+ * Recover a round whose prompt never reached its session.
4449
+ *
4450
+ * Loss is judged from the receipt, never inferred from activity: a prompt
4451
+ * is dropped precisely because the agent is busy, and a busy agent always
4452
+ * has activity after round start. `roundPromptAccepted` is a fact (the
4453
+ * dispatched receipt token, echoed into the session JSONL only when pi
4454
+ * accepted the message), so `false` means the prompt is not there. It does
4455
+ * not depend on the head's self-requeue budget either: a `head_moved`
4456
+ * requeue spends one seconds before dispatching the round most likely to be
4457
+ * lost.
4458
+ *
4459
+ * Recovery is cheap first. A missing prompt is a delivery fault, so the
4460
+ * remedy is to deliver it again — same round, same session, same
4461
+ * conversation — and only a redelivery that also fails to land escalates to
4462
+ * the expensive path (a cancelled round, and for a live session a kill):
4463
+ * killing a healthy session because a file did not arrive destroys a live
4464
+ * conversation.
4465
+ */
4466
+ private async maybeRequeueLostPrompt(round: RoundRow, nowMs: number): Promise<void> {
4467
+ const { cfg, store } = this.deps;
4468
+ const hooks = this.deps.hooks;
4469
+ if (!hooks.roundPromptAccepted || !hooks.sessionAlive) return;
4470
+ // A round dispatched before receipts existed carries no marker, so its
4471
+ // delivery is unknowable — never guess at it.
4472
+ if (!round.prompt_marker) return;
4473
+ const graceMs = (cfg.daemon.round_stall_grace_min ?? DEFAULT_ROUND_STALL_GRACE_MIN) * 60_000;
4474
+ const startedMs = Date.parse(round.started_at);
4475
+ if (!Number.isFinite(startedMs) || nowMs - startedMs < graceMs) return;
4476
+ if (store.hasLaterRound(round.pr, round.round_no)) return; // superseded — the deadline sweep owns that case
4477
+ if (this.verdictFilePresent(round.pr, round.round_no)) return;
4478
+ const clearSilent = (): void => {
4479
+ if (round.silent_since) store.clearRoundSilent(round.id);
4480
+ };
4481
+ try {
4482
+ const accepted = await hooks.roundPromptAccepted.call(
4483
+ hooks,
4484
+ round.pr,
4485
+ round.round_no,
4486
+ startedMs,
4487
+ );
4488
+ if (accepted !== false) {
4489
+ // true = delivered and the session owns it; null = no evidence either
4490
+ // way. Neither is loss.
4491
+ clearSilent();
4492
+ return;
4493
+ }
4494
+ } catch {
4495
+ clearSilent(); // an unreadable signal is not evidence
4496
+ return;
4497
+ }
4498
+ // Two consecutive observations before acting. The receipt is a fact, but
4499
+ // the window around it is not: a prompt delivered a moment ago may not yet
4500
+ // be flushed to the JSONL, and re-sending a round the session has already
4501
+ // started is worse than waiting one more tick.
4502
+ if (!round.silent_since) {
4503
+ store.markRoundSilent(round.id);
4504
+ this.log(
4505
+ `verdicts: #${round.pr} round ${round.round_no} shows no prompt receipt — marked; recovering on the next sweep if it is still missing`,
4506
+ );
4507
+ return;
4508
+ }
4509
+ store.logEvent("round_prompt_lost", round.pr, {
4510
+ round_id: round.id,
4511
+ round_no: round.round_no,
4512
+ started_at: round.started_at,
4513
+ silent_since: round.silent_since,
4514
+ redeliveries: round.redeliveries,
4515
+ });
4516
+ // ---- cheap recovery first: deliver the same order again ----------------
4517
+ if ((await this.redeliverPrompt(round, "before_deadline")) === "redelivered") return;
4518
+ // ---- redelivery is spent or impossible: the expensive path -------------
4519
+ await this.selfRequeue(round, "lost_prompt");
4520
+ }
4521
+
4522
+ /** Deadline sweep — a running round past `deadline_at` is an anomaly.
4523
+ * A superseded round (a newer round exists on the same PR) is cancelled
4524
+ * silently instead: its PR state, labels and reviewer requests belong to
4525
+ * the newer round, and yanking them here would corrupt live reviews.
4526
+ * An overdue round with no verdict whose session is dead (or alive and
4527
+ * wedged) is self-requeued, capped per head.
4528
+ *
4529
+ * Never overlaps itself: a tick still running
4530
+ * — it awaits GitHub reads and, on the anomaly path, the round's measurement
4531
+ * — makes the next one a no-op, as the verdict sweep's per-file guards do
4532
+ * for it. */
4533
+ async checkDeadlines(): Promise<number[]> {
4534
+ if (this.deadlinesInFlight) return [];
4535
+ this.deadlinesInFlight = true;
4536
+ try {
4537
+ return await this.checkDeadlinesOnce();
4538
+ } finally {
4539
+ this.deadlinesInFlight = false;
4540
+ }
4541
+ }
4542
+
4543
+ private async checkDeadlinesOnce(): Promise<number[]> {
4544
+ const overdue: number[] = [];
4545
+ const nowMs = this.now().getTime();
4546
+ const { store } = this.deps;
4547
+ await this.flushStrandedWakes();
4548
+ for (const round of store.runningRounds()) {
4549
+ if (Date.parse(round.deadline_at) > nowMs) {
4550
+ // Lost-prompt net, well before the deadline: the round's inject was
4551
+ // consumed (nothing pending, so the bridge is not merely retrying),
4552
+ // no verdict is on disk, and the session's JSONL carries no receipt
4553
+ // for the prompt. Redelivered first, then requeued through the same
4554
+ // capped path (`maybeRequeueLostPrompt`).
4555
+ await this.maybeRequeueLostPrompt(round, nowMs);
4556
+ continue;
4557
+ }
4558
+ if (store.hasLaterRound(round.pr, round.round_no)) {
4559
+ store.markRoundCancelled(
4560
+ round.id,
4561
+ `superseded — round ${round.round_no} outlived by a newer round`,
4562
+ "superseded",
4563
+ );
4564
+ store.logEvent("round_superseded", round.pr, {
4565
+ round_id: round.id,
4566
+ round_no: round.round_no,
4567
+ });
4568
+ // Slack: "read the newest one" — no existing review is touched, ever
4569
+ // (AGENTS.md invariant 2); the wording invariant lives in messages.ts.
4570
+ const newest = store
4571
+ .roundsFor(round.pr)
4572
+ .reduce((a, b) => (b.round_no > a.round_no ? b : a), round);
4573
+ await this.announceSafe((a) =>
4574
+ a.superseded({
4575
+ pr: round.pr,
4576
+ old_round_no: round.round_no,
4577
+ new_round_no: newest.round_no,
4578
+ head_sha: store.getPr(round.pr)?.head_sha ?? "",
4579
+ }),
4580
+ );
4581
+ this.log(`verdicts: #${round.pr} round ${round.round_no} cancelled (superseded)`);
4582
+ continue;
4583
+ }
4584
+ if (round.posting_key && !this.verdictFilePresent(round.pr, round.round_no)) {
4585
+ const proof = await this.postingProof(round);
4586
+ if (proof.kind === "waiting") continue;
4587
+ // The deadline honours the posted-tail grace the stage limits give:
4588
+ // settling minutes after the post would end the round under a session
4589
+ // still writing its tail, and the supervisor's terminal-round path
4590
+ // would then kill it, losing its summary and pass telemetry. Past the
4591
+ // grace it settles; a verdict file that lands within it is the
4592
+ // sweep's.
4593
+ if (
4594
+ (proof.kind === "skipped" || proof.kind === "confirmed") &&
4595
+ this.inPostedTailGrace(
4596
+ round,
4597
+ proof,
4598
+ "round_deadline",
4599
+ `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict file`,
4600
+ )
4601
+ )
4602
+ continue;
4603
+ if (proof.kind === "skipped") {
4604
+ // Refused by guard 2, no verdict file by the deadline. Not
4605
+ // stuck, not failed — settled, and a late verdict file replays inertly.
4606
+ await this.settleGuardedNoop(round, proof.noop!, "deadline");
4607
+ continue;
4608
+ }
4609
+ if (proof.kind === "confirmed" && proof.receipt) {
4610
+ // The review is on GitHub; the missing verdict file is settled from
4611
+ // the receipt, never re-run (a rerun would post a second review). A
4612
+ // receipt that does not say what was decided keeps the loud path.
4613
+ const receipt = proof.receipt;
4614
+ const decision = decidedVerdictFromReceipt(receipt);
4615
+ if (!decision) {
4616
+ await this.failPostingProofUnlessVerdict(
4617
+ round,
4618
+ `GitHub review ${receipt.gh_review_id ?? "confirmed"} was posted (${receipt.actual_event}) but no verdict file arrived before the round deadline, and the posting receipt does not say what was decided`,
4619
+ "posted_without_verdict",
4620
+ );
4621
+ continue;
4622
+ }
4623
+ await this.withVerdictSlot(round.pr, round.round_no, async () => {
4624
+ if (this.verdictFilePresent(round.pr, round.round_no)) return;
4625
+ await this.settleFromReceipt(
4626
+ round,
4627
+ receipt,
4628
+ decision,
4629
+ "deadline",
4630
+ `round ${round.round_no} passed its deadline (${round.deadline_at}) with its review posted and no verdict file`,
4631
+ );
4632
+ });
4633
+ continue;
4634
+ }
4635
+ if (proof.kind === "failed") {
4636
+ await this.failPostingProofUnlessVerdict(
4637
+ round,
4638
+ proof.detail ?? "round ended without posting confirmation",
4639
+ "posting_receipt",
4640
+ );
4641
+ continue;
4642
+ }
4643
+ }
4644
+ const stuck = await this.stuckDisposition(round);
4645
+ if (stuck === "verdict_pending") {
4646
+ // A real verdict is on disk — the sweep consumes it; do nothing here.
4647
+ this.log(
4648
+ `verdicts: #${round.pr} round ${round.round_no} overdue but its verdict file is pending — leaving it to the sweep`,
4649
+ );
4650
+ continue;
4651
+ }
4652
+ // A live session that provably never got the order: deliver it again
4653
+ // rather than kill a healthy conversation. Only when redelivery
4654
+ // is spent or impossible does this fall through to the expensive path.
4655
+ if (stuck === "never_delivered" && (await this.recoverNeverDelivered(round))) continue;
4656
+ const expensive = stuck === "dead" || stuck === "alive_stuck" || stuck === "never_delivered";
4657
+ if (
4658
+ expensive &&
4659
+ (await this.selfRequeue(round, stuck === "never_delivered" ? "lost_prompt" : stuck))
4660
+ )
4661
+ continue;
4662
+ overdue.push(round.pr);
4663
+ const spent =
4664
+ expensive && this.selfRequeueSpent(round.pr, store.getPr(round.pr)?.head_sha ?? "");
4665
+ await this.handleAnomaly(
4666
+ round.pr,
4667
+ round.round_no,
4668
+ `round ${round.round_no} passed its deadline (${round.deadline_at}); started ${round.started_at}` +
4669
+ (spent
4670
+ ? " — the self-requeue cap is exhausted; parking"
4671
+ : expensive
4672
+ ? " — recovery could not proceed (session not stopped or no recovery hook); parking"
4673
+ : ""),
4674
+ "overdue",
4675
+ );
4676
+ }
4677
+ return overdue;
4678
+ }
4679
+
4680
+ /** Slack is a side channel: absent announcer ⇒ nothing happens, and a Slack
4681
+ * failure is logged, never propagated into the verdict path. */
4682
+ private async announceSafe(fn: (a: VerdictAnnouncer) => Promise<void>): Promise<void> {
4683
+ const announcer = this.deps.announce;
4684
+ if (!announcer) return;
4685
+ try {
4686
+ await fn(announcer);
4687
+ } catch (err) {
4688
+ this.log("verdicts: slack announcement failed", err);
4689
+ }
4690
+ }
4691
+
4692
+ private async safe<T>(fn: () => Promise<T>, what: string): Promise<T | undefined> {
4693
+ try {
4694
+ return await fn();
4695
+ } catch (err) {
4696
+ this.log(`verdicts: ${what} failed`, err);
4697
+ // A side channel's failure record must not become a failure of its
4698
+ // own: the delayed round tail can outlive the store it writes to.
4699
+ try {
4700
+ this.deps.store.logEvent("verdict_action_failed", null, { what, error: String(err) });
4701
+ } catch (logErr) {
4702
+ this.log(`verdicts: ${what} failure not recorded`, logErr);
4703
+ }
4704
+ return undefined;
4705
+ }
4706
+ }
4707
+ }