@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,1442 @@
1
+ import { json, readBody } from "./utils.js";
2
+ import { agentsForConfig } from "../../core/config.js";
3
+ import { patchTaskFile, deleteTaskFile, PathGuardError, } from "../write.js";
4
+ import { resolveAgentForTask, resolvePmAgent, taskPmPrompt, runPrompt, deriveBranch, isModelOverridePinned, recordOneShotSession, pmCommand, parseOneShotLine, extractOneShotReportText, } from "../agents.js";
5
+ import { markPmWorking, clearPmWorking, withPmWorking, markPmChatSession, isPmWorking, } from "../pm-runs.js";
6
+ import { queuePmImages, dropPmImages } from "../pm-attachments.js";
7
+ import { parseGeneratedTask, pmPrompt, explanationTitle } from "../freeform.js";
8
+ import { getCurrentUser } from "./auth.js";
9
+ import { withOriginalPromptSection } from "../../core/repoos.js";
10
+ import { listInputs } from "../../core/input.js";
11
+ import { commitTaskFile, commitDirtyFiles, dirtyFiles, worktreePathForBranch, ensureWorktree, resetWorktree, getDiffStatsAsync, getDiff, GitDirtyCheckError, ensureHotfix, } from "../../core/git.js";
12
+ import { guardReviewTransition } from "../review-guard.js";
13
+ import { checkGenericStatusPatch } from "../task-transitions.js";
14
+ import { readFileSync, existsSync, statSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { releaseBranchless, isBranchlessReleaseEligible } from "../branchless-release.js";
17
+ import { bootstrap } from "../../core/bootstrap.js";
18
+ import { generateContextPack, resumePreamble } from "../../core/context-pack.js";
19
+ import { mimeForExtension, resolveScreenshot, saveScreenshot } from "../attachments.js";
20
+ import { STATUSES } from "../../core/types.js";
21
+ import { parseTask } from "../../core/task.js";
22
+ import { buildIntegrationSnapshot } from "../integration-status.js";
23
+ import { loadDiffSnapshot } from "../diff-snapshot.js";
24
+ import { previewTargetOptions } from "../preview.js";
25
+ // Helper to add review status to tasks
26
+ function withReviewStatus(task, reviews) {
27
+ return {
28
+ ...task,
29
+ automaticReview: {
30
+ running: reviews.isRunning(task.id),
31
+ enabled: reviews.enabled(),
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Add the preview targets a task can be served from (#0379). The drawer reads
37
+ * this to show the active target's name and, when the task's area matches more
38
+ * than one target, to offer a picker instead of silently previewing the first.
39
+ */
40
+ function withPreviewTargets(task, config) {
41
+ return { ...task, previewTargets: previewTargetOptions(config, task) };
42
+ }
43
+ export const getTasks = (ctx, req, res) => {
44
+ const { config, index, reviews } = ctx;
45
+ const url = new URL(req.url ?? "/", "http://localhost");
46
+ const status = url.searchParams.get("status");
47
+ if (status && !STATUSES.includes(status)) {
48
+ return json(res, 400, { error: `Invalid status "${status}"` });
49
+ }
50
+ const tasks = index
51
+ .getTasks(status ?? undefined)
52
+ .map((t) => withPmWorking(withReviewStatus(withPreviewTargets(t, config), reviews)));
53
+ return json(res, 200, tasks);
54
+ };
55
+ export const createTask = async (ctx, req, res) => {
56
+ const { config, repoos, index, logger } = ctx;
57
+ const body = (await readBody(req));
58
+ if (!body.title || typeof body.title !== "string") {
59
+ return json(res, 400, { error: "title is required" });
60
+ }
61
+ const taskBody = typeof body.body === "string" ? body.body : undefined;
62
+ // The client-side "Save as draft" freeform path posts the raw prompt as the
63
+ // body with status `draft` (no client change allowed, see #0251). Treat that
64
+ // as `originalPrompt` so the raw capture is stored under `## Original prompt`.
65
+ const originalPrompt = body.status === "draft"
66
+ ? typeof body.originalPrompt === "string" && body.originalPrompt
67
+ ? body.originalPrompt
68
+ : taskBody
69
+ : undefined;
70
+ const created = repoos.createTask({
71
+ title: body.title,
72
+ type: body.type,
73
+ area: body.area,
74
+ priority: body.priority,
75
+ assignedTo: body.assignedTo,
76
+ status: body.status,
77
+ body: taskBody,
78
+ originalPrompt,
79
+ createdBy: getCurrentUser(req, config)?.email,
80
+ });
81
+ logger.task(created.id, "info", "Task created", {
82
+ title: created.title,
83
+ type: created.type,
84
+ area: created.area,
85
+ });
86
+ index.applyFileChange(created.absPath);
87
+ commitTaskFile(config.root, created.absPath, `docs(${created.id}): add task`);
88
+ return json(res, 201, index.getTask(created.id));
89
+ };
90
+ export const createFreeformTask = async (ctx, req, res) => {
91
+ const { config, repoos, index, logger, emitEvent } = ctx;
92
+ const body = (await readBody(req));
93
+ const explanation = typeof body?.explanation === "string" ? body.explanation.trim() : "";
94
+ if (!explanation) {
95
+ return json(res, 400, { error: "explanation is required" });
96
+ }
97
+ const runId = typeof body?.runId === "string" && body.runId ? body.runId : null;
98
+ // #0382: when a freeform task is created from a resolved input, carry the
99
+ // input's screenshots onto the new task so they are linked in the task
100
+ // itself. The copy runs BEFORE the PM agent's rewrite so the agent's prompt
101
+ // already contains them (via `## Screenshots` in the draft body), and
102
+ // PROTECTED_SECTIONS preserves the section across the rewrite.
103
+ const sourceInputId = typeof body?.inputId === "string" && body.inputId ? body.inputId : null;
104
+ // #0251: create a draft task with the raw prompt preserved FIRST, then spawn
105
+ // the PM agent asynchronously to flesh it out. The draft survives a PM
106
+ // failure, a slow/unavailable agent, or a bad response — the user's capture
107
+ // is never lost.
108
+ const created = repoos.createTask({
109
+ title: explanationTitle(explanation),
110
+ body: explanation,
111
+ originalPrompt: explanation,
112
+ status: "draft",
113
+ createdBy: getCurrentUser(req, config)?.email,
114
+ });
115
+ logger.task(created.id, "info", "Task created as draft, PM agent will flesh it out", {
116
+ title: created.title,
117
+ });
118
+ index.applyFileChange(created.absPath);
119
+ commitTaskFile(config.root, created.absPath, `docs(${created.id}): add task`);
120
+ // Carry input attachments onto the new task before the PM rewrite (so the
121
+ // PM sees them in the draft body via `## Screenshots`). Each file is
122
+ // re-saved through `saveScreenshot` so it lands under the task's own
123
+ // `.attachments/<taskId>/` folder and gets a numbered name — the task is
124
+ // self-contained and unaffected by later input deletion (#0382).
125
+ //
126
+ // Audit round 2: track every skip explicitly. A missing file or an
127
+ // unsupported MIME on one attachment must not leave the carry silently
128
+ // incomplete — the resulting log line lists the dropped names so a
129
+ // human/agent reviewing the run can recover them rather than discover the
130
+ // gap after the input is gone.
131
+ if (sourceInputId) {
132
+ const input = listInputs(config).find((i) => i.id === sourceInputId);
133
+ if (input) {
134
+ const inputAttDir = join(config.root, config.inputsDir ?? "inputs", ".attachments", input.id);
135
+ let carried = 0;
136
+ const skipped = [];
137
+ for (const att of input.attachments) {
138
+ const srcPath = join(inputAttDir, att.name);
139
+ if (!existsSync(srcPath)) {
140
+ logger.task(created.id, "warn", `Skipping input attachment ${att.name}: file missing on disk`);
141
+ skipped.push(att.name);
142
+ continue;
143
+ }
144
+ const bytes = readFileSync(srcPath);
145
+ const result = saveScreenshot(config, created, {
146
+ name: att.name,
147
+ mime: att.mime,
148
+ data: bytes.toString("base64"),
149
+ });
150
+ if ("error" in result) {
151
+ logger.task(created.id, "warn", `Skipping input attachment ${att.name}: ${result.error}`);
152
+ skipped.push(att.name);
153
+ continue;
154
+ }
155
+ const updated = patchTaskFile(config, created.absPath, { addScreenshot: result });
156
+ index.applyFileChange(updated.absPath);
157
+ carried++;
158
+ }
159
+ if (carried > 0) {
160
+ logger.task(created.id, "info", `Carried ${carried} input attachment(s) onto task`, {
161
+ inputId: sourceInputId,
162
+ });
163
+ }
164
+ if (skipped.length > 0) {
165
+ logger.task(created.id, "warn", `Input carry incomplete: ${skipped.length}/${input.attachments.length} attachments dropped (${skipped.join(", ")})`, { inputId: sourceInputId });
166
+ }
167
+ }
168
+ }
169
+ const freeformAgentName = typeof body?.agentOverride === "string" && body.agentOverride ? body.agentOverride : undefined;
170
+ const freeformCli = typeof body?.cliOverride === "string" && body.cliOverride ? body.cliOverride : undefined;
171
+ const freeformModel = typeof body?.modelOverride === "string" && body.modelOverride ? body.modelOverride : undefined;
172
+ // "default" is the sentinel for "use the configured pm agent's own
173
+ // model" — not a real pin (same bug class as resolveAgentForTask /
174
+ // resolveReviewerForTask / the PM-message override above).
175
+ const freeformModelPinned = isModelOverridePinned(freeformModel);
176
+ const hasFreeformOverride = freeformAgentName || freeformCli || freeformModelPinned;
177
+ let pm;
178
+ if (hasFreeformOverride) {
179
+ const list = agentsForConfig(config);
180
+ const baseName = freeformAgentName || "pm";
181
+ const base = list.find((a) => a.enabled && a.name === baseName) ?? null;
182
+ pm = base
183
+ ? {
184
+ ...base,
185
+ ...(freeformCli ? { cli: freeformCli } : {}),
186
+ ...(freeformModelPinned ? { model: freeformModel } : {}),
187
+ }
188
+ : null;
189
+ }
190
+ else {
191
+ pm = resolvePmAgent(config);
192
+ }
193
+ // No PM agent configured: leave the draft exactly as created (the fallback
194
+ // behavior — the original prompt is already preserved under its heading).
195
+ if (!pm) {
196
+ return json(res, 201, {
197
+ ok: true,
198
+ fallback: true,
199
+ fallbackReason: "no-pm-agent",
200
+ task: withPmWorking(index.getTask(created.id)),
201
+ });
202
+ }
203
+ // 0335: flag the draft as being fleshed out RIGHT NOW — the server-side
204
+ // source of truth for the live "PM is working" indicator on the card and in
205
+ // the task panel. Cleared on every exit path below so a failed run can
206
+ // never leave the task looking like it is still being worked.
207
+ markPmWorking(created.id);
208
+ emitEvent({ type: "task.pmWorking", id: created.id, at: new Date().toISOString() });
209
+ // Spawn the PM agent asynchronously to replace the draft body with the
210
+ // structured version, keeping the `## Original prompt` section intact. The
211
+ // response is returned immediately so the user gets their draft right away.
212
+ void (async () => {
213
+ const prompt = pmPrompt(explanation);
214
+ try {
215
+ const result = await runPrompt(pm, prompt, {
216
+ cwd: config.root,
217
+ // Structured output flags (0335): `promptCommand`'s plain stdout
218
+ // carries no usage figures, which is why the initial PM run used to
219
+ // land in the usage tab as a blank row. `pmCommand` keeps the same
220
+ // authoring-only blast radius while letting runPrompt's foldUsage see
221
+ // real tokens/cost — the treatment the reviewer got in 0273.
222
+ command: pmCommand(pm, prompt, config.root),
223
+ onLine: runId
224
+ ? (line) => {
225
+ // Forward the parsed structured event so the freeform progress
226
+ // view renders clean text, not raw JSONL.
227
+ emitEvent({
228
+ type: "agent.output",
229
+ id: runId,
230
+ entry: parseOneShotLine(pm.cli, line) ?? { s: "out", d: line },
231
+ stream: "out",
232
+ at: new Date().toISOString(),
233
+ });
234
+ }
235
+ : undefined,
236
+ });
237
+ // Book the PM authoring pass under the task (0311) — it aggregates into
238
+ // the drawer's "by role" breakdown like per-task PM chats already do.
239
+ // With pmCommand's structured output, `result` now carries the real
240
+ // tokens/cost the CLI reported, so this session shows figures.
241
+ recordOneShotSession(config.root, pm, result, {
242
+ sessionType: "pm",
243
+ taskId: created.id,
244
+ sessionId: `pm-freeform:${created.id}:${new Date().toISOString()}`,
245
+ });
246
+ // Structured engines interleave step-by-step narration with the final
247
+ // answer — isolate the last text event before parsing the task fields.
248
+ const output = extractOneShotReportText(pm.cli, result.output ?? "");
249
+ if (!result.ok || !output) {
250
+ const reason = result.error ?? "the PM agent returned no usable output";
251
+ logger.task(created.id, "warn", "PM agent failed; keeping draft with original prompt", {
252
+ reason,
253
+ });
254
+ // 0320: nothing will promote this draft now — tell every client so
255
+ // its "AI creation in flight" marker is dropped and a later manual
256
+ // move of the stale draft cannot falsely flag the card.
257
+ emitEvent({
258
+ type: "task.aiCreateFailed",
259
+ id: created.id,
260
+ reason,
261
+ at: new Date().toISOString(),
262
+ });
263
+ return;
264
+ }
265
+ const fields = parseGeneratedTask(output);
266
+ // #0345: a response with no frontmatter is not the requested file
267
+ // content — it's typically the agent narrating what it (claims to
268
+ // have) done instead of emitting the file. Treat it as a failed
269
+ // generation and keep the draft untouched, rather than overwriting the
270
+ // user's original prompt and structure with the agent's stray prose.
271
+ if (!fields.title || !fields.body || !fields.hadFrontmatter) {
272
+ logger.task(created.id, "warn", "PM agent returned unusable output; keeping draft", {
273
+ hadFrontmatter: fields.hadFrontmatter,
274
+ });
275
+ emitEvent({
276
+ type: "task.aiCreateFailed",
277
+ id: created.id,
278
+ reason: "the PM agent returned unusable output",
279
+ at: new Date().toISOString(),
280
+ });
281
+ return;
282
+ }
283
+ // Keep the raw prompt section, then promote the fleshed-out task to the
284
+ // config's default status (usually "inbox") so it lands on the board like
285
+ // the pre-0251 flow did, instead of lingering as a draft.
286
+ const finalBody = withOriginalPromptSection(fields.body, explanation);
287
+ const updated = patchTaskFile(config, created.absPath, {
288
+ title: fields.title,
289
+ type: fields.type,
290
+ priority: fields.priority,
291
+ area: fields.area,
292
+ assignedTo: fields.assignedTo,
293
+ body: finalBody,
294
+ status: config.defaultStatus,
295
+ }, { onStatusChange: ctx.onServerStatusChange });
296
+ index.applyFileChange(updated.absPath);
297
+ logger.task(created.id, "info", "PM agent fleshed out draft task", {
298
+ title: updated.title,
299
+ });
300
+ }
301
+ catch (err) {
302
+ const reason = err instanceof Error ? err.message : String(err);
303
+ logger.task(created.id, "warn", "PM agent update failed; keeping draft with original prompt", {
304
+ reason,
305
+ });
306
+ emitEvent({
307
+ type: "task.aiCreateFailed",
308
+ id: created.id,
309
+ reason,
310
+ at: new Date().toISOString(),
311
+ });
312
+ }
313
+ finally {
314
+ // 0335: cleared on EVERY exit path — success (the promotion's own
315
+ // task.updated event lands first, so the card swaps its indicator for
316
+ // its new column), failure, or a thrown error — so the indicator can
317
+ // never get stuck showing "working". 0381: a live PM chat session on
318
+ // this task keeps the flag up — only emit pmFinished when nothing
319
+ // else is still working it.
320
+ clearPmWorking(created.id);
321
+ if (!isPmWorking(created.id)) {
322
+ emitEvent({ type: "task.pmFinished", id: created.id, at: new Date().toISOString() });
323
+ }
324
+ }
325
+ })();
326
+ return json(res, 201, {
327
+ ok: true,
328
+ fallback: false,
329
+ task: withPmWorking(index.getTask(created.id)),
330
+ });
331
+ };
332
+ export const getTask = (ctx, _req, res, params) => {
333
+ const { config, index, previews, reviews } = ctx;
334
+ const id = params.param1;
335
+ const t = index.getTask(id);
336
+ return t
337
+ ? json(res, 200, {
338
+ ...withPmWorking(withReviewStatus(withPreviewTargets(t, config), reviews)),
339
+ preview: previews.get(t.id) ?? null,
340
+ })
341
+ : json(res, 404, { error: `Task #${id} not found` });
342
+ };
343
+ export const patchTask = async (ctx, req, res, params) => {
344
+ const { config, index, reviews, runner, logger, onServerStatusChange, syncTaskBranch } = ctx;
345
+ const id = params.param1;
346
+ const existing = index.getTask(id);
347
+ if (!existing) {
348
+ return json(res, 404, { error: `Task #${id} not found` });
349
+ }
350
+ const body = (await readBody(req));
351
+ const prevStatus = existing.status;
352
+ if (body.status === "done" && prevStatus !== "done") {
353
+ if (reviews.isRunning(existing.id)) {
354
+ return json(res, 409, {
355
+ error: `Task #${existing.id} is waiting for automatic review to finish`,
356
+ });
357
+ }
358
+ return json(res, 400, {
359
+ error: `Use POST /api/tasks/${existing.id}/done to complete a review task`,
360
+ });
361
+ }
362
+ if (body.status && body.status !== prevStatus) {
363
+ // The lifecycle audit's transition table: a bare status write may only
364
+ // perform the six edges with no side effect requiring a dedicated
365
+ // action endpoint (start/pause/done/abandon/reopen). Rejects every other
366
+ // pair outright — including every skip-a-step jump and unreviewed
367
+ // backward move — regardless of whether the request came from the task
368
+ // drawer's dropdown, board drag-drop, or a direct API call.
369
+ const check = checkGenericStatusPatch(prevStatus, body.status);
370
+ if (!check.ok) {
371
+ return json(res, 400, {
372
+ error: `Cannot move task #${existing.id} from ${prevStatus} to ${body.status}: ${check.reason}`,
373
+ });
374
+ }
375
+ }
376
+ if (body.status === "review" && prevStatus !== "review") {
377
+ // #0210: any transition into `review` must pass the same commit+validate
378
+ // gate the trusted handoff path enforces — never silently leave an
379
+ // uncommitted worktree, and never allow a vacuous (zero source changes)
380
+ // transition unless the task opts out via no_source_change.
381
+ const gate = await guardReviewTransition(config, existing);
382
+ if (!gate.ok) {
383
+ return json(res, 400, {
384
+ error: `Cannot move task #${existing.id} to review: ${gate.detail}`,
385
+ });
386
+ }
387
+ }
388
+ const updated = patchTaskFile(config, existing.absPath, body, {
389
+ onStatusChange: onServerStatusChange,
390
+ });
391
+ if (body.status && body.status !== prevStatus) {
392
+ logger.task(id, "info", `Task status changed`, {
393
+ from: prevStatus,
394
+ to: body.status,
395
+ });
396
+ }
397
+ // Guarded: the #0210 gate already ran above for transitions into review.
398
+ index.applyFileChange(updated.absPath, { guarded: true });
399
+ if (prevStatus !== "review" &&
400
+ updated.status === "review" &&
401
+ updated.branch &&
402
+ !runner.isRunning(updated.id)) {
403
+ void syncTaskBranch(updated);
404
+ }
405
+ return json(res, 200, index.getTask(updated.id));
406
+ };
407
+ export const deleteTask = async (ctx, _req, res, params) => {
408
+ const { config, index, logger, previews } = ctx;
409
+ const id = params.param1;
410
+ const existing = index.getTask(id);
411
+ if (!existing) {
412
+ return json(res, 404, { error: `Task #${id} not found` });
413
+ }
414
+ await previews.stop(id);
415
+ try {
416
+ deleteTaskFile(config, existing.absPath);
417
+ }
418
+ catch (err) {
419
+ if (err instanceof PathGuardError) {
420
+ return json(res, 400, { error: err.message });
421
+ }
422
+ return json(res, 404, { error: `Task #${id} not found` });
423
+ }
424
+ logger.task(id, "info", "Task deleted", { title: existing.title });
425
+ index.applyFileDelete(existing.absPath);
426
+ return json(res, 200, { ok: true });
427
+ };
428
+ export const getScreenshot = (ctx, _req, res, params) => {
429
+ const { config } = ctx;
430
+ const taskId = params.param1;
431
+ const filename = params.param2;
432
+ const abs = resolveScreenshot(config, taskId, filename);
433
+ if (!abs) {
434
+ return json(res, 404, { error: "Attachment not found" });
435
+ }
436
+ const mime = mimeForExtension(abs);
437
+ res.writeHead(200, {
438
+ "Content-Type": mime ?? "application/octet-stream",
439
+ "Cache-Control": "no-cache",
440
+ "Access-Control-Allow-Origin": "*",
441
+ });
442
+ res.end(readFileSync(abs));
443
+ };
444
+ export const uploadScreenshot = async (ctx, req, res, params) => {
445
+ const { config, index } = ctx;
446
+ const taskId = params.param1;
447
+ const task = index.getTask(taskId);
448
+ if (!task) {
449
+ return json(res, 404, { error: `Task #${taskId} not found` });
450
+ }
451
+ const body = (await readBody(req));
452
+ const result = saveScreenshot(config, task, body ?? {});
453
+ if ("error" in result) {
454
+ return json(res, 400, { error: result.error });
455
+ }
456
+ const updated = patchTaskFile(config, task.absPath, { addScreenshot: result });
457
+ index.applyFileChange(updated.absPath);
458
+ return json(res, 201, { ok: true, attachment: result });
459
+ };
460
+ // Task logs
461
+ export const getTaskLogs = (ctx, _req, res, params) => {
462
+ const { logger } = ctx;
463
+ const id = params.param1;
464
+ const limit = 1000;
465
+ const logs = logger.getTaskLogs(id, limit);
466
+ return json(res, 200, { ok: true, logs });
467
+ };
468
+ // Task output
469
+ export const getTaskOutput = (ctx, _req, res, params) => {
470
+ const { runner } = ctx;
471
+ const id = params.param1;
472
+ const session = runner.output(id);
473
+ return json(res, 200, {
474
+ ok: true,
475
+ lines: session?.lines ?? [],
476
+ stats: runner.stats(id),
477
+ });
478
+ };
479
+ // Task actions: start, pause, message, done, sync
480
+ export const taskAction = async (ctx, req, res, params) => {
481
+ const { config, index, runner, previews, reviews, syncTaskBranch, onServerStatusChange } = ctx;
482
+ const id = params.param1;
483
+ const action = params.param2;
484
+ let existing = index.getTask(id);
485
+ if (!existing) {
486
+ return json(res, 404, { error: `Task #${id} not found` });
487
+ }
488
+ // Hotfix activation switches the root checkout and rewrites this task file.
489
+ // A rapid follow-up /start must use that on-disk state, not a snapshot that
490
+ // was captured before the switch and can still point at a normal worktree.
491
+ if (action === "start") {
492
+ try {
493
+ existing = parseTask({
494
+ content: readFileSync(existing.absPath, "utf8"),
495
+ absPath: existing.absPath,
496
+ root: config.root,
497
+ defaultStatus: config.defaultStatus,
498
+ defaultAssignee: config.defaultAssignee,
499
+ });
500
+ }
501
+ catch (error) {
502
+ return json(res, 500, {
503
+ error: `Could not refresh task #${id} before starting: ${error.message}`,
504
+ });
505
+ }
506
+ }
507
+ if (action === "start") {
508
+ if (existing.status !== "ready" && existing.status !== "active") {
509
+ return json(res, 400, {
510
+ error: `Only ready or paused tasks can be started (#${id} is ${existing.status})`,
511
+ });
512
+ }
513
+ if (runner.isRunning(id)) {
514
+ return json(res, 400, { error: `Task #${id} is already running` });
515
+ }
516
+ const agent = resolveAgentForTask(config, existing);
517
+ if (!agent) {
518
+ return json(res, 400, {
519
+ error: "No enabled engineer agent is configured on the Agents page",
520
+ });
521
+ }
522
+ const body = (await readBody(req));
523
+ const clean = body?.mode === "clean" && !existing.hotfix;
524
+ // A task returned from review needs its repair brief in the initial
525
+ // resumed turn. Sending it as a follow-up would race the agent's start
526
+ // and commonly be rejected while the new turn is already running.
527
+ const instruction = typeof body?.instruction === "string" ? body.instruction.trim() : "";
528
+ const branch = existing.branch || deriveBranch(existing.title);
529
+ if (clean) {
530
+ if (!existing.branch) {
531
+ return json(res, 400, {
532
+ error: `Task #${id} has no worktree yet — start normally instead`,
533
+ });
534
+ }
535
+ if (!resetWorktree(config.root, branch)) {
536
+ return json(res, 400, {
537
+ error: `Could not reset the worktree for ${branch} — is it the main checkout?`,
538
+ });
539
+ }
540
+ }
541
+ const isHotfix = existing.hotfix === true;
542
+ const patch = { status: "active", needsInput: false };
543
+ if (!existing.branch)
544
+ patch.branch = branch;
545
+ // Patch (and commit) the task file in main BEFORE forking the worktree's
546
+ // branch — ensureWorktree forks from main's current committed HEAD, so
547
+ // doing this after would freeze the new branch's task-file copy at the
548
+ // pre-activation state (status: ready, branch: "") forever, since nothing
549
+ // later syncs main's corrected metadata back into an already-forked
550
+ // branch. That stale copy then fails handoff finalization's branch check.
551
+ const updated = patchTaskFile(config, existing.absPath, patch, {
552
+ onStatusChange: onServerStatusChange,
553
+ });
554
+ const wtRes = isHotfix
555
+ ? { ok: true, path: config.root, created: false }
556
+ : ensureWorktree(config.root, branch);
557
+ index.applyFileChange(updated.absPath);
558
+ index.refreshBranches();
559
+ const cwd = wtRes.ok ? wtRes.path : config.root;
560
+ const taskForLaunch = index.getTask(updated.id) ?? updated;
561
+ const bootResult = await bootstrap(config, taskForLaunch, branch, cwd);
562
+ if (!bootResult.ok) {
563
+ return json(res, 500, {
564
+ ok: false,
565
+ error: `Bootstrap failed: ${bootResult.reason ?? "unknown error"}`,
566
+ bootstrap: {
567
+ ok: false,
568
+ durationMs: bootResult.durationMs,
569
+ steps: bootResult.steps.map((s) => ({
570
+ name: s.name,
571
+ ok: s.ok,
572
+ durationMs: s.durationMs,
573
+ detail: s.detail,
574
+ })),
575
+ },
576
+ });
577
+ }
578
+ const pack = generateContextPack(config, taskForLaunch, branch, cwd, bootResult);
579
+ const resumeContext = clean || !existing.branch ? undefined : resumePreamble(config, taskForLaunch, branch, cwd);
580
+ const preamble = [resumeContext, instruction].filter(Boolean).join("\n\n") || undefined;
581
+ const spawnRes = runner.start(taskForLaunch, branch, agent, {
582
+ cwd,
583
+ contextPack: pack.content,
584
+ resumePreamble: preamble,
585
+ });
586
+ return json(res, 200, {
587
+ ok: true,
588
+ task: index.getTask(updated.id),
589
+ branch,
590
+ clean,
591
+ git: wtRes.ok ? "ok" : (wtRes.reason ?? "unknown"),
592
+ worktree: wtRes.ok ? wtRes.path : undefined,
593
+ spawn: {
594
+ ok: spawnRes.ok,
595
+ pid: spawnRes.pid,
596
+ queued: spawnRes.queued,
597
+ reason: spawnRes.reason,
598
+ },
599
+ bootstrap: {
600
+ ok: bootResult.ok,
601
+ durationMs: bootResult.durationMs,
602
+ steps: bootResult.steps.map((s) => ({
603
+ name: s.name,
604
+ ok: s.ok,
605
+ durationMs: s.durationMs,
606
+ })),
607
+ },
608
+ context: {
609
+ cacheHit: pack.cacheHit,
610
+ generationMs: pack.generationMs,
611
+ size: pack.size,
612
+ },
613
+ });
614
+ }
615
+ if (action === "done") {
616
+ // Branch-less release (2026-08-15): a task fixed by a direct commit on
617
+ // main (a hotfix — see #0212, not yet a first-class flow) has nothing to
618
+ // merge. Routing it through the branch-merge close-out pipeline below
619
+ // just dead-ends on "no branch to merge" — that's not a rejection of the
620
+ // task, it's the wrong pipeline for it. This is a separate, self-contained
621
+ // path: verify main is currently green, then release directly. It never
622
+ // touches the job queue or the repo lock, since there is no merge to
623
+ // serialize against other close-outs.
624
+ if (isBranchlessReleaseEligible(existing)) {
625
+ if (runner.isRunning(id)) {
626
+ return json(res, 409, { error: `Task #${id} has an agent turn in progress` });
627
+ }
628
+ const result = await releaseBranchless(config, existing);
629
+ if (!result.ok) {
630
+ return json(res, 400, { error: result.reason });
631
+ }
632
+ index.applyFileChange(result.task.absPath);
633
+ return json(res, 200, index.getTask(id));
634
+ }
635
+ if (existing.status !== "review") {
636
+ return json(res, 400, {
637
+ error: `Only review tasks can be completed (#${id} is ${existing.status})`,
638
+ });
639
+ }
640
+ // A branch-less task in review is unreachable in practice (nothing sets
641
+ // status: review without a branch), but keep the guard as defense in
642
+ // depth — the branch-less release path above only handles non-review
643
+ // statuses, by design, so it must not silently fall through here.
644
+ if (!existing.branch) {
645
+ return json(res, 400, { error: `Task #${id} has no branch to merge` });
646
+ }
647
+ if (runner.isRunning(id)) {
648
+ return json(res, 409, {
649
+ error: `Task #${id} has an agent turn in progress`,
650
+ });
651
+ }
652
+ if (reviews.isRunning(id)) {
653
+ return json(res, 409, {
654
+ error: `Task #${id} is waiting for automatic review to finish`,
655
+ });
656
+ }
657
+ await previews.stop(id);
658
+ reviews.cancel(id);
659
+ void runner.stop(id);
660
+ // Guard against task deletion mid-close-out (0118): re-validate the task
661
+ // exists and still has a branch before enqueueing.
662
+ const taskStillExists = index.getTask(id);
663
+ if (!taskStillExists || !taskStillExists.branch) {
664
+ return json(res, 400, {
665
+ error: `Task #${id} was deleted or lost its branch before close-out could start`,
666
+ });
667
+ }
668
+ // Stale-lock guard (0204): a stale repository lock blocks all close-outs.
669
+ // Check before enqueueing; if stale, clear it automatically so the user
670
+ // doesn't hit a cryptic "could not acquire publication lock" failure.
671
+ const lockPath = join(config.root, ".repoos/close-out.lock");
672
+ if (existsSync(lockPath)) {
673
+ try {
674
+ const lockStat = statSync(lockPath);
675
+ const age = Date.now() - lockStat.mtime.getTime();
676
+ if (age > 60_000) {
677
+ unlinkSync(lockPath);
678
+ }
679
+ }
680
+ catch {
681
+ // Best-effort
682
+ }
683
+ }
684
+ // Dirty-main guard (0204): a dirty working tree on `main` aborts the
685
+ // close-out merge. Check before enqueueing; if dirty files exist and the
686
+ // user has not opted in via "Commit & continue", hand the list back so the
687
+ // UI can show a confirmation modal and pause the close-out. The task stays
688
+ // in review until the user decides.
689
+ //
690
+ // Fails closed (#0211): if the dirty check itself errors or times out we
691
+ // cannot assert the tree is clean, so the close-out is refused rather than
692
+ // enqueued against a tree that git may abort at publish time. An unknown
693
+ // state must never silently look clean.
694
+ let dirty;
695
+ try {
696
+ dirty = await dirtyFiles(config.root);
697
+ }
698
+ catch (err) {
699
+ if (err instanceof GitDirtyCheckError) {
700
+ return json(res, 409, {
701
+ error: `could not verify main is clean before close-out (${err.message}). Retry, or commit/stash main's working tree and try again.`,
702
+ needsCommit: true,
703
+ dirtyFiles: [],
704
+ dirtyCheckFailed: true,
705
+ causeKind: err.causeKind,
706
+ });
707
+ }
708
+ throw err;
709
+ }
710
+ const doneBody = (await readBody(req));
711
+ const commitDirty = doneBody?.commitDirty === true;
712
+ if (dirty.length > 0 && !commitDirty) {
713
+ return json(res, 409, {
714
+ error: `main has ${dirty.length} uncommitted file${dirty.length === 1 ? "" : "s"} blocking close-out`,
715
+ needsCommit: true,
716
+ dirtyFiles: dirty,
717
+ });
718
+ }
719
+ if (dirty.length > 0 && commitDirty) {
720
+ let committed;
721
+ try {
722
+ committed = await commitDirtyFiles(config.root, `chore: checkpoint before close-out (#${id})`);
723
+ }
724
+ catch (err) {
725
+ if (err instanceof GitDirtyCheckError) {
726
+ return json(res, 500, {
727
+ error: `could not re-verify main while committing dirty files (${err.message}). Close-out aborted; nothing was merged.`,
728
+ needsCommit: true,
729
+ dirtyFiles: dirty,
730
+ dirtyCheckFailed: true,
731
+ });
732
+ }
733
+ throw err;
734
+ }
735
+ if (committed.length !== dirty.length) {
736
+ return json(res, 500, {
737
+ error: `auto-commit of ${dirty.length} dirty file${dirty.length === 1 ? "" : "s"} failed on main`,
738
+ needsCommit: true,
739
+ dirtyFiles: dirty,
740
+ });
741
+ }
742
+ }
743
+ // Enqueue the close-out job (idempotent per task).
744
+ const job = ctx.jobCoordinator.enqueue(taskStillExists);
745
+ if (!job) {
746
+ return json(res, 400, { error: `Task #${id} has no branch to merge` });
747
+ }
748
+ // Reflect the new queue entry in the pinned status bar immediately (0207),
749
+ // even before job processing's own snapshot emission picks it up.
750
+ ctx.emitEvent({
751
+ type: "integration",
752
+ pipeline: buildIntegrationSnapshot(ctx.jobCoordinator, {}),
753
+ });
754
+ // Trigger job processing to start the pipeline.
755
+ ctx.triggerJobProcessing();
756
+ // Return the job status to the client.
757
+ return json(res, 200, {
758
+ ok: true,
759
+ job: {
760
+ taskId: job.taskId,
761
+ phase: job.phase,
762
+ enqueuedAt: job.enqueuedAt,
763
+ startedAt: job.startedAt,
764
+ queuePosition: ctx.jobCoordinator.allJobs().findIndex((j) => j.taskId === job.taskId),
765
+ },
766
+ });
767
+ }
768
+ if (action === "sync") {
769
+ if (!existing.branch) {
770
+ return json(res, 400, { error: `Task #${id} has no branch to sync` });
771
+ }
772
+ if (runner.isRunning(id)) {
773
+ return json(res, 409, {
774
+ error: `Task #${id} has an agent turn in progress`,
775
+ });
776
+ }
777
+ if (reviews.isRunning(id)) {
778
+ return json(res, 409, {
779
+ error: `Task #${id} has a review in progress`,
780
+ });
781
+ }
782
+ const sync = await syncTaskBranch(existing);
783
+ index.refreshBranches();
784
+ return json(res, sync.ok ? 200 : 409, {
785
+ ok: sync.ok,
786
+ conflicts: sync.conflicts,
787
+ error: sync.reason,
788
+ });
789
+ }
790
+ if (action === "message") {
791
+ if (existing.status !== "active" && existing.status !== "review") {
792
+ return json(res, 400, {
793
+ error: `Only active or review tasks accept messages (#${id} is ${existing.status})`,
794
+ });
795
+ }
796
+ const agent = resolveAgentForTask(config, existing);
797
+ if (!agent) {
798
+ return json(res, 400, {
799
+ error: "No enabled engineer agent is configured on the Agents page",
800
+ });
801
+ }
802
+ const body = (await readBody(req));
803
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
804
+ if (!text) {
805
+ return json(res, 400, { error: "message text is required" });
806
+ }
807
+ if (existing.needsInput) {
808
+ const cleared = patchTaskFile(config, existing.absPath, { needsInput: false });
809
+ index.applyFileChange(cleared.absPath);
810
+ }
811
+ let preamble;
812
+ if (existing.branch) {
813
+ const wtPath = worktreePathForBranch(config.root, existing.branch);
814
+ if (wtPath) {
815
+ preamble = resumePreamble(config, existing, existing.branch, wtPath) || undefined;
816
+ }
817
+ }
818
+ const sendRes = runner.send(id, text, agent, { resumePreamble: preamble });
819
+ if (!sendRes.ok && sendRes.busy) {
820
+ return json(res, 409, { error: sendRes.reason ?? "agent is busy" });
821
+ }
822
+ if (!sendRes.ok) {
823
+ return json(res, 400, { error: sendRes.reason ?? "could not send message" });
824
+ }
825
+ return json(res, 200, {
826
+ ok: true,
827
+ spawn: { ok: true, pid: sendRes.pid },
828
+ });
829
+ }
830
+ if (action === "pause") {
831
+ if (existing.status !== "active") {
832
+ return json(res, 400, {
833
+ error: `Only active tasks can be paused (#${id} is ${existing.status})`,
834
+ });
835
+ }
836
+ const stopRes = runner.stop(id);
837
+ // A human pause is legitimate: the task stays active with no process, so
838
+ // tell the runner — the task watchdog must never disturb it (#0180).
839
+ runner.markPaused(id);
840
+ const updated = patchTaskFile(config, existing.absPath, {
841
+ needsInput: false,
842
+ }, {
843
+ onStatusChange: onServerStatusChange,
844
+ });
845
+ index.applyFileChange(updated.absPath);
846
+ return json(res, 200, {
847
+ ok: true,
848
+ task: index.getTask(updated.id),
849
+ stopped: stopRes.stopped,
850
+ reason: stopRes.reason,
851
+ });
852
+ }
853
+ // Abandon (lifecycle audit, task-transitions.ts): active or review -> ready,
854
+ // stopping any live agent/review first. Unlike pause (which stays active,
855
+ // preserving the in-progress turn to resume later), abandon is a deliberate
856
+ // "start this task's dev pass over" — the worktree is kept, not deleted, so
857
+ // Start work can still resume from it, but the task no longer reads as
858
+ // in-flight.
859
+ if (action === "abandon") {
860
+ if (existing.status !== "active" && existing.status !== "review") {
861
+ return json(res, 400, {
862
+ error: `Only active or review tasks can be abandoned (#${id} is ${existing.status})`,
863
+ });
864
+ }
865
+ if (existing.status === "review") {
866
+ reviews.cancel(id);
867
+ }
868
+ await previews.stop(id);
869
+ const stopRes = runner.stop(id);
870
+ const updated = patchTaskFile(config, existing.absPath, { status: "ready", needsInput: false }, { onStatusChange: onServerStatusChange });
871
+ index.applyFileChange(updated.absPath);
872
+ return json(res, 200, {
873
+ ok: true,
874
+ task: index.getTask(updated.id),
875
+ stopped: stopRes.stopped,
876
+ reason: stopRes.reason,
877
+ });
878
+ }
879
+ // Reopen (lifecycle audit, task-transitions.ts): done -> ready. Close-out's
880
+ // cleanup() deletes the task's branch and worktree, so re-provisioning them
881
+ // here would just duplicate what Start work (ready -> active) already does
882
+ // — clear the now-stale branch reference and land on ready instead, so the
883
+ // next Start work derives a fresh branch exactly like a brand-new task.
884
+ if (action === "reopen") {
885
+ if (existing.status !== "done") {
886
+ return json(res, 400, {
887
+ error: `Only done tasks can be reopened (#${id} is ${existing.status})`,
888
+ });
889
+ }
890
+ const updated = patchTaskFile(config, existing.absPath, { status: "ready", branch: "", needsInput: false }, { onStatusChange: onServerStatusChange });
891
+ index.applyFileChange(updated.absPath);
892
+ return json(res, 200, { ok: true, task: index.getTask(updated.id) });
893
+ }
894
+ if (action === "hotfix") {
895
+ if (existing.status !== "ready" && !existing.hotfix) {
896
+ return json(res, 400, {
897
+ error: `Only ready tasks can be switched to hotfix (#${id} is ${existing.status})`,
898
+ });
899
+ }
900
+ if (existing.hotfix) {
901
+ return json(res, 400, {
902
+ error: `Task #${id} is already in hotfix mode`,
903
+ });
904
+ }
905
+ const body = (await readBody(req));
906
+ const hotfixTarget = body?.hotfixTarget === "main" ? "main" : "branch";
907
+ const branch = `hotfix/${existing.id}-${existing.title
908
+ .toLowerCase()
909
+ .replace(/[^a-z0-9]+/g, "-")
910
+ .replace(/^-|-$/g, "")
911
+ .slice(0, 40)}`;
912
+ const rootLock = ctx.rootLock;
913
+ if (!rootLock.acquire(existing.id, "hotfix")) {
914
+ const holder = rootLock.getHolder();
915
+ return json(res, 409, {
916
+ error: `The main checkout is held by ${holder?.kind ?? "another operation"} (task #${holder?.taskId ?? "unknown"}) — wait for it to finish`,
917
+ });
918
+ }
919
+ let dirty;
920
+ try {
921
+ dirty = await dirtyFiles(config.root);
922
+ }
923
+ catch (err) {
924
+ rootLock.release(existing.id);
925
+ if (err instanceof GitDirtyCheckError) {
926
+ return json(res, 409, {
927
+ error: `could not verify main is clean before hotfix (${err.message}). Commit or stash first.`,
928
+ needsCommit: true,
929
+ dirtyCheckFailed: true,
930
+ causeKind: err.causeKind,
931
+ });
932
+ }
933
+ throw err;
934
+ }
935
+ if (dirty.length > 0) {
936
+ rootLock.release(existing.id);
937
+ return json(res, 409, {
938
+ error: `main has ${dirty.length} uncommitted file${dirty.length === 1 ? "" : "s"} — commit or stash before starting a hotfix`,
939
+ needsCommit: true,
940
+ dirtyFiles: dirty,
941
+ });
942
+ }
943
+ // The lock must precede `ensureHotfix`: checking out the hotfix branch is
944
+ // itself a mutation of the root checkout and can race a close-out merge.
945
+ const hotfixRes = ensureHotfix(config.root, branch, hotfixTarget);
946
+ if (!hotfixRes.ok) {
947
+ rootLock.release(existing.id);
948
+ return json(res, 400, {
949
+ error: `Cannot switch to hotfix: ${hotfixRes.reason ?? "unknown reason"}`,
950
+ });
951
+ }
952
+ let updated;
953
+ try {
954
+ updated = patchTaskFile(config, existing.absPath, {
955
+ hotfix: true,
956
+ hotfixTarget,
957
+ branch,
958
+ }, {
959
+ onStatusChange: onServerStatusChange,
960
+ });
961
+ }
962
+ catch (err) {
963
+ rootLock.release(existing.id);
964
+ throw err;
965
+ }
966
+ index.applyFileChange(updated.absPath);
967
+ index.refreshBranches();
968
+ return json(res, 200, {
969
+ ok: true,
970
+ task: index.getTask(updated.id),
971
+ branch,
972
+ hotfixTarget,
973
+ });
974
+ }
975
+ return json(res, 400, { error: `Unknown action: ${action}` });
976
+ };
977
+ // Preview routes
978
+ export const startPreview = async (ctx, req, res, params) => {
979
+ const { index, previews } = ctx;
980
+ const id = params.param1;
981
+ const t = index.getTask(id);
982
+ if (!t) {
983
+ return json(res, 404, { error: `Task #${id} not found` });
984
+ }
985
+ // The drawer's picker passes the chosen target when the task's area matches
986
+ // more than one (#0379); omitting it starts the first match.
987
+ const body = (await readBody(req));
988
+ const target = typeof body?.target === "string" && body.target.trim() ? body.target.trim() : undefined;
989
+ const result = await previews.start(t, target);
990
+ if (!result.ok) {
991
+ return json(res, 400, { error: result.error ?? "could not start preview" });
992
+ }
993
+ return json(res, 200, { ok: true, port: result.port, url: result.url, label: result.label });
994
+ };
995
+ export const stopPreview = async (ctx, _req, res, params) => {
996
+ const { previews } = ctx;
997
+ const id = params.param1;
998
+ await previews.stop(id);
999
+ return json(res, 200, { ok: true });
1000
+ };
1001
+ // Review routes
1002
+ export const getTaskReview = (ctx, _req, res, params) => {
1003
+ const { reviews } = ctx;
1004
+ const id = params.param1;
1005
+ return json(res, 200, {
1006
+ ok: true,
1007
+ running: reviews.isRunning(id),
1008
+ enabled: reviews.enabled(),
1009
+ review: reviews.read(id),
1010
+ lines: reviews.session(id),
1011
+ });
1012
+ };
1013
+ export const reviewAgain = async (ctx, _req, res, params) => {
1014
+ const { index, runner, reviews } = ctx;
1015
+ const id = params.param1;
1016
+ const existing = index.getTask(id);
1017
+ if (!existing) {
1018
+ return json(res, 404, { error: `Task #${id} not found` });
1019
+ }
1020
+ if (existing.status !== "review") {
1021
+ return json(res, 400, {
1022
+ error: `Only review tasks can be re-reviewed (#${id} is ${existing.status})`,
1023
+ });
1024
+ }
1025
+ if (runner.isRunning(id)) {
1026
+ return json(res, 409, {
1027
+ error: `Task #${id} has an agent turn in progress — wait for it to finish`,
1028
+ });
1029
+ }
1030
+ const gate = reviews.canRun(existing);
1031
+ if (!gate.ok) {
1032
+ return json(res, 400, { error: gate.reason ?? "could not start the review" });
1033
+ }
1034
+ void reviews.run(existing);
1035
+ return json(res, 200, { ok: true });
1036
+ };
1037
+ export const reviewMessage = async (ctx, req, res, params) => {
1038
+ const { index, reviews } = ctx;
1039
+ const id = params.param1;
1040
+ const existing = index.getTask(id);
1041
+ if (!existing) {
1042
+ return json(res, 404, { error: `Task #${id} not found` });
1043
+ }
1044
+ if (existing.status !== "review") {
1045
+ return json(res, 400, {
1046
+ error: `Only review tasks accept reviewer messages (#${id} is ${existing.status})`,
1047
+ });
1048
+ }
1049
+ const body = (await readBody(req));
1050
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
1051
+ if (!text) {
1052
+ return json(res, 400, { error: "message text is required" });
1053
+ }
1054
+ const gate = reviews.canSend(existing);
1055
+ if (!gate.ok) {
1056
+ return json(res, 400, { error: gate.reason ?? "could not send to the reviewer" });
1057
+ }
1058
+ void reviews.send(existing, text);
1059
+ return json(res, 200, { ok: true });
1060
+ };
1061
+ export const getCTO = (ctx, _req, res) => {
1062
+ const { cto } = ctx;
1063
+ return json(res, 200, {
1064
+ ok: true,
1065
+ running: cto.isRunning(),
1066
+ enabled: cto.enabled(),
1067
+ report: cto.read(),
1068
+ lines: cto.session(),
1069
+ });
1070
+ };
1071
+ export const ctoMessage = async (ctx, req, res) => {
1072
+ const { cto } = ctx;
1073
+ const body = (await readBody(req));
1074
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
1075
+ if (!text) {
1076
+ return json(res, 400, { error: "message text is required" });
1077
+ }
1078
+ if (cto.isRunning()) {
1079
+ return json(res, 409, { error: "a CTO run is already in progress" });
1080
+ }
1081
+ if (!cto.enabled()) {
1082
+ return json(res, 400, { error: "the CTO agent is disabled" });
1083
+ }
1084
+ void cto.send(text);
1085
+ return json(res, 200, { ok: true });
1086
+ };
1087
+ /** Interrupt a running CTO response. Idempotent. */
1088
+ export const ctoInterrupt = (ctx, _req, res) => {
1089
+ const result = ctx.cto.interrupt();
1090
+ return json(res, 200, { ok: true, ...result });
1091
+ };
1092
+ /**
1093
+ * Build the "newly attached screenshots" fragment appended to the PM's task
1094
+ * context (#0382). Each entry surfaces the API URL and the repo-relative
1095
+ * path so the PM can either link them or quote them when editing the spec.
1096
+ * Empty/missing url+path combos are silently dropped. Returns an empty
1097
+ * string when there are no usable screenshots, leaving the prompt unchanged
1098
+ * for plain text messages.
1099
+ */
1100
+ export function buildPmShotContext(incoming) {
1101
+ const lines = [];
1102
+ for (const raw of incoming) {
1103
+ if (!raw || typeof raw !== "object")
1104
+ continue;
1105
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
1106
+ const path = typeof raw.path === "string"
1107
+ ? raw.path.trim()
1108
+ : "";
1109
+ if (!url && !path)
1110
+ continue;
1111
+ const bits = [];
1112
+ if (url)
1113
+ bits.push(`url=${url}`);
1114
+ if (path)
1115
+ bits.push(`repo-path=${path}`);
1116
+ lines.push(`- ${bits.join(" ")}`);
1117
+ }
1118
+ if (!lines.length)
1119
+ return "";
1120
+ return `\nNewly attached screenshots (already saved to this task — link them in the spec if relevant):\n${lines.join("\n")}\n`;
1121
+ }
1122
+ export const pmMessage = async (ctx, req, res, params) => {
1123
+ const { config, index, runner, logger, emitEvent } = ctx;
1124
+ const id = params.param1;
1125
+ const existing = index.getTask(id);
1126
+ if (!existing) {
1127
+ return json(res, 404, { error: `Task #${id} not found` });
1128
+ }
1129
+ // v2 deliberately starts a clean PM conversation: older PM chats were
1130
+ // incorrectly launched with Ross's read-only mission. When auth is on,
1131
+ // each user gets their own PM conversation per task (0248) — otherwise
1132
+ // teammates sharing one instance would all read and post into the same
1133
+ // thread. Falls back to the unscoped id when auth is off, matching every
1134
+ // existing single-user setup exactly as before.
1135
+ const currentUserEmail = getCurrentUser(req, config)?.email;
1136
+ const pmSessionId = currentUserEmail
1137
+ ? `pm-task-v2:${id}::${currentUserEmail}`
1138
+ : `pm-task-v2:${id}`;
1139
+ const body = (await readBody(req));
1140
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
1141
+ if (!text) {
1142
+ return json(res, 400, { error: "message text is required" });
1143
+ }
1144
+ // Build a one-shot agent override for this PM request. Falls back to the
1145
+ // task's persisted PM overrides (set via the PM tab's selector) when the
1146
+ // client doesn't pass explicit values.
1147
+ const pmAgentName = typeof body?.agentOverride === "string" && body.agentOverride
1148
+ ? body.agentOverride
1149
+ : existing.pmAgentOverride || undefined;
1150
+ const pmCli = typeof body?.cliOverride === "string" && body.cliOverride
1151
+ ? body.cliOverride
1152
+ : existing.pmCliOverride || undefined;
1153
+ const pmModel = typeof body?.modelOverride === "string" && body.modelOverride
1154
+ ? body.modelOverride
1155
+ : existing.pmModelOverride || undefined;
1156
+ // "default" is the sentinel the PM tab's model dropdown offers for "use
1157
+ // the configured pm agent's own model" — NOT a real pin. Treating it as
1158
+ // truthy here force-overwrites the base agent's actual configured model
1159
+ // with the literal string "default", which then skips --model entirely
1160
+ // and falls back to the CLI's own raw default instead of what's
1161
+ // configured on the Agents page (same bug fixed in resolveAgentForTask /
1162
+ // resolveReviewerForTask).
1163
+ const pmModelPinned = isModelOverridePinned(pmModel);
1164
+ const hasPmOverride = pmAgentName || pmCli || pmModelPinned;
1165
+ // Resolve the PM agent, applying any one-shot override
1166
+ let pm;
1167
+ if (hasPmOverride) {
1168
+ const list = agentsForConfig(config);
1169
+ const baseName = pmAgentName || "pm";
1170
+ const base = list.find((a) => a.enabled && a.name === baseName) ?? null;
1171
+ pm = base
1172
+ ? {
1173
+ ...base,
1174
+ ...(pmCli ? { cli: pmCli } : {}),
1175
+ ...(pmModelPinned ? { model: pmModel } : {}),
1176
+ }
1177
+ : null;
1178
+ }
1179
+ else {
1180
+ pm = resolvePmAgent(config);
1181
+ }
1182
+ if (!pm) {
1183
+ return json(res, 400, {
1184
+ error: "PM agent is not configured — enable it on the Agents page",
1185
+ });
1186
+ }
1187
+ // 0381: chat-input screenshots ride along as a pending batch keyed to this
1188
+ // PM session. The task the PM creates from the message doesn't exist yet,
1189
+ // so the batch is parked on disk now and attached to the created task by
1190
+ // the server's task.created hook while the session is still running.
1191
+ const rawImages = Array.isArray(body?.images) ? body?.images : [];
1192
+ let imageBatchId = null;
1193
+ if (rawImages.length > 0) {
1194
+ const queuedImages = queuePmImages(config, pmSessionId, rawImages);
1195
+ imageBatchId = queuedImages.batchId;
1196
+ if (queuedImages.errors.length > 0) {
1197
+ // Best-effort: a rejected image never blocks the message itself.
1198
+ logger.task(id, "warn", "Some PM chat attachments were rejected", {
1199
+ errors: queuedImages.errors,
1200
+ });
1201
+ }
1202
+ }
1203
+ // Build context about the current task for the PM
1204
+ const taskContext = `Task #${id}: ${existing.title}
1205
+ Status: ${existing.status}
1206
+ Priority: ${existing.priority || "unset"}
1207
+ Area: ${existing.area || "unset"}
1208
+ Type: ${existing.type || "unset"}
1209
+
1210
+ Description:
1211
+ ${existing.body || "(no description)"}`;
1212
+ // #0382: the PM tab's attach button lets the user upload screenshots that
1213
+ // arrive as a `screenshots` array of {url,path} on this message. They are
1214
+ // already persisted in the task's `## Screenshots` section (and on disk) by
1215
+ // `POST /api/tasks/:id/attachments`; we surface them here so the PM agent
1216
+ // knows what to reference or link into the spec when relevant. Empty/missing
1217
+ // keys are silently ignored — a plain text message stays a plain text
1218
+ // message.
1219
+ const shotContext = buildPmShotContext(Array.isArray(body?.screenshots) ? body.screenshots : []);
1220
+ const fullContext = `${taskContext}${shotContext}`;
1221
+ const existing_session = runner.output(pmSessionId);
1222
+ const result = existing_session
1223
+ ? runner.send(pmSessionId, text, pm, {
1224
+ resumePreamble: `Task context:\n${fullContext}`,
1225
+ })
1226
+ : runner.startChat(pmSessionId, text, pm, fullContext, taskPmPrompt);
1227
+ if (!result.ok && result.busy) {
1228
+ dropPmImages(imageBatchId);
1229
+ return json(res, 409, { error: result.reason ?? "PM is busy" });
1230
+ }
1231
+ if (!result.ok) {
1232
+ dropPmImages(imageBatchId);
1233
+ return json(res, 400, { error: result.reason ?? "could not send message to PM" });
1234
+ }
1235
+ // 0381: the runner accepted the turn (running now, or queued behind
1236
+ // maxConcurrentAgents) — flag the task so its card and panel show "PM is
1237
+ // working". Cleared by the emit hook in server.ts when the runner reports
1238
+ // the session's exit (success, error, or user interrupt).
1239
+ markPmChatSession(pmSessionId, id);
1240
+ emitEvent({ type: "task.pmWorking", id, at: new Date().toISOString() });
1241
+ return json(res, 200, { ok: true, spawn: { ok: true, pid: result.pid } });
1242
+ };
1243
+ /**
1244
+ * Interrupt a running PM response about a task. Resolves the same per-user PM
1245
+ * session id as `pmMessage` and stops the in-flight agent turn. Idempotent.
1246
+ */
1247
+ export const pmInterrupt = (ctx, req, res, params) => {
1248
+ const { config, index, runner } = ctx;
1249
+ const id = params.param1;
1250
+ const existing = index.getTask(id);
1251
+ if (!existing) {
1252
+ return json(res, 404, { error: `Task #${id} not found` });
1253
+ }
1254
+ const currentUserEmail = getCurrentUser(req, config)?.email;
1255
+ const pmSessionId = currentUserEmail
1256
+ ? `pm-task-v2:${id}::${currentUserEmail}`
1257
+ : `pm-task-v2:${id}`;
1258
+ const result = runner.interrupt(pmSessionId);
1259
+ return json(res, 200, { ok: true, ...result });
1260
+ };
1261
+ export const getIntegrationJob = (ctx, _req, res, params) => {
1262
+ const { jobCoordinator } = ctx;
1263
+ const id = params.param1;
1264
+ const job = jobCoordinator.getJob(id);
1265
+ if (!job) {
1266
+ return json(res, 404, { error: `No integration job for task #${id}` });
1267
+ }
1268
+ const allJobs = jobCoordinator.allJobs();
1269
+ const queuePos = allJobs.findIndex((j) => j.taskId === job.taskId);
1270
+ return json(res, 200, {
1271
+ ok: true,
1272
+ job: {
1273
+ taskId: job.taskId,
1274
+ phase: job.phase,
1275
+ enqueuedAt: job.enqueuedAt,
1276
+ startedAt: job.startedAt,
1277
+ baseMainSha: job.baseMainSha,
1278
+ branchSha: job.branchSha,
1279
+ candidateSha: job.candidateSha,
1280
+ reason: job.reason,
1281
+ queuePosition: queuePos,
1282
+ queueLength: allJobs.length,
1283
+ },
1284
+ });
1285
+ };
1286
+ export const getIntegrationJobs = (ctx, _req, res) => {
1287
+ const allJobs = ctx.jobCoordinator.allJobs();
1288
+ return json(res, 200, {
1289
+ ok: true,
1290
+ jobs: allJobs.map((job, idx) => ({
1291
+ taskId: job.taskId,
1292
+ phase: job.phase,
1293
+ enqueuedAt: job.enqueuedAt,
1294
+ startedAt: job.startedAt,
1295
+ reason: job.reason,
1296
+ queuePosition: idx,
1297
+ })),
1298
+ queueLength: allJobs.length,
1299
+ });
1300
+ };
1301
+ /**
1302
+ * Full integration-pipeline snapshot for the pinned status bar (0207).
1303
+ * Reads the same live `reportedStages` map the SSE push path uses, so a
1304
+ * page refresh mid-pipeline shows the job's actual current sub-step (e.g.
1305
+ * "check", mid-test-run) rather than falling back to a coarse per-phase
1306
+ * guess (0207 follow-up — see RouteContext.reportedStages).
1307
+ */
1308
+ export const getIntegrationPipeline = (ctx, _req, res) => {
1309
+ return json(res, 200, {
1310
+ ok: true,
1311
+ pipeline: buildIntegrationSnapshot(ctx.jobCoordinator, ctx.reportedStages),
1312
+ });
1313
+ };
1314
+ /**
1315
+ * Retry a failed integration job (0207). Reuses the coordinator's existing
1316
+ * retry path: `enqueue` re-enqueues a `failed` job as a fresh queued job
1317
+ * (see integration-job.ts), then processing resumes from the queue.
1318
+ */
1319
+ export const retryIntegration = (ctx, _req, res, params) => {
1320
+ const { jobCoordinator } = ctx;
1321
+ const id = params.param1;
1322
+ const job = jobCoordinator.getJob(id);
1323
+ if (!job) {
1324
+ return json(res, 404, { error: `No integration job for task #${id}` });
1325
+ }
1326
+ if (job.phase !== "failed") {
1327
+ return json(res, 409, {
1328
+ error: `Task #${id} is not in a failed integration state (it is ${job.phase})`,
1329
+ });
1330
+ }
1331
+ const task = ctx.index.getTask(id);
1332
+ if (!task) {
1333
+ return json(res, 404, { error: `Task #${id} not found` });
1334
+ }
1335
+ const reenqueued = jobCoordinator.enqueue(task);
1336
+ if (!reenqueued) {
1337
+ return json(res, 400, { error: `Task #${id} has no branch to integrate` });
1338
+ }
1339
+ ctx.emitEvent({ type: "integration", pipeline: buildIntegrationSnapshot(jobCoordinator, {}) });
1340
+ ctx.triggerJobProcessing();
1341
+ return json(res, 200, {
1342
+ ok: true,
1343
+ job: {
1344
+ taskId: reenqueued.taskId,
1345
+ phase: reenqueued.phase,
1346
+ enqueuedAt: reenqueued.enqueuedAt,
1347
+ },
1348
+ });
1349
+ };
1350
+ // Session stats endpoints
1351
+ export const getTaskStats = (ctx, _req, res, params) => {
1352
+ const { runner } = ctx;
1353
+ const taskId = params.param1;
1354
+ const stats = runner.taskStats(taskId);
1355
+ if (!stats) {
1356
+ return json(res, 404, { error: `No stats found for task #${taskId}` });
1357
+ }
1358
+ return json(res, 200, { ok: true, stats });
1359
+ };
1360
+ export const getSessionTypeStats = (ctx, _req, res) => {
1361
+ const { runner } = ctx;
1362
+ const stats = runner.sessionTypeStats();
1363
+ return json(res, 200, { ok: true, stats });
1364
+ };
1365
+ export const getBoardStats = (ctx, req, res) => {
1366
+ const { runner } = ctx;
1367
+ // ?range=1d|7d|30d|all (0334) — trailing window on sessions.startedAt;
1368
+ // omitted or "all" means the unfiltered pre-range behavior.
1369
+ const raw = new URL(req.url ?? "/", "http://localhost").searchParams.get("range");
1370
+ let range = "all";
1371
+ if (raw !== null) {
1372
+ if (raw === "1d" || raw === "7d" || raw === "30d" || raw === "all") {
1373
+ range = raw;
1374
+ }
1375
+ else {
1376
+ return json(res, 400, { error: `Invalid range '${raw}' — expected 1d, 7d, 30d, or all` });
1377
+ }
1378
+ }
1379
+ const stats = runner.boardStats(range);
1380
+ return json(res, 200, { ok: true, stats });
1381
+ };
1382
+ export const getDailyTotals = (ctx, _req, res) => {
1383
+ const { runner } = ctx;
1384
+ const stats = runner.dailyTotals();
1385
+ return json(res, 200, { ok: true, stats });
1386
+ };
1387
+ // Diff stats endpoint
1388
+ export const getDiffStatsForTask = async (ctx, _req, res, params) => {
1389
+ const { index, config } = ctx;
1390
+ const id = params.param1;
1391
+ const task = index.getTask(id);
1392
+ if (!task) {
1393
+ return json(res, 404, { error: `Task #${id} not found` });
1394
+ }
1395
+ if (!task.branch) {
1396
+ return json(res, 200, {
1397
+ ok: true,
1398
+ stats: { filesChanged: 0, additions: 0, deletions: 0 },
1399
+ noBranch: true,
1400
+ });
1401
+ }
1402
+ const worktreePath = worktreePathForBranch(config.root, task.branch);
1403
+ if (!worktreePath) {
1404
+ const snapshot = task.status === "done" ? loadDiffSnapshot(config.root, config.cacheDir, task.id) : null;
1405
+ if (snapshot) {
1406
+ return json(res, 200, { ok: true, stats: snapshot.stats, snapshot: true });
1407
+ }
1408
+ return json(res, 200, {
1409
+ ok: true,
1410
+ stats: { filesChanged: 0, additions: 0, deletions: 0 },
1411
+ noWorktree: true,
1412
+ });
1413
+ }
1414
+ // Async (spawn-based) rather than the sync/execFileSync getDiffStats: every
1415
+ // card on the Work board fires this on mount, and the synchronous version
1416
+ // blocked the whole event loop for each one serially — including whatever
1417
+ // GET /api/tasks/:id a drawer-opening click was waiting behind.
1418
+ const stats = await getDiffStatsAsync(worktreePath, "main");
1419
+ return json(res, 200, { ok: true, stats });
1420
+ };
1421
+ // Diff endpoint — full patch
1422
+ export const getDiffForTask = async (ctx, _req, res, params) => {
1423
+ const { index, config } = ctx;
1424
+ const id = params.param1;
1425
+ const task = index.getTask(id);
1426
+ if (!task) {
1427
+ return json(res, 404, { error: `Task #${id} not found` });
1428
+ }
1429
+ if (!task.branch) {
1430
+ return json(res, 200, { ok: true, diff: { patch: "", truncated: false }, noBranch: true });
1431
+ }
1432
+ const worktreePath = worktreePathForBranch(config.root, task.branch);
1433
+ if (!worktreePath) {
1434
+ const snapshot = task.status === "done" ? loadDiffSnapshot(config.root, config.cacheDir, task.id) : null;
1435
+ if (snapshot) {
1436
+ return json(res, 200, { ok: true, diff: snapshot.diff, snapshot: true });
1437
+ }
1438
+ return json(res, 200, { ok: true, diff: { patch: "", truncated: false }, noWorktree: true });
1439
+ }
1440
+ const diff = await getDiff(worktreePath, "main");
1441
+ return json(res, 200, { ok: true, diff });
1442
+ };