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