@repo-os/repoos 0.5.45

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 (362) hide show
  1. package/LICENSE.md +110 -0
  2. package/README.md +272 -0
  3. package/dist/.build-info.json +4 -0
  4. package/dist/.build-stamp.json +3 -0
  5. package/dist/cli/colors.d.ts +13 -0
  6. package/dist/cli/colors.js +37 -0
  7. package/dist/cli/index.d.ts +2 -0
  8. package/dist/cli/index.js +221 -0
  9. package/dist/commands/check.d.ts +170 -0
  10. package/dist/commands/check.js +1227 -0
  11. package/dist/commands/docs.d.ts +2 -0
  12. package/dist/commands/docs.js +70 -0
  13. package/dist/commands/gc.d.ts +1 -0
  14. package/dist/commands/gc.js +57 -0
  15. package/dist/commands/init.d.ts +9 -0
  16. package/dist/commands/init.js +699 -0
  17. package/dist/commands/screenshots.d.ts +7 -0
  18. package/dist/commands/screenshots.js +77 -0
  19. package/dist/commands/serve.d.ts +55 -0
  20. package/dist/commands/serve.js +204 -0
  21. package/dist/commands/status.d.ts +140 -0
  22. package/dist/commands/status.js +569 -0
  23. package/dist/commands/stop.d.ts +1 -0
  24. package/dist/commands/stop.js +59 -0
  25. package/dist/commands/tasks.d.ts +47 -0
  26. package/dist/commands/tasks.js +334 -0
  27. package/dist/commands/tunnel.d.ts +1 -0
  28. package/dist/commands/tunnel.js +1271 -0
  29. package/dist/commands/ui-harness.d.ts +53 -0
  30. package/dist/commands/ui-harness.js +62 -0
  31. package/dist/commands/ui-smoke.d.ts +6 -0
  32. package/dist/commands/ui-smoke.js +220 -0
  33. package/dist/commands/upgrade.d.ts +15 -0
  34. package/dist/commands/upgrade.js +188 -0
  35. package/dist/core/auth-store.d.ts +81 -0
  36. package/dist/core/auth-store.js +530 -0
  37. package/dist/core/auth.d.ts +75 -0
  38. package/dist/core/auth.js +124 -0
  39. package/dist/core/bootstrap.d.ts +41 -0
  40. package/dist/core/bootstrap.js +236 -0
  41. package/dist/core/build.d.ts +92 -0
  42. package/dist/core/build.js +249 -0
  43. package/dist/core/canary.d.ts +10 -0
  44. package/dist/core/canary.js +14 -0
  45. package/dist/core/check-failure-summary.d.ts +10 -0
  46. package/dist/core/check-failure-summary.js +34 -0
  47. package/dist/core/config.d.ts +160 -0
  48. package/dist/core/config.js +1288 -0
  49. package/dist/core/context-pack.d.ts +74 -0
  50. package/dist/core/context-pack.js +778 -0
  51. package/dist/core/db.d.ts +236 -0
  52. package/dist/core/db.js +747 -0
  53. package/dist/core/detect.d.ts +69 -0
  54. package/dist/core/detect.js +251 -0
  55. package/dist/core/docs.d.ts +76 -0
  56. package/dist/core/docs.js +225 -0
  57. package/dist/core/frontmatter.d.ts +29 -0
  58. package/dist/core/frontmatter.js +215 -0
  59. package/dist/core/git.d.ts +505 -0
  60. package/dist/core/git.js +1453 -0
  61. package/dist/core/indexer.d.ts +33 -0
  62. package/dist/core/indexer.js +282 -0
  63. package/dist/core/input.d.ts +66 -0
  64. package/dist/core/input.js +222 -0
  65. package/dist/core/logger.d.ts +70 -0
  66. package/dist/core/logger.js +138 -0
  67. package/dist/core/models.d.ts +45 -0
  68. package/dist/core/models.js +291 -0
  69. package/dist/core/net-probe.d.ts +4 -0
  70. package/dist/core/net-probe.js +20 -0
  71. package/dist/core/providers/deepinfra.d.ts +23 -0
  72. package/dist/core/providers/deepinfra.js +44 -0
  73. package/dist/core/providers/index.d.ts +24 -0
  74. package/dist/core/providers/index.js +59 -0
  75. package/dist/core/providers/openrouter.d.ts +28 -0
  76. package/dist/core/providers/openrouter.js +82 -0
  77. package/dist/core/providers/spend.d.ts +86 -0
  78. package/dist/core/providers/spend.js +286 -0
  79. package/dist/core/providers/types.d.ts +56 -0
  80. package/dist/core/providers/types.js +39 -0
  81. package/dist/core/repoos.d.ts +59 -0
  82. package/dist/core/repoos.js +221 -0
  83. package/dist/core/runtime.d.ts +35 -0
  84. package/dist/core/runtime.js +167 -0
  85. package/dist/core/skills-registry.d.ts +39 -0
  86. package/dist/core/skills-registry.js +148 -0
  87. package/dist/core/skills-registry.test.d.ts +1 -0
  88. package/dist/core/skills-registry.test.js +52 -0
  89. package/dist/core/sysmem.d.ts +7 -0
  90. package/dist/core/sysmem.js +55 -0
  91. package/dist/core/tailscale.d.ts +2 -0
  92. package/dist/core/tailscale.js +19 -0
  93. package/dist/core/task.d.ts +53 -0
  94. package/dist/core/task.js +310 -0
  95. package/dist/core/toml-line.d.ts +20 -0
  96. package/dist/core/toml-line.js +52 -0
  97. package/dist/core/toml-validate.d.ts +44 -0
  98. package/dist/core/toml-validate.js +335 -0
  99. package/dist/core/tunnel-assistant.d.ts +30 -0
  100. package/dist/core/tunnel-assistant.js +65 -0
  101. package/dist/core/tunnel-registry.d.ts +96 -0
  102. package/dist/core/tunnel-registry.js +267 -0
  103. package/dist/core/tunnel-registry.test.d.ts +1 -0
  104. package/dist/core/tunnel-registry.test.js +309 -0
  105. package/dist/core/tunnel.d.ts +103 -0
  106. package/dist/core/tunnel.js +320 -0
  107. package/dist/core/types.d.ts +811 -0
  108. package/dist/core/types.js +27 -0
  109. package/dist/core/worktree-gc.d.ts +57 -0
  110. package/dist/core/worktree-gc.js +194 -0
  111. package/dist/server/__tests__/integration-job.test.d.ts +1 -0
  112. package/dist/server/__tests__/integration-job.test.js +129 -0
  113. package/dist/server/agents.d.ts +1020 -0
  114. package/dist/server/agents.js +4070 -0
  115. package/dist/server/attachments.d.ts +51 -0
  116. package/dist/server/attachments.js +130 -0
  117. package/dist/server/auto-engineering.d.ts +45 -0
  118. package/dist/server/auto-engineering.js +275 -0
  119. package/dist/server/branchless-release.d.ts +22 -0
  120. package/dist/server/branchless-release.js +67 -0
  121. package/dist/server/built-in-agents.d.ts +305 -0
  122. package/dist/server/built-in-agents.js +1838 -0
  123. package/dist/server/cto-monitor.d.ts +35 -0
  124. package/dist/server/cto-monitor.js +281 -0
  125. package/dist/server/cto.d.ts +125 -0
  126. package/dist/server/cto.js +590 -0
  127. package/dist/server/deployments.d.ts +119 -0
  128. package/dist/server/deployments.js +325 -0
  129. package/dist/server/diff-snapshot.d.ts +20 -0
  130. package/dist/server/diff-snapshot.js +73 -0
  131. package/dist/server/done.d.ts +160 -0
  132. package/dist/server/done.js +488 -0
  133. package/dist/server/freeform.d.ts +55 -0
  134. package/dist/server/freeform.js +179 -0
  135. package/dist/server/handoff.d.ts +99 -0
  136. package/dist/server/handoff.js +575 -0
  137. package/dist/server/hetzner.d.ts +45 -0
  138. package/dist/server/hetzner.js +117 -0
  139. package/dist/server/icons.d.ts +8 -0
  140. package/dist/server/icons.js +195 -0
  141. package/dist/server/integration-job.d.ts +75 -0
  142. package/dist/server/integration-job.js +144 -0
  143. package/dist/server/integration-orchestrator.d.ts +211 -0
  144. package/dist/server/integration-orchestrator.js +1299 -0
  145. package/dist/server/integration-status.d.ts +48 -0
  146. package/dist/server/integration-status.js +71 -0
  147. package/dist/server/live-index.d.ts +295 -0
  148. package/dist/server/live-index.js +452 -0
  149. package/dist/server/model-test.d.ts +20 -0
  150. package/dist/server/model-test.js +100 -0
  151. package/dist/server/ntfy.d.ts +57 -0
  152. package/dist/server/ntfy.js +112 -0
  153. package/dist/server/pm-attachments.d.ts +35 -0
  154. package/dist/server/pm-attachments.js +181 -0
  155. package/dist/server/pm-runs.d.ts +58 -0
  156. package/dist/server/pm-runs.js +80 -0
  157. package/dist/server/preview.d.ts +174 -0
  158. package/dist/server/preview.js +641 -0
  159. package/dist/server/release.d.ts +88 -0
  160. package/dist/server/release.js +449 -0
  161. package/dist/server/reload.d.ts +194 -0
  162. package/dist/server/reload.js +586 -0
  163. package/dist/server/remote-validation.d.ts +132 -0
  164. package/dist/server/remote-validation.js +478 -0
  165. package/dist/server/repo-lock.d.ts +36 -0
  166. package/dist/server/repo-lock.js +141 -0
  167. package/dist/server/review-guard.d.ts +14 -0
  168. package/dist/server/review-guard.js +159 -0
  169. package/dist/server/review.d.ts +243 -0
  170. package/dist/server/review.js +1211 -0
  171. package/dist/server/routes/agents.d.ts +6 -0
  172. package/dist/server/routes/agents.js +28 -0
  173. package/dist/server/routes/auth.d.ts +60 -0
  174. package/dist/server/routes/auth.js +755 -0
  175. package/dist/server/routes/config.d.ts +20 -0
  176. package/dist/server/routes/config.js +347 -0
  177. package/dist/server/routes/debugger.d.ts +33 -0
  178. package/dist/server/routes/debugger.js +360 -0
  179. package/dist/server/routes/deployments.d.ts +3 -0
  180. package/dist/server/routes/deployments.js +16 -0
  181. package/dist/server/routes/docs.d.ts +8 -0
  182. package/dist/server/routes/docs.js +135 -0
  183. package/dist/server/routes/helpers.d.ts +21 -0
  184. package/dist/server/routes/helpers.js +171 -0
  185. package/dist/server/routes/index.d.ts +19 -0
  186. package/dist/server/routes/index.js +18 -0
  187. package/dist/server/routes/info.d.ts +17 -0
  188. package/dist/server/routes/info.js +177 -0
  189. package/dist/server/routes/inputs.d.ts +7 -0
  190. package/dist/server/routes/inputs.js +125 -0
  191. package/dist/server/routes/model-providers.d.ts +27 -0
  192. package/dist/server/routes/model-providers.js +94 -0
  193. package/dist/server/routes/models.d.ts +3 -0
  194. package/dist/server/routes/models.js +36 -0
  195. package/dist/server/routes/notify.d.ts +2 -0
  196. package/dist/server/routes/notify.js +17 -0
  197. package/dist/server/routes/playground.d.ts +29 -0
  198. package/dist/server/routes/playground.js +81 -0
  199. package/dist/server/routes/release.d.ts +20 -0
  200. package/dist/server/routes/release.js +86 -0
  201. package/dist/server/routes/router.d.ts +12 -0
  202. package/dist/server/routes/router.js +32 -0
  203. package/dist/server/routes/skill-registry.d.ts +5 -0
  204. package/dist/server/routes/skill-registry.js +51 -0
  205. package/dist/server/routes/tasks.d.ts +58 -0
  206. package/dist/server/routes/tasks.js +1442 -0
  207. package/dist/server/routes/transcribe.d.ts +2 -0
  208. package/dist/server/routes/transcribe.js +124 -0
  209. package/dist/server/routes/types.d.ts +68 -0
  210. package/dist/server/routes/types.js +1 -0
  211. package/dist/server/routes/ui.d.ts +5 -0
  212. package/dist/server/routes/ui.js +81 -0
  213. package/dist/server/routes/utils.d.ts +3 -0
  214. package/dist/server/routes/utils.js +23 -0
  215. package/dist/server/serve-reaper.d.ts +135 -0
  216. package/dist/server/serve-reaper.js +491 -0
  217. package/dist/server/server.d.ts +49 -0
  218. package/dist/server/server.js +2171 -0
  219. package/dist/server/supervisor.d.ts +89 -0
  220. package/dist/server/supervisor.js +231 -0
  221. package/dist/server/system.d.ts +198 -0
  222. package/dist/server/system.js +480 -0
  223. package/dist/server/task-check.d.ts +45 -0
  224. package/dist/server/task-check.js +71 -0
  225. package/dist/server/task-transitions.d.ts +51 -0
  226. package/dist/server/task-transitions.js +78 -0
  227. package/dist/server/task-watchdog.d.ts +158 -0
  228. package/dist/server/task-watchdog.js +681 -0
  229. package/dist/server/test-run.d.ts +28 -0
  230. package/dist/server/test-run.js +81 -0
  231. package/dist/server/watcher.d.ts +21 -0
  232. package/dist/server/watcher.js +194 -0
  233. package/dist/server/write.d.ts +94 -0
  234. package/dist/server/write.js +345 -0
  235. package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
  236. package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
  237. package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
  238. package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
  239. package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
  240. package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
  241. package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
  242. package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
  243. package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
  244. package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
  245. package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
  246. package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
  247. package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
  248. package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
  249. package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
  250. package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
  251. package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
  252. package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
  253. package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
  254. package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
  255. package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
  256. package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
  257. package/dist/ui/assets/array-BifhSqXX.js +1 -0
  258. package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
  259. package/dist/ui/assets/button-lbikqSph.js +1 -0
  260. package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
  261. package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
  262. package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  263. package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
  264. package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  265. package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
  266. package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
  267. package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
  268. package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
  269. package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
  270. package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
  271. package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
  272. package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  273. package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
  274. package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
  275. package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
  276. package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
  277. package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
  278. package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
  279. package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
  280. package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
  281. package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  282. package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  283. package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
  284. package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
  285. package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
  286. package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
  287. package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
  288. package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
  289. package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
  290. package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
  291. package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  292. package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
  293. package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
  294. package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
  295. package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
  296. package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
  297. package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
  298. package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
  299. package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
  300. package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
  301. package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
  302. package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
  303. package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
  304. package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
  305. package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
  306. package/dist/ui/assets/index-BACBfnga.css +1 -0
  307. package/dist/ui/assets/index-BOQ-ust6.js +26 -0
  308. package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
  309. package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
  310. package/dist/ui/assets/init-C-OQMol4.js +1 -0
  311. package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
  312. package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
  313. package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
  314. package/dist/ui/assets/katex-DolUETbr.js +257 -0
  315. package/dist/ui/assets/line-KAyfuul4.js +1 -0
  316. package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
  317. package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
  318. package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
  319. package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
  320. package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
  321. package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
  322. package/dist/ui/assets/path-COt_16Va.js +1 -0
  323. package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
  324. package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
  325. package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
  326. package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
  327. package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
  328. package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
  329. package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
  330. package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
  331. package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
  332. package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
  333. package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
  334. package/dist/ui/assets/repoos-cto-square.webp +0 -0
  335. package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
  336. package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
  337. package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
  338. package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
  339. package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
  340. package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
  341. package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
  342. package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
  343. package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
  344. package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  345. package/dist/ui/assets/src-BH-TyZbA.js +1 -0
  346. package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
  347. package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
  348. package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
  349. package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
  350. package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
  351. package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
  352. package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
  353. package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
  354. package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
  355. package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
  356. package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
  357. package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
  358. package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
  359. package/dist/ui/favicon.svg +11 -0
  360. package/dist/ui/index.html +49 -0
  361. package/dist/ui/sw.js +48 -0
  362. package/package.json +91 -0
