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