@@ -0,0 +1,1211 @@
1
+ /**
2
+ * The review agent (0101): an automatic, read-only assessment of a task's
3
+ * implementation, produced the moment the task lands in `review` and read by
4
+ * the human who signs it off.
5
+ *
6
+ * It SUPPORTS sign-off, it never replaces it. The report is advisory: the
7
+ * agent inspects the branch's diff in the task's own worktree, writes a short
8
+ * markdown report to `<cacheDir>/reviews/<id>.md`, and stops. It never edits
9
+ * the repo, never merges, and never moves the task to `done` — that transition
10
+ * stays exactly where it was, behind the human's "Move to done".
11
+ *
12
+ * Three layers keep it that way:
13
+ * 1. the mission spells out the read-only boundary;
14
+ * 2. the child is spawned through the runner's review path, which injects no
15
+ * `REPOOS_API_URL`/`REPOOS_TASK_ID`, so the agent has no pointer at the
16
+ * control plane's `/done` endpoint;
17
+ * 3. `enforceStillInReview` reverts the task file if it comes back `done`
18
+ * anyway, and says so loudly (the same shape as the 0077 self-heal).
19
+ *
20
+ * Reload durability (0288): the review run is spawned through `AgentRunner`
21
+ * (a durable per-session log file + a PID in the durable registry), so when
22
+ * the server self-reloads mid-review the NEW process's `adoptRunningAgents()`
23
+ * re-attaches to the still-alive reviewer, replays its output, and on
24
+ * completion fires `onReviewDone` — and THIS manager finalizes the report from
25
+ * that hook instead of from a post-`runPrompt` continuation that would have
26
+ * died with the old process.
27
+ *
28
+ * Zero runtime deps — node:fs / node:path only.
29
+ */
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { parseDocument, serializeDocument } from "../core/frontmatter.js";
33
+ import { commitTaskFile, currentBranch, headCommitISO, worktreePathForBranch, } from "../core/git.js";
34
+ import { parseTask, utcTimestamp } from "../core/task.js";
35
+ import { estimateCostUsd, extractOneShotReportText, resolveReviewer, resolveReviewerForTask, usageCostSource, } from "./agents.js";
36
+ import { patchTaskFile } from "./write.js";
37
+ import { createLogger } from "../core/logger.js";
38
+ import { getRepoOSDb } from "../core/db.js";
39
+ /**
40
+ * Ceiling on one review run. Generous compared to the 3-minute one-shot
41
+ * default: a reviewer reads a whole diff and may run the test suite, and a
42
+ * timeout means the human gets no report at all.
43
+ */
44
+ const REVIEW_TIMEOUT_MS = 900_000;
45
+ /** Cap on the task spec quoted into the mission (keeps the prompt bounded). */
46
+ const SPEC_CHARS = 6000;
47
+ /** Cap on the stored report (a runaway agent must not fill the cache dir). */
48
+ const REPORT_CHARS = 20_000;
49
+ /**
50
+ * Prefix on the SSE event id used for a task's reviewer conversation. The
51
+ * engineer session streams under the plain task id; reviewer output streams
52
+ * under `review:<taskId>` so the two conversations can never mix on the wire
53
+ * or in the UI (0110).
54
+ */
55
+ export const REVIEW_SESSION_ID_PREFIX = "review:";
56
+ const now = () => new Date().toISOString();
57
+ /**
58
+ * Compute the reviewer session's cost + source the same way the engineer's
59
+ * `recordSessionToDb` does (0273): a real CLI-reported figure wins outright,
60
+ * otherwise a token-count estimate (never fabricated), and Kiro credits are
61
+ * never passed off as US dollars.
62
+ */
63
+ function reviewUsage(agent, usage) {
64
+ if (usage.costUsd) {
65
+ return { costUsd: usage.costUsd, costSource: usageCostSource(agent, usage) };
66
+ }
67
+ if (usage.totalTokens) {
68
+ return { costUsd: estimateCostUsd(usage.totalTokens), costSource: "estimate" };
69
+ }
70
+ return { costSource: "none" };
71
+ }
72
+ /** Frontmatter key order for the stored report file. */
73
+ const REPORT_KEYS = ["task", "at", "agent", "cli", "model", "branch", "state"];
74
+ /** Max number of automatic review/fix rounds before requiring human intervention. */
75
+ const MAX_AUTO_REVIEW_ROUNDS = 2;
76
+ /**
77
+ * The mission handed to the review agent. Deliberately narrow: what to look
78
+ * at, what to write, and a hard boundary on what it may not touch.
79
+ */
80
+ export function reviewMission(task, agent, workdir, baseBranch) {
81
+ const spec = task.body.trim().slice(0, SPEC_CHARS);
82
+ const role = agent.instructions?.trim();
83
+ const parts = [];
84
+ if (role)
85
+ parts.push(role, "");
86
+ parts.push("You are the REVIEW agent for RepoOS. A human is about to sign this task off and", "wants your read on it first. Your job is to inspect the finished implementation", "and write ONE short report they can read while reviewing. You do not approve", "anything and you do not finish the task — the human does.", "", `Task #${task.id}: ${task.title}`, `Worktree (read-only for you): ${workdir}`, `Branch under review: ${task.branch || "(none)"} · base branch: ${baseBranch}`, "", "## The task spec", "", spec || "(the task file has no body)", "", "## How to inspect it", "", `- \`git diff ${baseBranch}...HEAD\` and \`git log ${baseBranch}..HEAD --oneline\` in the`, " worktree show exactly what changed. Start there.", "- Read the files the diff touches — judge the CODE, not just the diff hunks.", "- Check the implementation actually does what the spec asks, including every", " acceptance criterion, and that its behaviour holds up beyond the happy path.", "- Look for real bugs: wrong logic, unhandled errors, races, resource leaks,", " broken contracts with existing callers, missing or misleading tests.", "- Call out the edge cases that matter for this change (empty/missing input,", " concurrency, failure of a dependency, first run, repeated run).", "- You may run read-only commands (git, reading files). The implementer already", " ran `repoos check` green to enter review, so do NOT re-run the full build or", " test suite — it is slow and adds nothing you cannot see from the code.", "- Before judging the diff itself, check whether the task is still worth having.", " Look at what has landed on the base branch and skim nearby recent tasks in", " `work/` for signs the same problem was already solved elsewhere, requirements", " shifted, or the task's premise no longer holds. A diff can be flawless and the", " task still not worth merging as scoped — say so; don't just grade the code.", "", "## Hard boundaries", "", "- Do NOT edit, create, or delete ANY file, including the task file.", "- Do NOT commit, push, merge, sync, or delete branches.", "- Do NOT change the task's status, and NEVER move it to `done` — the task stays", " in `review` for the human. Approving, merging, and completing are not yours.", "- Do NOT start servers or long-running processes.", "", "## What to output", "", "Output ONLY the report, as markdown, with no preamble and no closing chatter.", "Keep it under 400 words — a reviewer reads this next to the diff. Use exactly", "these sections. Verdict and Relevance each require one of their labels; write", "`None found` under Bugs, Edge cases, or Suggestions when genuinely empty:", "", "## Verdict", "Open the report with EXACTLY one of these three verdict labels, then one or two", "sentences saying what the change does and whether anything blocks sign-off:", "", "`good to go` — the change is correct and complete.", "`needs some work` — the change is close, but has issues worth fixing first.", "`back to the drawing board` — the change is off the mark.", "", "## Relevance", "Open with EXACTLY one of these three labels, then one sentence explaining why.", "This is independent of code quality — a correct diff can still fail this check:", "", "`still relevant` — the task's premise holds; this is still worth landing.", "`no longer needed` — the problem is already solved (elsewhere, or superseded by", "other work since this task was written); recommend discarding instead of merging.", "`needs rescoping` — the premise partly holds, but the task description or", "acceptance criteria are stale given what's shipped since; a human should update", "the task before this is merged as-is.", "", "## Bugs", "Concrete defects, each with the file and what goes wrong.", "", "## Edge cases", "Cases worth checking that the change may not handle.", "", "## Suggestions", "Improvements worth making. Say so plainly when there are none.");
87
+ return parts.join("\n");
88
+ }
89
+ /**
90
+ * The mission handed to the review agent for a follow-up chat turn. The human
91
+ * asked a question about the review; the reviewer answers in the same read-only
92
+ * style, grounded in the task spec, its own previous report (when one exists),
93
+ * and the actual code. It still never touches the repo beyond reading.
94
+ */
95
+ export function reviewFollowupMission(task, agent, workdir, baseBranch, text, previousReport) {
96
+ const spec = task.body.trim().slice(0, SPEC_CHARS);
97
+ const role = agent.instructions?.trim();
98
+ const parts = [];
99
+ if (role)
100
+ parts.push(role, "");
101
+ parts.push("You are the REVIEW agent for RepoOS, continuing an earlier review conversation.", "A human is asking a follow-up question about your review of a task. Answer it", "directly and concisely, grounded in the actual code. You still do not approve", "anything and you do not finish the task — the human does.", "", `Task #${task.id}: ${task.title}`, `Worktree (read-only for you): ${workdir}`, `Branch under review: ${task.branch || "(none)"} · base branch: ${baseBranch}`, "", "## The task spec", "", spec || "(the task file has no body)", "", ...(previousReport
102
+ ? ["## Your previous review", "", previousReport.markdown.trim().slice(0, REPORT_CHARS), ""]
103
+ : []), "## Hard boundaries", "", "- Do NOT edit, create, or delete ANY file, including the task file.", "- Do NOT commit, push, merge, sync, or delete branches.", "- Do NOT change the task's status, and NEVER move it to `done` — the task stays", " in `review` for the human. Approving, merging, and completing are not yours.", "- Do NOT start servers or long-running processes.", "", "## What to output", "", "Answer the human's follow-up question in plain markdown, under 400 words. Quote", "specific files and lines where useful. If the question is about the verdict,", "say clearly whether it stays one of: `good to go`, `needs some work`, or", "`back to the drawing board`.", "", "## The human's question", "", text);
104
+ return parts.join("\n");
105
+ }
106
+ /** How long a guard revert stays claimable by the review trigger, in ms. */
107
+ const REVERT_CLAIM_MS = 30_000;
108
+ /** Parse the verdict from the review report markdown. */
109
+ function parseVerdict(markdown) {
110
+ const lines = markdown.split("\n");
111
+ for (const line of lines) {
112
+ if (line.includes("`good to go`"))
113
+ return "good to go";
114
+ if (line.includes("`needs some work`"))
115
+ return "needs some work";
116
+ if (line.includes("`back to the drawing board`"))
117
+ return "back to the drawing board";
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Parse the relevance label from the review report markdown (whether the task
123
+ * is still worth having, independent of the diff's code quality). Absent on
124
+ * reports written before this section existed — callers must treat `null` as
125
+ * "unknown", not as "still relevant".
126
+ */
127
+ function parseRelevance(markdown) {
128
+ const lines = markdown.split("\n");
129
+ for (const line of lines) {
130
+ if (line.includes("`still relevant`"))
131
+ return "still relevant";
132
+ if (line.includes("`no longer needed`"))
133
+ return "no longer needed";
134
+ if (line.includes("`needs rescoping`"))
135
+ return "needs rescoping";
136
+ }
137
+ return null;
138
+ }
139
+ /** Extract Relevance, Bugs, Edge cases, and Suggestions sections from the report markdown. */
140
+ function extractReportSections(markdown) {
141
+ const sections = {};
142
+ const lines = markdown.split("\n");
143
+ let currentSection = null;
144
+ const sectionLines = {};
145
+ for (const line of lines) {
146
+ const trimmed = line.trim();
147
+ if (trimmed === "## Relevance") {
148
+ currentSection = "relevance";
149
+ sectionLines.relevance = [];
150
+ }
151
+ else if (trimmed === "## Bugs") {
152
+ currentSection = "bugs";
153
+ sectionLines.bugs = [];
154
+ }
155
+ else if (trimmed === "## Edge cases") {
156
+ currentSection = "edgeCases";
157
+ sectionLines.edgeCases = [];
158
+ }
159
+ else if (trimmed === "## Suggestions") {
160
+ currentSection = "suggestions";
161
+ sectionLines.suggestions = [];
162
+ }
163
+ else if (trimmed.startsWith("## ") && currentSection) {
164
+ currentSection = null;
165
+ }
166
+ else if (currentSection && sectionLines[currentSection]) {
167
+ sectionLines[currentSection].push(line);
168
+ }
169
+ }
170
+ for (const [key, lines] of Object.entries(sectionLines)) {
171
+ const content = lines.join("\n").trim();
172
+ if (content && content !== "None found") {
173
+ sections[key] = content;
174
+ }
175
+ }
176
+ return {
177
+ relevance: sections.relevance,
178
+ bugs: sections.bugs,
179
+ edgeCases: sections.edgeCases,
180
+ suggestions: sections.suggestions,
181
+ };
182
+ }
183
+ /**
184
+ * Reconstruct the review report from a durable runner session's parsed
185
+ * transcript (0288). This is the `runPrompt`-era `extractOneShotReportText`,
186
+ * re-sourced from the entries the runner keeps in the review session so the
187
+ * report can be built after a reload. Structured engines (opencode/claude/
188
+ * qwen/codex) surface the final answer as the LAST `text` entry; the plain
189
+ * path (kiro) keeps each line as an `{ s: "out", d }` entry whose `d` values
190
+ * concatenate into the report.
191
+ */
192
+ function extractReportFromSession(cli, lines, fallback) {
193
+ let lastText = "";
194
+ const outLines = [];
195
+ for (const line of lines) {
196
+ if ("type" in line && line.type === "text" && line.text) {
197
+ lastText = line.text;
198
+ }
199
+ else if ("s" in line && line.s === "out" && line.d) {
200
+ outLines.push(line.d);
201
+ }
202
+ }
203
+ const structured = lastText.trim();
204
+ if (structured)
205
+ return structured;
206
+ const plain = outLines.join("\n").trim();
207
+ if (plain) {
208
+ // extractOneShotReportText's non-JSON engines return the raw output as the
209
+ // report; mirror that for kiro/plain sessions.
210
+ return extractOneShotReportText(cli, plain) || plain;
211
+ }
212
+ return fallback;
213
+ }
214
+ /** Whether a durable review session produced enough output to count as a report. */
215
+ function sessionHasUsableOutput(cli, lines) {
216
+ for (const line of lines) {
217
+ if ("type" in line && line.type === "text" && line.text.trim())
218
+ return true;
219
+ if ("s" in line && line.s === "out" && line.d.trim())
220
+ return true;
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * Best-effort failure text for a run that produced no report, mirroring
226
+ * `runPrompt`'s old behaviour of surfacing the child's last stderr lines.
227
+ */
228
+ function sessionErrorText(lines) {
229
+ const errs = [];
230
+ for (const line of lines) {
231
+ if ("s" in line && line.s === "err" && line.d.trim())
232
+ errs.push(line.d.trim());
233
+ else if ("type" in line && line.type === "sys" && line.d?.trim())
234
+ errs.push(line.d.trim());
235
+ }
236
+ if (errs.length === 0)
237
+ return "no output produced";
238
+ return errs.slice(-3).join(" ").trim();
239
+ }
240
+ /**
241
+ * The task id for a durable review session key (`review:<id>` → `<id>`).
242
+ * Guards against keys that don't carry the prefix.
243
+ */
244
+ function taskIdFromReviewKey(sessionKey) {
245
+ return sessionKey.startsWith(REVIEW_SESSION_ID_PREFIX)
246
+ ? sessionKey.slice(REVIEW_SESSION_ID_PREFIX.length)
247
+ : sessionKey;
248
+ }
249
+ export class ReviewManager {
250
+ config;
251
+ emit;
252
+ runs = new Map();
253
+ /** Tasks the status guard just put back in `review`, by timestamp. */
254
+ reverted = new Map();
255
+ /** AgentRunner to send auto-bounce messages to the engineer session. */
256
+ runner;
257
+ /**
258
+ * Reflects a trusted write straight into the index (see server.ts's other
259
+ * `applyFileChange(path, { guarded: true })` call sites). Without this the
260
+ * revert below is only ever seen by the async file watcher, which under
261
+ * load can fire the transition-into-review path twice for one write —
262
+ * `claimRevert` is single-use, so the second, spurious firing starts a
263
+ * second review the guard was supposed to prevent.
264
+ */
265
+ index;
266
+ /**
267
+ * Own logger rather than a constructor param (0187 review): the only call
268
+ * site is `startServer`, and every review event is a task-lifecycle event —
269
+ * it belongs on the same per-task log stream `logger.task()` already
270
+ * writes to elsewhere, keyed by the same `config.root`.
271
+ */
272
+ logger;
273
+ /** Database for recording review sessions. */
274
+ db;
275
+ constructor(config, emit, runner, index) {
276
+ this.config = config;
277
+ this.emit = emit;
278
+ this.runner = runner;
279
+ this.index = index;
280
+ this.logger = createLogger(config.root);
281
+ this.db = getRepoOSDb(config.root);
282
+ }
283
+ /** Whether an enabled review agent exists — the Agents page toggle. */
284
+ enabled() {
285
+ return resolveReviewer(this.config) !== null;
286
+ }
287
+ isRunning(taskId) {
288
+ // An in-flight review is tracked in the durable runner session (0288), so
289
+ // a review re-attached after a reload still reads as running here.
290
+ return this.runs.has(taskId) || !!this.runner?.isRunning(this.reviewKey(taskId));
291
+ }
292
+ /**
293
+ * How many reviews are in flight (the server's idle check).
294
+ *
295
+ * `this.runs` is the single source of truth for running reviews: fresh turns
296
+ * register in `run()`/`send()`, and adopted turns (re-attached after a reload)
297
+ * are registered by `armAdoptedTimeouts()` at boot. So counting `this.runs`
298
+ * already covers every in-flight review, including adopted ones — adding a
299
+ * separate count of `runner.running()` review sessions here would DOUBLE-count
300
+ * each adopted review (0288 review round 2).
301
+ */
302
+ runningCount() {
303
+ return this.runs.size;
304
+ }
305
+ /**
306
+ * Claim the "this task is only back in `review` because the guard put it
307
+ * there" marker, if it is fresh. The revert lands on disk as an ordinary
308
+ * status change, so without this the trigger would start another review,
309
+ * whose agent would move it to done again, forever.
310
+ */
311
+ claimRevert(taskId) {
312
+ const at = this.reverted.get(taskId);
313
+ if (at === undefined)
314
+ return false;
315
+ this.reverted.delete(taskId);
316
+ return Date.now() - at <= REVERT_CLAIM_MS;
317
+ }
318
+ /** Where a task's report lives. Derived cache — safe to delete. */
319
+ path(taskId) {
320
+ return join(this.config.root, this.config.cacheDir, "reviews", `${taskId}.md`);
321
+ }
322
+ /** The stored report for a task, or null when none was ever written. */
323
+ read(taskId) {
324
+ const file = this.path(taskId);
325
+ if (!existsSync(file))
326
+ return null;
327
+ let raw;
328
+ try {
329
+ raw = readFileSync(file, "utf8");
330
+ }
331
+ catch {
332
+ return null;
333
+ }
334
+ const doc = parseDocument(raw);
335
+ const str = (key) => {
336
+ const v = doc.data[key];
337
+ return v === null || v === undefined ? "" : String(v);
338
+ };
339
+ return {
340
+ id: str("task") || taskId,
341
+ at: str("at"),
342
+ agent: str("agent"),
343
+ cli: str("cli"),
344
+ model: str("model"),
345
+ branch: str("branch"),
346
+ state: str("state") === "failed" ? "failed" : "ok",
347
+ markdown: doc.body.trim(),
348
+ };
349
+ }
350
+ /**
351
+ * The synchronous gate both `run` and `send` share: is a review possible for
352
+ * this task right now? Resolved before firing the async spawn so an HTTP
353
+ * handler can answer "why not" without waiting for a prompt that will never
354
+ * start.
355
+ */
356
+ canRun(task) {
357
+ if (this.runs.has(task.id)) {
358
+ return { ok: false, reason: "a review is already running for this task" };
359
+ }
360
+ if (task.hotfix) {
361
+ return { ok: false, reason: "hotfix tasks skip diff-based review" };
362
+ }
363
+ const agent = resolveReviewerForTask(this.config, task);
364
+ if (!agent) {
365
+ return { ok: false, reason: "the review agent is disabled on the Agents page" };
366
+ }
367
+ const workdir = task.branch ? worktreePathForBranch(this.config.root, task.branch) : null;
368
+ if (!workdir) {
369
+ return { ok: false, reason: "the task has no worktree to review" };
370
+ }
371
+ return { ok: true };
372
+ }
373
+ /**
374
+ * Same gate for a follow-up chat turn, which additionally needs a prior
375
+ * conversation (a review must have run at least once).
376
+ */
377
+ canSend(task) {
378
+ const base = this.canRun(task);
379
+ if (!base.ok)
380
+ return base;
381
+ if (!this.hasSession(task.id)) {
382
+ return { ok: false, reason: "no review has run for this task — start a review first" };
383
+ }
384
+ return { ok: true };
385
+ }
386
+ /**
387
+ * Run the review agent against a task sitting in `review`.
388
+ *
389
+ * Skips silently (no event, no file) when the review agent is disabled or
390
+ * the task has no worktree to inspect — "disabled means nothing happens" is
391
+ * the contract, not "something happens quietly and fails".
392
+ *
393
+ * A fresh run every time: the reviewer conversation is reset, so the run is
394
+ * a new assessment (the initial landing review, or a deliberate "Review
395
+ * again"), never a continuation of the prior one (0110).
396
+ */
397
+ async run(task) {
398
+ if (this.isRunning(task.id)) {
399
+ return { ok: false, reason: "a review is already running for this task" };
400
+ }
401
+ const agent = resolveReviewerForTask(this.config, task);
402
+ if (!agent) {
403
+ this.logger.task(task.id, "info", "review skipped — agent disabled");
404
+ return { ok: false, skipped: true, reason: "the review agent is disabled" };
405
+ }
406
+ const workdir = task.branch ? worktreePathForBranch(this.config.root, task.branch) : null;
407
+ if (!workdir) {
408
+ this.logger.task(task.id, "info", "review skipped — no worktree", { branch: task.branch });
409
+ return { ok: false, skipped: true, reason: "the task has no worktree to review" };
410
+ }
411
+ const baseBranch = currentBranch(this.config.root) ?? "main";
412
+ // Fresh run: drop any earlier conversation and its persisted copy so the
413
+ // new report reads as a new assessment, not a continuation of the old one.
414
+ this.resetReviewSession(task.id);
415
+ const run = { cancelled: false, mode: "run" };
416
+ this.runs.set(task.id, run);
417
+ this.emit({ type: "review", id: task.id, state: "running", at: now() });
418
+ this.logger.task(task.id, "info", "review started", {
419
+ agent: agent.name,
420
+ cli: agent.cli,
421
+ model: agent.model,
422
+ });
423
+ this.appendMarker(task.id, `review started — ${agent.name} (${agent.cli})`);
424
+ const mission = reviewMission(task, agent, workdir, baseBranch);
425
+ // A cancel that landed before the spawn must not still start a run.
426
+ if (run.cancelled) {
427
+ this.runs.delete(task.id);
428
+ this.emit({ type: "review", id: task.id, state: "cancelled", at: now() });
429
+ return { ok: false, skipped: true, reason: "review cancelled" };
430
+ }
431
+ this.spawnDurable(task.id, agent, mission, workdir, run, "run");
432
+ return { ok: true };
433
+ }
434
+ /**
435
+ * Spawn (or re-start) a durable review turn in the AgentRunner and arm its
436
+ * hard timeout. Completion is event-driven: `AgentRunner` fires `onReviewDone`
437
+ * (0288), which lands in `handleReviewDone` — so a review that is in flight
438
+ * across a server reload is finalized by the NEW process's `ReviewManager`
439
+ * rather than dying with a `runPrompt` continuation.
440
+ */
441
+ spawnDurable(taskId, agent, mission, workdir, run, mode, humanEntry) {
442
+ const started = this.runner?.startReview(this.reviewKey(taskId), agent, mission, workdir, {
443
+ reset: mode === "run",
444
+ reviewKind: mode,
445
+ humanEntry,
446
+ });
447
+ if (!started || !started.ok) {
448
+ this.runs.delete(taskId);
449
+ this.emit({
450
+ type: "review",
451
+ id: taskId,
452
+ state: "failed",
453
+ at: now(),
454
+ error: started?.reason ?? "could not start the review",
455
+ });
456
+ this.appendMarker(taskId, `✗ review could not start: ${started?.reason ?? "unknown error"}`);
457
+ this.logger.task(taskId, "error", "review failed to start", {
458
+ error: started?.reason ?? "unknown",
459
+ });
460
+ return;
461
+ }
462
+ // A fresh spawn expresses fresh intent — drop any cancellation recorded by
463
+ // an earlier (possibly pre-reload) abandon, so the new turn is not
464
+ // instantly treated as cancelled.
465
+ this.clearCancelled(taskId);
466
+ run.timer = setTimeout(() => {
467
+ run.timedOut = true;
468
+ this.runner?.stop(this.reviewKey(taskId));
469
+ }, REVIEW_TIMEOUT_MS);
470
+ run.timer.unref?.();
471
+ }
472
+ /**
473
+ * Re-arm the hard review timeout for durable review sessions re-attached
474
+ * from the previous server (0288). The spawning process's timer died with the
475
+ * reload, so without this an adopted review could hang forever. Also registers
476
+ * each adopted turn as a `Run` so cancellation and the run/chat mode keep
477
+ * working across the handoff. Called once at boot, after the runner has
478
+ * adopted still-alive review children.
479
+ */
480
+ armAdoptedTimeouts() {
481
+ for (const r of this.runner?.running() ?? []) {
482
+ if (!r.id.startsWith(REVIEW_SESSION_ID_PREFIX))
483
+ continue;
484
+ const taskId = taskIdFromReviewKey(r.id);
485
+ if (this.runs.has(taskId))
486
+ continue;
487
+ const run = {
488
+ cancelled: this.isCancelled(taskId),
489
+ mode: this.runner?.reviewKindOf(r.id) ?? "run",
490
+ };
491
+ run.timer = setTimeout(() => {
492
+ run.timedOut = true;
493
+ this.runner?.stop(r.id);
494
+ }, REVIEW_TIMEOUT_MS);
495
+ run.timer.unref?.();
496
+ this.runs.set(taskId, run);
497
+ }
498
+ }
499
+ /**
500
+ * Re-spawn reviews that a real (non-reload) server shutdown killed. On
501
+ * shutdown `handle.close()` runs `reviews.cancelAll()`, which SIGKILLs every
502
+ * in-flight review agent (unlike the engineer, which is left running and
503
+ * re-adopted by PID). The next boot's `adoptRunningAgents()` then finds
504
+ * nothing to re-attach, so the task sits in `review` forever with a stale or
505
+ * missing report. This closes that gap — the review equivalent of
506
+ * `recoverPendingHandoffs()`.
507
+ *
508
+ * A task is re-reviewed when it is in `review`, has a reviewer enabled and a
509
+ * worktree, is not already running/adopted, was not deliberately cancelled,
510
+ * AND its stored report is missing or older than the branch's HEAD commit
511
+ * (i.e. the engineer pushed a fix the report never saw). Runs are staggered
512
+ * so a batch doesn't spawn every reviewer at once.
513
+ *
514
+ * Must be called AFTER the index has populated — it reads `index.getTasks()`.
515
+ */
516
+ recoverInterruptedReviews(tasks) {
517
+ let delay = 0;
518
+ for (const task of tasks) {
519
+ if (task.status !== "review")
520
+ continue;
521
+ if (this.isRunning(task.id))
522
+ continue;
523
+ if (this.isCancelled(task.id))
524
+ continue;
525
+ if (!task.branch)
526
+ continue;
527
+ if (!resolveReviewerForTask(this.config, task))
528
+ continue;
529
+ const workdir = worktreePathForBranch(this.config.root, task.branch);
530
+ if (!workdir)
531
+ continue;
532
+ const report = this.read(task.id);
533
+ const headAt = headCommitISO(workdir);
534
+ const stale = !report || !report.at || (headAt !== null && Date.parse(report.at) < Date.parse(headAt));
535
+ if (!stale)
536
+ continue;
537
+ this.appendMarker(task.id, report
538
+ ? "re-reviewing — the previous review was interrupted and the branch has newer commits"
539
+ : "re-reviewing — the previous review was interrupted before writing a report");
540
+ setTimeout(() => {
541
+ // Guard again at fire time: a concurrent auto-review (engineer.exited)
542
+ // or a human "Review again" may have started one in the interim.
543
+ if (this.isRunning(task.id))
544
+ return;
545
+ const fresh = this.index?.getTask(task.id) ?? task;
546
+ if (fresh.status === "review")
547
+ void this.run(fresh);
548
+ }, delay).unref?.();
549
+ delay += 3000;
550
+ }
551
+ }
552
+ /** Durable review-turn completion hook (0288). */
553
+ handleReviewDone(sessionKey, exitedCleanly = false, reviewKind) {
554
+ const taskId = taskIdFromReviewKey(sessionKey);
555
+ const run = this.runs.get(taskId);
556
+ // The run/chat distinction and cancel state are persisted (reviewKind in
557
+ // the durable registry, cancellation in the cancelled-marker file), so an
558
+ // ADOPTED turn — whose `Run` record died with the previous process — is
559
+ // finalized in the right mode and honoured if it was cancelled.
560
+ const mode = reviewKind ?? run?.mode ?? "run";
561
+ const cancelled = run?.cancelled ?? this.isCancelled(taskId);
562
+ const timedOut = run?.timedOut ?? false;
563
+ if (run) {
564
+ if (run.timer)
565
+ clearTimeout(run.timer);
566
+ this.runs.delete(taskId);
567
+ }
568
+ if (cancelled) {
569
+ this.clearCancelled(taskId);
570
+ this.logger.task(taskId, "info", "review cancelled — task left review");
571
+ this.emit({ type: "review", id: taskId, state: "cancelled", at: now() });
572
+ return;
573
+ }
574
+ const agent = resolveReviewer(this.config);
575
+ const task = this.index?.getTask(taskId) ?? null;
576
+ if (!task || !agent) {
577
+ // Cannot finalize — no task/agent to build the report from (e.g. the task
578
+ // was deleted). Surface it and leave the durable session intact.
579
+ this.emit({
580
+ type: "review",
581
+ id: taskId,
582
+ state: "failed",
583
+ at: now(),
584
+ error: "could not resolve the task or review agent after the review ran",
585
+ });
586
+ return;
587
+ }
588
+ const session = this.runner?.output(sessionKey) ?? null;
589
+ const lines = session?.lines ?? [];
590
+ if (mode === "run") {
591
+ this.finalizeRun(task, agent, lines, session, timedOut);
592
+ }
593
+ else {
594
+ this.finalizeChat(task, agent, lines, session, timedOut);
595
+ }
596
+ }
597
+ /** Finalize a fresh review run from its durable session transcript. */
598
+ finalizeRun(task, agent, lines, session, timedOut) {
599
+ const hasOutput = sessionHasUsableOutput(agent.cli, lines);
600
+ const ok = !timedOut && hasOutput;
601
+ const error = timedOut
602
+ ? `the ${agent.cli} agent timed out after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s`
603
+ : ok
604
+ ? undefined
605
+ : `the ${agent.cli} agent exited without output: ${sessionErrorText(lines)}`;
606
+ const result = {
607
+ ok,
608
+ // The full raw transcript isn't kept; extract the report from the parsed
609
+ // session so it is usable even after a reload rebuilt the transcript.
610
+ output: ok ? extractReportFromSession(agent.cli, lines, "") : undefined,
611
+ error,
612
+ elapsedMs: session?.accumulatedMs ?? 0,
613
+ inputTokens: session?.inputTokens,
614
+ outputTokens: session?.outputTokens,
615
+ totalTokens: session?.tokens,
616
+ costUsd: session?.costUsd,
617
+ };
618
+ const reportText = result.output?.trim() ?? "";
619
+ const state = ok && reportText ? "ok" : "failed";
620
+ const body = state === "ok" ? reportText : `The review agent produced no report: ${error ?? "no report"}`;
621
+ const report = {
622
+ id: task.id,
623
+ at: now(),
624
+ agent: agent.name,
625
+ cli: agent.cli,
626
+ model: agent.model,
627
+ branch: task.branch,
628
+ state,
629
+ markdown: body.slice(0, REPORT_CHARS),
630
+ };
631
+ this.write(report);
632
+ this.appendMarker(task.id, state === "ok" ? "✓ review complete" : `✗ review failed: ${error ?? "no report"}`);
633
+ if (state === "ok") {
634
+ this.logger.task(task.id, "info", "review completed");
635
+ }
636
+ else {
637
+ this.logger.task(task.id, "error", "review failed", { error: error ?? "no report" });
638
+ }
639
+ this.emit({
640
+ type: "review",
641
+ id: task.id,
642
+ state: state === "ok" ? "ready" : "failed",
643
+ at: report.at,
644
+ ...(state === "failed" ? { error } : {}),
645
+ });
646
+ this.enforceStillInReview(task);
647
+ // The review agent produced no report at all (crash, timeout) — there's no
648
+ // verdict to auto-bounce on and nothing for the engineer to act on, so
649
+ // bouncing would just burn a round on silence. Escalate straight to a
650
+ // human, same as the relevance-flag escalation below.
651
+ if (state === "failed" && !task.needsInput) {
652
+ try {
653
+ patchTaskFile(this.config, task.absPath, {
654
+ needsInput: true,
655
+ needsInputReason: "review-failed",
656
+ });
657
+ this.emit({
658
+ type: "task.corrected",
659
+ id: task.id,
660
+ path: task.path,
661
+ note: `Review agent failed to produce a report (${error ?? "unknown error"}) — needs a human to retry or investigate.`,
662
+ at: now(),
663
+ });
664
+ }
665
+ catch (err) {
666
+ console.error(`[repoos] could not escalate failed review for #${task.id}: ${err.message}`);
667
+ }
668
+ }
669
+ // A review that finally comes back clean resolves the exact problem that
670
+ // escalated this task to needsInput in the first place (the reviewer
671
+ // crashing/timing out) — clear it so the task doesn't sit flagged forever
672
+ // on a stale failure. Scoped to reason "review-failed" specifically: an
673
+ // unrelated needsInput (e.g. a CTO policy question) must survive a review
674
+ // that happens to succeed around the same time.
675
+ if (state === "ok" && task.needsInput && task.needsInputReason === "review-failed") {
676
+ try {
677
+ patchTaskFile(this.config, task.absPath, { needsInput: false, needsInputReason: null });
678
+ }
679
+ catch (err) {
680
+ console.error(`[repoos] could not clear stale needs_input for #${task.id}: ${err.message}`);
681
+ }
682
+ }
683
+ // Auto-bounce if review is ok and verdict is not "good to go"
684
+ // Fire-and-forget (don't await) so the review completion isn't delayed
685
+ if (state === "ok") {
686
+ const verdict = parseVerdict(report.markdown);
687
+ if (verdict) {
688
+ this.autoBounce(task, report, verdict).catch((err) => {
689
+ console.error(`[repoos] uncaught error in auto-bounce for #${task.id}: ${err.message}`);
690
+ });
691
+ }
692
+ }
693
+ // Record the review session to the database
694
+ this.recordReviewSession(task.id, agent, result, report.at, state === "ok");
695
+ // Track every COMPLETED review pass (a real report, not a failed/empty
696
+ // run) so the badge D/R counters reflect reality. See review_rounds
697
+ // (auto-bounce bookkeeping, capped at MAX_AUTO_REVIEW_ROUNDS) vs
698
+ // review_passes (all successful passes).
699
+ if (state === "ok")
700
+ this.bumpReviewPasses(task);
701
+ }
702
+ /**
703
+ * Increment the `review_passes` frontmatter counter. Unlike `review_rounds`
704
+ * (which counts only auto-bounced rounds and caps at MAX_AUTO_REVIEW_ROUNDS),
705
+ * this counts every completed review pass — auto and manual ("Review again")
706
+ * alike — so the UI badge (`D# · R#`) matches the true number of review
707
+ * sessions.
708
+ *
709
+ * The write also bumps `updated_at` (a field the live index's `diff()`
710
+ * watches, since it omits `extra`) and commits the file, so the drawer's
711
+ * badge refreshes live and `main` stays clean/mergeable. Best-effort, never
712
+ * fails.
713
+ */
714
+ bumpReviewPasses(task) {
715
+ try {
716
+ const raw = readFileSync(task.absPath, "utf8");
717
+ const doc = parseDocument(raw);
718
+ const current = doc.data.review_passes;
719
+ const passes = typeof current === "number" && Number.isFinite(current)
720
+ ? Math.max(0, Math.floor(current))
721
+ : 0;
722
+ doc.data.review_passes = passes + 1;
723
+ doc.data.updated_at = utcTimestamp();
724
+ const keys = Object.keys(doc.data).filter((k) => k !== "review_passes" && k !== "updated_at");
725
+ keys.unshift("updated_at", "review_passes");
726
+ writeFileSync(task.absPath, serializeDocument(doc.data, `\n${doc.body}\n`, keys));
727
+ commitTaskFile(this.config.root, task.absPath, `docs(${task.id}): update task`);
728
+ }
729
+ catch (err) {
730
+ console.error(`[repoos] could not update review_passes for #${task.id}: ${err.message}`);
731
+ }
732
+ }
733
+ /** Record a review session to the database. Best-effort, never fails. */
734
+ recordReviewSession(taskId, agent, result, completedAt, success) {
735
+ if (!this.db)
736
+ return;
737
+ try {
738
+ const sessionId = `review:${taskId}-${completedAt}`;
739
+ const elapsedMs = result.elapsedMs ?? 0;
740
+ const { costUsd, costSource } = reviewUsage(agent, result);
741
+ this.db.upsertSession({
742
+ sessionId,
743
+ sessionType: "reviewer",
744
+ taskId,
745
+ agent: agent.name,
746
+ model: agent.model,
747
+ codingAgent: agent.cli,
748
+ startedAt: new Date(Date.parse(completedAt) - elapsedMs).toISOString(),
749
+ endedAt: completedAt,
750
+ elapsedMs,
751
+ inputTokens: result.inputTokens ?? undefined,
752
+ outputTokens: result.outputTokens ?? undefined,
753
+ totalTokens: result.totalTokens ?? undefined,
754
+ costUsd,
755
+ costSource,
756
+ status: success ? "finished" : "errored",
757
+ lastActivityAt: completedAt,
758
+ });
759
+ }
760
+ catch {
761
+ // Database recording is best-effort and must never crash.
762
+ }
763
+ }
764
+ /**
765
+ * Send a follow-up chat turn to the reviewer. The human's message joins the
766
+ * reviewer conversation (never the engineer session), and the reviewer's
767
+ * answer streams into the same conversation. Chat turns do not overwrite the
768
+ * stored report — the verdict callout keeps describing the last full review.
769
+ */
770
+ async send(task, text) {
771
+ if (this.isRunning(task.id)) {
772
+ return { ok: false, reason: "a review is already running for this task" };
773
+ }
774
+ const agent = resolveReviewerForTask(this.config, task);
775
+ if (!agent) {
776
+ return { ok: false, reason: "the review agent is disabled" };
777
+ }
778
+ const workdir = task.branch ? worktreePathForBranch(this.config.root, task.branch) : null;
779
+ if (!workdir) {
780
+ return { ok: false, reason: "the task has no worktree to review" };
781
+ }
782
+ const baseBranch = currentBranch(this.config.root) ?? "main";
783
+ // The human's message starts the turn and streams into the reviewer
784
+ // conversation under `review:<taskId>`.
785
+ this.emitHumanEntry(task.id, { type: "human", text });
786
+ const run = { cancelled: false, mode: "chat" };
787
+ this.runs.set(task.id, run);
788
+ this.emit({ type: "review", id: task.id, state: "running", at: now() });
789
+ const mission = reviewFollowupMission(task, agent, workdir, baseBranch, text, this.read(task.id));
790
+ if (run.cancelled) {
791
+ this.runs.delete(task.id);
792
+ this.emit({ type: "review", id: task.id, state: "cancelled", at: now() });
793
+ return { ok: false, skipped: true, reason: "review cancelled" };
794
+ }
795
+ const humanEntry = { type: "human", text };
796
+ this.spawnDurable(task.id, agent, mission, workdir, run, "chat", humanEntry);
797
+ return { ok: true };
798
+ }
799
+ /** Finalize a follow-up chat turn from its durable session transcript. */
800
+ finalizeChat(task, agent, lines, session, timedOut) {
801
+ const hasOutput = sessionHasUsableOutput(agent.cli, lines);
802
+ const ok = !timedOut && hasOutput;
803
+ const error = timedOut
804
+ ? `the ${agent.cli} agent timed out after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s`
805
+ : ok
806
+ ? undefined
807
+ : `the ${agent.cli} agent exited without output: ${sessionErrorText(lines)}`;
808
+ const result = {
809
+ ok,
810
+ output: ok ? extractReportFromSession(agent.cli, lines, "") : undefined,
811
+ error,
812
+ elapsedMs: session?.accumulatedMs ?? 0,
813
+ inputTokens: session?.inputTokens,
814
+ outputTokens: session?.outputTokens,
815
+ totalTokens: session?.tokens,
816
+ costUsd: session?.costUsd,
817
+ };
818
+ const completedAt = now();
819
+ const success = result.ok;
820
+ if (!result.ok) {
821
+ this.appendMarker(task.id, `✗ the reviewer could not answer: ${result.error ?? "unknown error"}`);
822
+ this.emit({
823
+ type: "review",
824
+ id: task.id,
825
+ state: "failed",
826
+ at: completedAt,
827
+ ...(result.error ? { error: result.error } : {}),
828
+ });
829
+ this.enforceStillInReview(task);
830
+ this.recordReviewChatTurn(task.id, agent, result, completedAt, false);
831
+ return;
832
+ }
833
+ this.emit({ type: "review", id: task.id, state: "ready", at: completedAt });
834
+ this.enforceStillInReview(task);
835
+ this.recordReviewChatTurn(task.id, agent, result, completedAt, true);
836
+ }
837
+ /** Record a review chat turn to the database. Best-effort, never fails. */
838
+ recordReviewChatTurn(taskId, agent, result, completedAt, success) {
839
+ if (!this.db)
840
+ return;
841
+ try {
842
+ const sessionId = `review:${taskId}-chat-${completedAt}`;
843
+ const elapsedMs = result.elapsedMs ?? 0;
844
+ const { costUsd, costSource } = reviewUsage(agent, result);
845
+ this.db.upsertSession({
846
+ sessionId,
847
+ sessionType: "reviewer",
848
+ taskId,
849
+ agent: agent.name,
850
+ model: agent.model,
851
+ codingAgent: agent.cli,
852
+ startedAt: new Date(Date.parse(completedAt) - elapsedMs).toISOString(),
853
+ endedAt: completedAt,
854
+ elapsedMs,
855
+ inputTokens: result.inputTokens ?? undefined,
856
+ outputTokens: result.outputTokens ?? undefined,
857
+ totalTokens: result.totalTokens ?? undefined,
858
+ costUsd,
859
+ costSource,
860
+ status: success ? "finished" : "errored",
861
+ lastActivityAt: completedAt,
862
+ });
863
+ }
864
+ catch {
865
+ // Database recording is best-effort and must never crash.
866
+ }
867
+ }
868
+ /** The retained reviewer conversation for a task, or [] when none exists. */
869
+ session(taskId) {
870
+ const session = this.runner?.output(this.reviewKey(taskId));
871
+ if (session)
872
+ return session.lines;
873
+ return [];
874
+ }
875
+ /**
876
+ * Abandon a task's review: kill the child if one is running and mark the run
877
+ * cancelled so it writes nothing. Called when the task leaves `review` by a
878
+ * human route (the close-out, a status change from the API), where both the
879
+ * report and the status guard have stopped being relevant — and where the
880
+ * worktree is about to be removed out from under the child.
881
+ */
882
+ cancel(taskId) {
883
+ const run = this.runs.get(taskId);
884
+ const key = this.reviewKey(taskId);
885
+ if (!run && !this.runner?.isRunning(key))
886
+ return;
887
+ if (run)
888
+ run.cancelled = true;
889
+ // Persist the cancellation so it survives a reload: an adopted review whose
890
+ // `Run` record died with the previous process must still finalize as
891
+ // "cancelled", never as a spurious failed report (0288).
892
+ this.markCancelled(taskId);
893
+ // Stop the durable child by PID (works for adopted, proc-less, entries too).
894
+ try {
895
+ this.runner?.stop(key);
896
+ }
897
+ catch {
898
+ /* already gone */
899
+ }
900
+ if (run?.timer)
901
+ clearTimeout(run.timer);
902
+ }
903
+ /** Cancel every in-flight review (server shutdown). */
904
+ cancelAll() {
905
+ for (const id of [...this.runs.keys()])
906
+ this.cancel(id);
907
+ // Also stop adopted review sessions re-attached after a reload (0288).
908
+ for (const r of this.runner?.running() ?? []) {
909
+ if (r.id.startsWith(REVIEW_SESSION_ID_PREFIX))
910
+ this.cancel(r.id.slice(REVIEW_SESSION_ID_PREFIX.length));
911
+ }
912
+ }
913
+ // ---- Durable "cancelled review" marker (0288) ----
914
+ //
915
+ // `this.runs` is per-server-process bookkeeping that dies on a reload. A
916
+ // human abandoning a review (task leaving `review`, server shutdown) records
917
+ // the task in a small durable marker file so that even an ADOPTED review —
918
+ // re-attached by the next process with no `Run` record — finalizes as
919
+ // cancelled rather than writing a spurious failed report.
920
+ cancelledFilePath() {
921
+ return join(this.config.root, this.config.cacheDir, "reviews", ".cancelled.json");
922
+ }
923
+ readCancelled() {
924
+ try {
925
+ const raw = readFileSync(this.cancelledFilePath(), "utf8");
926
+ const parsed = JSON.parse(raw);
927
+ return new Set(Array.isArray(parsed.taskIds) ? parsed.taskIds : []);
928
+ }
929
+ catch {
930
+ return new Set();
931
+ }
932
+ }
933
+ writeCancelled(ids) {
934
+ try {
935
+ const file = this.cancelledFilePath();
936
+ mkdirSync(dirname(file), { recursive: true });
937
+ writeFileSync(file, JSON.stringify({ taskIds: [...ids] }));
938
+ }
939
+ catch {
940
+ /* best-effort — a lost marker only risks a spurious failed report */
941
+ }
942
+ }
943
+ isCancelled(taskId) {
944
+ return this.readCancelled().has(taskId);
945
+ }
946
+ markCancelled(taskId) {
947
+ const ids = this.readCancelled();
948
+ ids.add(taskId);
949
+ this.writeCancelled(ids);
950
+ }
951
+ clearCancelled(taskId) {
952
+ const ids = this.readCancelled();
953
+ if (!ids.has(taskId))
954
+ return;
955
+ ids.delete(taskId);
956
+ this.writeCancelled(ids);
957
+ }
958
+ /**
959
+ * Auto-bounce logic: if the verdict is "needs some work" or "back to the
960
+ * drawing board" and we haven't exceeded the max rounds, send the findings
961
+ * back to the engineer session and increment the review_rounds counter.
962
+ */
963
+ async autoBounce(task, report, verdict) {
964
+ if (!this.runner || verdict === "good to go") {
965
+ return;
966
+ }
967
+ if (verdict !== "needs some work" && verdict !== "back to the drawing board") {
968
+ return;
969
+ }
970
+ // A relevance flag means the task itself is the problem, not the diff —
971
+ // bouncing back to the engineer to "fix" an obsolete task just burns a
972
+ // review round on nothing. Escalate to a human instead.
973
+ const relevance = parseRelevance(report.markdown);
974
+ if (relevance && relevance !== "still relevant") {
975
+ const note = `Reviewer flagged this task's relevance as "${relevance}" — needs a human scoping decision, not further engineering. See the review report.`;
976
+ this.emit({
977
+ type: "task.corrected",
978
+ id: task.id,
979
+ path: task.path,
980
+ note,
981
+ at: now(),
982
+ });
983
+ console.log(`[repoos] auto-bounce skipped for #${task.id}: relevance=${relevance}`);
984
+ return;
985
+ }
986
+ // Check if the engineer session is resumable
987
+ const engineerSession = this.runner.output(task.id);
988
+ if (!engineerSession) {
989
+ // Session not found or completed, log that human must step in
990
+ const note = "Auto-bounce could not proceed: engineer session is not resumable";
991
+ this.emit({
992
+ type: "task.corrected",
993
+ id: task.id,
994
+ path: task.path,
995
+ note,
996
+ at: now(),
997
+ });
998
+ console.log(`[repoos] auto-bounce blocked for #${task.id}: ${note}`);
999
+ return;
1000
+ }
1001
+ // Get current review_rounds count
1002
+ let reviewRounds = task.extra?.review_rounds;
1003
+ if (typeof reviewRounds !== "number")
1004
+ reviewRounds = 0;
1005
+ // Check if we've exceeded the max rounds
1006
+ if (reviewRounds >= MAX_AUTO_REVIEW_ROUNDS) {
1007
+ const note = `Auto-bounce stopped: reached maximum of ${MAX_AUTO_REVIEW_ROUNDS} review rounds. Human review needed.`;
1008
+ this.emit({
1009
+ type: "task.corrected",
1010
+ id: task.id,
1011
+ path: task.path,
1012
+ note,
1013
+ at: now(),
1014
+ });
1015
+ console.log(`[repoos] auto-bounce max rounds reached for #${task.id}`);
1016
+ return;
1017
+ }
1018
+ // Extract the report sections
1019
+ const sections = extractReportSections(report.markdown);
1020
+ const messageParts = [];
1021
+ messageParts.push(`The automated review found the following (review round ${reviewRounds + 1}):`);
1022
+ messageParts.push("");
1023
+ if (sections.bugs) {
1024
+ messageParts.push("## Bugs");
1025
+ messageParts.push(sections.bugs);
1026
+ messageParts.push("");
1027
+ }
1028
+ if (sections.edgeCases) {
1029
+ messageParts.push("## Edge cases");
1030
+ messageParts.push(sections.edgeCases);
1031
+ messageParts.push("");
1032
+ }
1033
+ if (sections.suggestions) {
1034
+ messageParts.push("## Suggestions");
1035
+ messageParts.push(sections.suggestions);
1036
+ messageParts.push("");
1037
+ }
1038
+ messageParts.push(`Please fix the issues and re-handoff to review (review round ${reviewRounds + 2}).`);
1039
+ const message = messageParts.join("\n");
1040
+ // Start the engineer before changing the task state. If the session cannot
1041
+ // resume, leaving the task in review is honest and does not consume a
1042
+ // review round. Once it does start, the task MUST leave review: otherwise
1043
+ // Move to done is blocked by a real engineer process that the board still
1044
+ // presents as sign-off-ready (#0239).
1045
+ try {
1046
+ const agent = resolveReviewerForTask(this.config, task);
1047
+ if (!agent)
1048
+ return;
1049
+ // Get the engineer agent
1050
+ const { resolveAgentForTask } = await import("./agents.js");
1051
+ const engineerAgent = resolveAgentForTask(this.config, task);
1052
+ if (!engineerAgent)
1053
+ return;
1054
+ const sent = this.runner.send(task.id, message, engineerAgent, { skipBoardDivergence: true });
1055
+ if (!sent.ok) {
1056
+ const note = `Auto-bounce could not start the engineer: ${sent.reason ?? "unknown error"}`;
1057
+ this.emit({
1058
+ type: "task.corrected",
1059
+ id: task.id,
1060
+ path: task.path,
1061
+ note,
1062
+ at: now(),
1063
+ });
1064
+ console.log(`[repoos] auto-bounce blocked for #${task.id}: ${note}`);
1065
+ return;
1066
+ }
1067
+ // Increment the counter in task frontmatter.
1068
+ const newRounds = reviewRounds + 1;
1069
+ // Update the review_rounds counter in the task file
1070
+ try {
1071
+ const raw = readFileSync(task.absPath, "utf8");
1072
+ const doc = parseDocument(raw);
1073
+ doc.data.review_rounds = newRounds;
1074
+ const keys = Object.keys(doc.data).filter((k) => k !== "review_rounds");
1075
+ keys.unshift("review_rounds");
1076
+ writeFileSync(task.absPath, serializeDocument(doc.data, `\n${doc.body}\n`, keys));
1077
+ }
1078
+ catch (err) {
1079
+ console.error(`[repoos] could not update review_rounds for #${task.id}: ${err.message}`);
1080
+ return;
1081
+ }
1082
+ // A successful auto-bounce is engineering work, not a review task. This
1083
+ // status write also records an activity entry and commits the updated
1084
+ // review-round counter. The live index observes it and keeps the board
1085
+ // and completion guards in sync.
1086
+ patchTaskFile(this.config, task.absPath, { status: "active" });
1087
+ this.emit({
1088
+ type: "task.corrected",
1089
+ id: task.id,
1090
+ path: task.path,
1091
+ note: `Auto-bounce sent review findings (round ${newRounds}) to engineer`,
1092
+ at: now(),
1093
+ });
1094
+ console.log(`[repoos] auto-bounce sent findings for #${task.id}, round ${newRounds}`);
1095
+ }
1096
+ catch (err) {
1097
+ const note = `Auto-bounce failed: ${err.message}`;
1098
+ this.emit({
1099
+ type: "task.corrected",
1100
+ id: task.id,
1101
+ path: task.path,
1102
+ note,
1103
+ at: now(),
1104
+ });
1105
+ console.error(`[repoos] auto-bounce error for #${task.id}: ${err.message}`);
1106
+ }
1107
+ }
1108
+ // ---- reviewer conversation (isolated from the engineer session) ----
1109
+ //
1110
+ // The durable reviewer conversation lives in the AgentRunner session under
1111
+ // the `review:<taskId>` key (0288), NOT in this manager — so it survives a
1112
+ // server reload and stays isolated from the engineer's session under the
1113
+ // plain task id (0110). The manager only reaches into that session to read
1114
+ // it (`session`) and to append trusted markers / the human's text.
1115
+ /** The runner session key (== SSE event id) for a task's reviewer chat. */
1116
+ reviewKey(taskId) {
1117
+ return `${REVIEW_SESSION_ID_PREFIX}${taskId}`;
1118
+ }
1119
+ /** Whether a review conversation exists for the task (memory or persisted). */
1120
+ hasSession(taskId) {
1121
+ return this.runner?.hasSession(this.reviewKey(taskId)) ?? false;
1122
+ }
1123
+ /** Drop the task's durable conversation, in memory and on disk. */
1124
+ resetReviewSession(taskId) {
1125
+ this.runner?.clearSession(this.reviewKey(taskId));
1126
+ }
1127
+ /**
1128
+ * Emit the human's follow-up text under the review session id. The durable
1129
+ * transcript records it too (via the runner session's `humanEntry`), and the
1130
+ * live SSE stream is emitted here because the runner does not surface a
1131
+ * seeded human entry on spawn.
1132
+ */
1133
+ emitHumanEntry(taskId, entry) {
1134
+ this.emit({
1135
+ type: "agent.output",
1136
+ id: this.reviewKey(taskId),
1137
+ entry,
1138
+ stream: "out",
1139
+ at: now(),
1140
+ });
1141
+ }
1142
+ /** Append a trusted system marker into the durable review transcript. */
1143
+ appendMarker(taskId, text) {
1144
+ // Delegate to the runner so the marker is recorded in the durable session
1145
+ // AND streamed under `review:<taskId>` (runner.system emits agent.output).
1146
+ this.runner?.system(this.reviewKey(taskId), text);
1147
+ }
1148
+ /** Persist a report, creating `<cacheDir>/reviews/` on first use. */
1149
+ write(report) {
1150
+ const file = this.path(report.id);
1151
+ try {
1152
+ mkdirSync(dirname(file), { recursive: true });
1153
+ const data = {
1154
+ task: report.id,
1155
+ at: report.at,
1156
+ agent: report.agent,
1157
+ cli: report.cli,
1158
+ model: report.model,
1159
+ branch: report.branch,
1160
+ state: report.state,
1161
+ };
1162
+ writeFileSync(file, serializeDocument(data, `\n${report.markdown}\n`, REPORT_KEYS));
1163
+ }
1164
+ catch (err) {
1165
+ console.error(`[repoos] could not write the review for #${report.id}: ${err.message}`);
1166
+ }
1167
+ }
1168
+ /**
1169
+ * Defense in depth for the one thing the review agent must never do. If the
1170
+ * task file says `done` when the review ends, the reviewer moved it there:
1171
+ * every human route out of `review` cancels the run first, so a live run can
1172
+ * only be looking at a task a human has not touched. Put it back and say so.
1173
+ */
1174
+ enforceStillInReview(task) {
1175
+ if (!existsSync(task.absPath))
1176
+ return;
1177
+ let current;
1178
+ try {
1179
+ current = parseTask({
1180
+ content: readFileSync(task.absPath, "utf8"),
1181
+ absPath: task.absPath,
1182
+ root: this.config.root,
1183
+ defaultStatus: this.config.defaultStatus,
1184
+ defaultAssignee: this.config.defaultAssignee,
1185
+ });
1186
+ }
1187
+ catch {
1188
+ return;
1189
+ }
1190
+ if (current.status !== "done")
1191
+ return;
1192
+ this.reverted.set(task.id, Date.now());
1193
+ try {
1194
+ patchTaskFile(this.config, task.absPath, { status: "review" });
1195
+ }
1196
+ catch (err) {
1197
+ this.reverted.delete(task.id);
1198
+ console.error(`[repoos] could not revert #${task.id} to review: ${err.message}`);
1199
+ return;
1200
+ }
1201
+ // Reflect the revert into the index immediately (trusted write — see the
1202
+ // constructor comment on `index`), instead of leaving it to the async
1203
+ // file watcher, which can otherwise process the transition twice under
1204
+ // load and burn the single-use revert claim on a spurious first firing.
1205
+ void this.index?.applyFileChange(task.absPath, { guarded: true });
1206
+ const note = "the review agent moved this task to done — only a human signs a task off, " +
1207
+ "so it was put back in review";
1208
+ this.emit({ type: "task.corrected", id: task.id, path: task.path, note, at: now() });
1209
+ console.error(`[repoos] reverted task #${task.id} to review — ${note}`);
1210
+ }
1211
+ }