@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,1453 @@
1
+ /**
2
+ * Best-effort git facts. Everything here degrades gracefully: if git is not
3
+ * installed, or the repo isn't a git repo, callers get safe empty values.
4
+ * We shell out rather than depend on a git library (zero deps).
5
+ */
6
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
7
+ import { copyFileSync, existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
8
+ import { dirname, isAbsolute, join, relative } from "node:path";
9
+ import { worktreesDir } from "./config.js";
10
+ function git(root, args) {
11
+ try {
12
+ return execFileSync("git", args, {
13
+ cwd: root,
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ timeout: 4000,
17
+ }).trim();
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Blocking `git` that also hands back the exit status and stderr — `git()`
25
+ * collapses every failure to `null`, which is fine for reads but loses the
26
+ * information `removeWorktree` needs to tell "already gone" from "refused
27
+ * because the branch is checked out elsewhere".
28
+ */
29
+ function gitCapture(root, args) {
30
+ const run = spawnSync("git", args, {
31
+ cwd: root,
32
+ encoding: "utf8",
33
+ timeout: 4000,
34
+ });
35
+ return {
36
+ status: run.status,
37
+ stdout: run.stdout ?? "",
38
+ stderr: run.stderr ?? (run.error ? `${run.error.message}\n` : ""),
39
+ };
40
+ }
41
+ /**
42
+ * Non-blocking counterpart of `git()`. Lets a caller enriching many tasks
43
+ * (e.g. `buildIndexAsync`) run their git spawns concurrently instead of
44
+ * one at a time on the main thread — each `execFileSync` call above blocks
45
+ * the event loop for the OS round-trip, so N tasks in a `for` loop costs N
46
+ * full serial round-trips no matter how idle the machine is.
47
+ */
48
+ function gitAsync(root, args) {
49
+ return new Promise((resolve) => {
50
+ execFile("git", args, { cwd: root, encoding: "utf8", timeout: 4000 }, (error, stdout) => {
51
+ resolve(error ? null : stdout.trim());
52
+ });
53
+ });
54
+ }
55
+ /**
56
+ * Non-blocking `spawn` that resolves with the exit status and captured output.
57
+ * Never throws; a timeout kills the child. Unlike `spawnSync` this does not
58
+ * freeze the server's event loop, so SSE progress events can flush in real
59
+ * time while a long command (merge, build, check) is in flight.
60
+ */
61
+ export function runGit(root, args, timeout) {
62
+ return new Promise((resolve) => {
63
+ const child = spawn("git", args, { cwd: root });
64
+ let stdout = "";
65
+ let stderr = "";
66
+ let timer;
67
+ let settled = false;
68
+ let timedOut = false;
69
+ const finish = (status) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ resolve({ status, stdout, stderr, timedOut });
76
+ };
77
+ child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
78
+ child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
79
+ child.on("error", (err) => {
80
+ stderr += `${err.message}\n`;
81
+ finish(null);
82
+ });
83
+ child.on("close", (code) => finish(code));
84
+ timer = setTimeout(() => {
85
+ timedOut = true;
86
+ child.kill("SIGKILL");
87
+ }, timeout);
88
+ });
89
+ }
90
+ export function isGitRepo(root) {
91
+ return git(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
92
+ }
93
+ export function currentBranch(root) {
94
+ return git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
95
+ }
96
+ /** ISO-8601 commit date of HEAD at `root` (a worktree or the main checkout), or null. */
97
+ export function headCommitISO(root) {
98
+ return git(root, ["log", "-1", "--format=%cI"]) || null;
99
+ }
100
+ /** Set of local branch names, fetched once and reused across tasks. */
101
+ export function localBranches(root) {
102
+ const out = git(root, ["branch", "--format=%(refname:short)"]);
103
+ if (!out)
104
+ return new Set();
105
+ return new Set(out
106
+ .split("\n")
107
+ .map((s) => s.trim())
108
+ .filter(Boolean));
109
+ }
110
+ /**
111
+ * Commits-ahead count for EVERY local branch relative to `base`, in a single
112
+ * `git for-each-ref` call. The indexer needs "does this task's branch have
113
+ * unmerged commits" for every task; the per-branch alternative
114
+ * (`git rev-list --count base..branch` once per task) was ~2.5s of the index
115
+ * build on a repo with ~40 task branches.
116
+ *
117
+ * `%(ahead-behind:<base>)` needs git ≥ 2.41 (2023-06); on older git the field
118
+ * renders empty and this returns an empty map, so callers fall back to
119
+ * `rev-list` per branch. Fail-soft: empty map when git is missing.
120
+ */
121
+ export function branchAheadCounts(root, base) {
122
+ const map = new Map();
123
+ if (!base)
124
+ return map;
125
+ // refs never contain spaces, so a space separator parses unambiguously:
126
+ // "<branch> <ahead> <behind>". The ahead/behind pair is empty on git < 2.41.
127
+ const out = git(root, [
128
+ "for-each-ref",
129
+ `--format=%(refname:short) %(ahead-behind:${base})`,
130
+ "refs/heads/",
131
+ ]);
132
+ if (!out)
133
+ return map;
134
+ for (const line of out.split("\n")) {
135
+ const parts = line.trim().split(/\s+/);
136
+ if (parts.length < 2)
137
+ continue; // no ahead/behind -> old git, or the base ref
138
+ const ahead = Number(parts[1]);
139
+ if (parts[0] && Number.isFinite(ahead))
140
+ map.set(parts[0], ahead);
141
+ }
142
+ return map;
143
+ }
144
+ /** Last commit subject + ISO date touching a specific file. */
145
+ export function lastCommitForFile(root, relPath) {
146
+ const out = git(root, ["log", "-1", "--format=%s%x00%cI", "--", relPath]);
147
+ if (!out)
148
+ return { subject: null, date: null };
149
+ const [subject, date] = out.split("\u0000");
150
+ return { subject: subject || null, date: date || null };
151
+ }
152
+ /** Async counterpart of lastCommitForFile - see gitAsync. */
153
+ export async function lastCommitForFileAsync(root, relPath) {
154
+ const out = await gitAsync(root, ["log", "-1", "--format=%s%x00%cI", "--", relPath]);
155
+ if (!out)
156
+ return { subject: null, date: null };
157
+ const [subject, date] = out.split("\u0000");
158
+ return { subject: subject || null, date: date || null };
159
+ }
160
+ const DIR_LOG_REC = "\x1e";
161
+ const DIR_LOG_FLD = "\x1f";
162
+ /** Args for a single `git log` pass that reports the last commit per file. */
163
+ function dirLogArgs(relDir) {
164
+ return [
165
+ // Unicode-safe paths; never abbreviate task filenames.
166
+ "-c",
167
+ "core.quotePath=false",
168
+ "log",
169
+ // A rename would list only the new path anyway; skip the extra diffing.
170
+ "--no-renames",
171
+ // Print `--name-only` paths relative to cwd (== root) so they match
172
+ // `Task.path`, not relative to the repo toplevel when root is a subdir.
173
+ "--relative",
174
+ `--format=${DIR_LOG_REC}%s${DIR_LOG_FLD}%cI`,
175
+ "--name-only",
176
+ "--",
177
+ relDir,
178
+ ];
179
+ }
180
+ function parseDirLog(out) {
181
+ const map = new Map();
182
+ if (!out)
183
+ return map;
184
+ // Newest commit first: the first chunk that touches a path wins.
185
+ for (const chunk of out.split(DIR_LOG_REC)) {
186
+ if (!chunk)
187
+ continue;
188
+ const nl = chunk.indexOf("\n");
189
+ const header = nl === -1 ? chunk : chunk.slice(0, nl);
190
+ const sep = header.indexOf(DIR_LOG_FLD);
191
+ const subject = (sep === -1 ? header : header.slice(0, sep)) || null;
192
+ const date = (sep === -1 ? "" : header.slice(sep + 1)) || null;
193
+ if (nl === -1)
194
+ continue;
195
+ for (const line of chunk.slice(nl + 1).split("\n")) {
196
+ const path = line.trim();
197
+ if (!path || map.has(path))
198
+ continue;
199
+ map.set(path, { subject, date });
200
+ }
201
+ }
202
+ return map;
203
+ }
204
+ /**
205
+ * Last commit subject + ISO date for EVERY tracked file under `relDir`, in a
206
+ * SINGLE `git log` pass. The indexer needs this for every task file; calling
207
+ * `lastCommitForFile` per task instead runs one full history walk per file,
208
+ * which was a large share of RepoOS's slow index build at 200+ tasks (#0271
209
+ * follow-up). Keys are paths relative to `root`, matching `Task.path`.
210
+ * Fail-soft: an empty map when git is missing or the walk fails.
211
+ */
212
+ export function lastCommitsForDir(root, relDir) {
213
+ return parseDirLog(git(root, dirLogArgs(relDir)));
214
+ }
215
+ /** Async counterpart of `lastCommitsForDir` - see `gitAsync`. */
216
+ export async function lastCommitsForDirAsync(root, relDir) {
217
+ return parseDirLog(await gitAsync(root, dirLogArgs(relDir)));
218
+ }
219
+ /**
220
+ * Turn on git's own fast-status machinery for `root` (and, since worktrees
221
+ * share `.git/config`, every task worktree cut from it). RepoOS runs
222
+ * `git status --porcelain` once per task worktree on every index build; on a
223
+ * large monorepo each of those is a full tree stat-walk. `core.fsmonitor`
224
+ * lets git's builtin file-system-monitor daemon (git ≥ 2.37, macOS/Windows/
225
+ * Linux) skip unchanged directories, and `core.untrackedCache` caches the
226
+ * per-directory untracked-file scan. Both are opt-in per repo and safe:
227
+ * git falls back to a full scan if the daemon is unavailable.
228
+ *
229
+ * Idempotent, best-effort, and cheap — call it once at startup. Skips any key
230
+ * already set to the desired value, so a repo that has been tuned once pays
231
+ * only two `git config --get` reads on subsequent boots.
232
+ */
233
+ export function tuneRepoForScale(root) {
234
+ if (!isGitRepo(root))
235
+ return;
236
+ for (const [key, value] of [
237
+ ["core.fsmonitor", "true"],
238
+ ["core.untrackedCache", "true"],
239
+ ]) {
240
+ try {
241
+ if (git(root, ["config", "--get", key]) === value)
242
+ continue;
243
+ git(root, ["config", key, value]);
244
+ }
245
+ catch {
246
+ /* best-effort: an old git or a read-only .git/config just means no speedup */
247
+ }
248
+ }
249
+ }
250
+ /**
251
+ * True when git tracks `relPath` in the checkout at `root` and its working
252
+ * copy matches HEAD — i.e. whatever the file currently shows is backed by a
253
+ * commit, not a stray mid-edit. Untracked or dirty files return false.
254
+ * Used to prove a worktree's committed task-file state before trusting it.
255
+ */
256
+ export function fileCommittedClean(root, relPath) {
257
+ const status = git(root, ["status", "--porcelain", "--", relPath]);
258
+ if (status === null || status !== "")
259
+ return false;
260
+ return lastCommitForFile(root, relPath).subject !== null;
261
+ }
262
+ export function emptyGitInfo() {
263
+ return {
264
+ branchExists: false,
265
+ worktreeExists: false,
266
+ lastCommit: null,
267
+ lastCommitAt: null,
268
+ worktreePath: null,
269
+ dirty: false,
270
+ };
271
+ }
272
+ /**
273
+ * Branch -> { path, head } for every registered linked worktree, derived from
274
+ * a single `git worktree list --porcelain`. Empty map when git is missing.
275
+ * Callers resolving several branches at once (indexer, live index) MUST call
276
+ * this once and reuse the map — every call shells out to git.
277
+ *
278
+ * `head` lets a caller skip an expensive per-worktree `git status` when the
279
+ * worktree has not moved since a cached check (see the indexer's worktree
280
+ * dirty-status cache).
281
+ */
282
+ export function worktreeList(root) {
283
+ const out = git(root, ["worktree", "list", "--porcelain"]);
284
+ const map = new Map();
285
+ if (!out)
286
+ return map;
287
+ let path = null;
288
+ let head = "";
289
+ for (const line of out.split("\n")) {
290
+ if (line.startsWith("worktree ")) {
291
+ path = line.slice("worktree ".length).trim();
292
+ head = "";
293
+ }
294
+ else if (line.startsWith("HEAD ")) {
295
+ head = line.slice("HEAD ".length).trim();
296
+ }
297
+ else if (path && line.startsWith("branch refs/heads/")) {
298
+ map.set(line.slice("branch refs/heads/".length).trim(), { path, head });
299
+ path = null;
300
+ head = "";
301
+ }
302
+ }
303
+ return map;
304
+ }
305
+ /**
306
+ * Every entry from `git worktree list --porcelain`, including detached-HEAD and
307
+ * bare entries that `worktreeList` (branch-keyed) necessarily drops. The garbage
308
+ * collector needs the full picture so it can also prune worktrees that lost
309
+ * their branch ref. Empty when git is missing.
310
+ */
311
+ export function listWorktrees(root) {
312
+ const out = git(root, ["worktree", "list", "--porcelain"]);
313
+ const infos = [];
314
+ if (!out)
315
+ return infos;
316
+ let cur = null;
317
+ const flush = () => {
318
+ if (cur?.path) {
319
+ infos.push({
320
+ path: cur.path,
321
+ branch: cur.branch ?? null,
322
+ head: cur.head ?? "",
323
+ isMain: infos.length === 0,
324
+ isBare: cur.isBare ?? false,
325
+ });
326
+ }
327
+ cur = null;
328
+ };
329
+ for (const line of out.split("\n")) {
330
+ if (line.startsWith("worktree ")) {
331
+ flush();
332
+ cur = { path: line.slice("worktree ".length).trim() };
333
+ }
334
+ else if (!cur) {
335
+ continue;
336
+ }
337
+ else if (line.startsWith("HEAD ")) {
338
+ cur.head = line.slice("HEAD ".length).trim();
339
+ }
340
+ else if (line.startsWith("branch refs/heads/")) {
341
+ cur.branch = line.slice("branch refs/heads/".length).trim();
342
+ }
343
+ else if (line.trim() === "bare") {
344
+ cur.isBare = true;
345
+ }
346
+ }
347
+ flush();
348
+ return infos;
349
+ }
350
+ /**
351
+ * Branch -> worktree path for every registered linked worktree. Thin
352
+ * projection of `worktreeList` kept for the many callers that only need the
353
+ * path; prefer `worktreeList` when you also want the checked-out SHA.
354
+ */
355
+ export function worktreePaths(root) {
356
+ const map = new Map();
357
+ for (const [branch, entry] of worktreeList(root))
358
+ map.set(branch, entry.path);
359
+ return map;
360
+ }
361
+ /**
362
+ * Absolute path of the worktree that has `branch` checked out, or null when the
363
+ * branch has no linked worktree (or git is missing). The main checkout's own
364
+ * branch resolves to the repo root. Uses `git worktree list --porcelain` —
365
+ * never string concatenation — so branch names with `/` and exotic characters
366
+ * resolve correctly.
367
+ */
368
+ export function worktreePathForBranch(root, branch) {
369
+ return worktreePaths(root).get(branch) ?? null;
370
+ }
371
+ /**
372
+ * Copy `taskRelPath` from `root` into an already-resolved `worktreePath` and
373
+ * commit it there, ONLY when the worktree is missing it. This heals a
374
+ * specific, narrow race: `ensureWorktree` cuts (or reuses) a worktree from
375
+ * whatever main's HEAD was at that moment, but the task's own file can be
376
+ * absent from that HEAD if its creation commit hadn't landed yet (e.g.
377
+ * `commitTaskFile` silently failed at creation time) or the worktree/branch
378
+ * is a leftover from an earlier start attempt that raced task creation. Once
379
+ * a worktree exists, plain reuse (see `ensureWorktree`) never re-checks
380
+ * freshness, so without this the task is stuck forever: every resume reuses
381
+ * the same file-less worktree and finalization keeps failing validation.
382
+ *
383
+ * Best-effort and narrowly scoped to the ONE file — never touches unrelated
384
+ * in-progress work in the worktree, and never runs when the worktree already
385
+ * has its own copy (including a stale/older one; that is a different,
386
+ * legitimate divergence left to the normal review/merge flow).
387
+ */
388
+ function healMissingTaskFile(root, worktreePath, taskRelPath) {
389
+ if (root === worktreePath)
390
+ return; // the main checkout is never a task worktree
391
+ const worktreeFile = join(worktreePath, taskRelPath);
392
+ if (existsSync(worktreeFile))
393
+ return;
394
+ const mainFile = join(root, taskRelPath);
395
+ if (!existsSync(mainFile))
396
+ return; // nothing to heal from
397
+ try {
398
+ mkdirSync(dirname(worktreeFile), { recursive: true });
399
+ copyFileSync(mainFile, worktreeFile);
400
+ if (git(worktreePath, ["add", "--", taskRelPath]) === null)
401
+ return;
402
+ git(worktreePath, [
403
+ "commit",
404
+ "-m",
405
+ `chore: restore task file missing from worktree (self-heal)`,
406
+ ]);
407
+ }
408
+ catch {
409
+ /* best-effort; handoff validation still catches an unhealed worktree */
410
+ }
411
+ }
412
+ /**
413
+ * Make sure the agent for `branch` has a dedicated working directory WITHOUT
414
+ * touching the main worktree's checkout. Three outcomes:
415
+ *
416
+ * - branch is what the main worktree already has checked out -> use root
417
+ * - branch already has a linked worktree -> reuse it (idempotent)
418
+ * - otherwise -> `git worktree add` a sibling directory for it
419
+ *
420
+ * Fail-soft like the branch-switching predecessor: returns `{ ok: false,
421
+ * reason }` when git is missing or the worktree can't be created, so agent
422
+ * launch degrades gracefully instead of crashing the server.
423
+ *
424
+ * `taskRelPath`, when given, is the task's own file path relative to `root`
425
+ * (e.g. `work/0151-....md`). Whichever worktree is resolved — new or reused —
426
+ * is healed if that file is missing from it; see `healMissingTaskFile`.
427
+ */
428
+ export function ensureWorktree(root, branch, taskRelPath) {
429
+ if (!isGitRepo(root)) {
430
+ return { ok: false, path: "", created: false, reason: "not a git repository" };
431
+ }
432
+ if (currentBranch(root) === branch) {
433
+ return { ok: true, path: root, created: false };
434
+ }
435
+ const existing = worktreePaths(root).get(branch);
436
+ if (existing) {
437
+ if (taskRelPath)
438
+ healMissingTaskFile(root, existing, taskRelPath);
439
+ return { ok: true, path: existing, created: false };
440
+ }
441
+ const target = join(worktreesDir(root), branch);
442
+ const branchExists = localBranches(root).has(branch);
443
+ const args = branchExists
444
+ ? ["worktree", "add", target, branch]
445
+ : ["worktree", "add", "-b", branch, target];
446
+ if (git(root, args) === null) {
447
+ // An orphaned directory (leftover from a prior interrupted close-out) blocks
448
+ // `git worktree add`. Remove it once and retry — the branch is still valid,
449
+ // only the worktree registration (gitdir) is missing.
450
+ if (existsSync(target)) {
451
+ try {
452
+ rmSync(target, { recursive: true, force: true });
453
+ }
454
+ catch {
455
+ /* best-effort */
456
+ }
457
+ if (git(root, args) !== null) {
458
+ let path = target;
459
+ try {
460
+ path = realpathSync(target);
461
+ }
462
+ catch {
463
+ /* keep */
464
+ }
465
+ if (taskRelPath)
466
+ healMissingTaskFile(root, path, taskRelPath);
467
+ return { ok: true, path, created: true };
468
+ }
469
+ }
470
+ return {
471
+ ok: false,
472
+ path: target,
473
+ created: false,
474
+ reason: "could not create worktree",
475
+ };
476
+ }
477
+ // git reports real paths (macOS /var -> /private/var); normalize the fresh
478
+ // path so a later reuse lookup returns the identical string.
479
+ let path = target;
480
+ try {
481
+ path = realpathSync(target);
482
+ }
483
+ catch {
484
+ /* keep the composed path */
485
+ }
486
+ if (taskRelPath)
487
+ healMissingTaskFile(root, path, taskRelPath);
488
+ return { ok: true, path, created: true };
489
+ }
490
+ /**
491
+ * Best-effort facts about a task's worktree, derived from `git worktree list`
492
+ * plus a status check inside the worktree. Fail-soft: any git failure yields
493
+ * a safe `{ path: null, dirty: false }`.
494
+ *
495
+ * `opts.worktrees`/`opts.baseBranch` let a caller looping over many tasks
496
+ * against the same repo (e.g. `buildIndex`) pass in a `worktreePaths`/
497
+ * `currentBranch` result it already fetched once, instead of this function
498
+ * re-running those two `git` spawns on every single task (#0271 follow-up:
499
+ * this redundancy was a large share of RepoOS's slow boot with 260+ tasks).
500
+ * Omit them and behavior is unchanged — each call fetches fresh.
501
+ */
502
+ export function worktreeStatus(root, branch, opts = {}) {
503
+ const worktrees = opts.worktrees ?? worktreePaths(root);
504
+ const path = worktrees.get(branch) ?? null;
505
+ if (!path)
506
+ return { path: null, dirty: false };
507
+ // git reports the main checkout as a worktree too; only a LINKED worktree
508
+ // counts, otherwise the whole repo's dirt would mark tasks dirty.
509
+ let realRoot = root;
510
+ let realPath = path;
511
+ try {
512
+ realRoot = realpathSync(root);
513
+ }
514
+ catch {
515
+ /* keep the composed path */
516
+ }
517
+ try {
518
+ realPath = realpathSync(path);
519
+ }
520
+ catch {
521
+ /* keep the reported path */
522
+ }
523
+ if (realPath === realRoot)
524
+ return { path: null, dirty: false };
525
+ const status = git(path, ["status", "--porcelain"]);
526
+ const uncommitted = status !== null && status !== "";
527
+ const base = opts.baseBranch !== undefined ? opts.baseBranch : currentBranch(root);
528
+ const count = base && base !== branch ? git(root, ["rev-list", "--count", `${base}..${branch}`]) : null;
529
+ const ahead = count !== null && Number(count) > 0;
530
+ return { path, dirty: uncommitted || ahead };
531
+ }
532
+ /** Async counterpart of `worktreeStatus` — see `gitAsync`. */
533
+ export async function worktreeStatusAsync(root, branch, opts = {}) {
534
+ const worktrees = opts.worktrees ?? worktreePaths(root);
535
+ const path = worktrees.get(branch) ?? null;
536
+ if (!path)
537
+ return { path: null, dirty: false };
538
+ let realRoot = root;
539
+ let realPath = path;
540
+ try {
541
+ realRoot = realpathSync(root);
542
+ }
543
+ catch {
544
+ /* keep the composed path */
545
+ }
546
+ try {
547
+ realPath = realpathSync(path);
548
+ }
549
+ catch {
550
+ /* keep the reported path */
551
+ }
552
+ if (realPath === realRoot)
553
+ return { path: null, dirty: false };
554
+ const base = opts.baseBranch !== undefined ? opts.baseBranch : currentBranch(root);
555
+ const [status, count] = await Promise.all([
556
+ gitAsync(path, ["status", "--porcelain"]),
557
+ base && base !== branch
558
+ ? gitAsync(root, ["rev-list", "--count", `${base}..${branch}`])
559
+ : Promise.resolve(null),
560
+ ]);
561
+ const uncommitted = status !== null && status !== "";
562
+ const ahead = count !== null && Number(count) > 0;
563
+ return { path, dirty: uncommitted || ahead };
564
+ }
565
+ const WORKTREE_STATUS_CONCURRENCY = 12;
566
+ /**
567
+ * Resolve `WorktreeStatus` for MANY branches with a bounded number of git
568
+ * spawns instead of three per branch:
569
+ *
570
+ * - one `git worktree list --porcelain` (paths + HEAD SHAs)
571
+ * - one `git for-each-ref` for every branch's ahead count
572
+ * - one `git status --porcelain` per worktree WHOSE HEAD MOVED since `cache`
573
+ * (all of them on a cold cache; usually none on a server reload)
574
+ *
575
+ * `opts.cache` is consulted and updated in place; `changed` says whether it
576
+ * needs persisting. `opts.skipStatus` returns the cached dirtiness verbatim
577
+ * and never runs `git status` — the fast boot path, paired with a later
578
+ * reconcile sweep. Fail-soft throughout: a git failure yields
579
+ * `{ path: null, dirty: false }` for that branch, matching `worktreeStatus`.
580
+ */
581
+ export async function resolveWorktreeStatuses(root, branches, opts) {
582
+ const cache = opts.cache ?? new Map();
583
+ const statuses = new Map();
584
+ const wanted = new Set(branches);
585
+ if (wanted.size === 0)
586
+ return { statuses, cache, changed: false };
587
+ const worktrees = worktreeList(root);
588
+ const aheadCounts = branchAheadCounts(root, opts.baseBranch);
589
+ let realRoot = root;
590
+ try {
591
+ realRoot = realpathSync(root);
592
+ }
593
+ catch {
594
+ /* keep root */
595
+ }
596
+ // Branches that need a fresh `git status`: HEAD moved (or not cached).
597
+ const toCheck = [];
598
+ const seenBranches = new Set();
599
+ for (const branch of wanted) {
600
+ const entry = worktrees.get(branch);
601
+ if (!entry) {
602
+ statuses.set(branch, { path: null, dirty: false });
603
+ continue;
604
+ }
605
+ seenBranches.add(branch);
606
+ let realPath = entry.path;
607
+ try {
608
+ realPath = realpathSync(entry.path);
609
+ }
610
+ catch {
611
+ /* keep the reported path */
612
+ }
613
+ // git lists the main checkout as a worktree too; it is never a task worktree.
614
+ if (realPath === realRoot) {
615
+ statuses.set(branch, { path: null, dirty: false });
616
+ continue;
617
+ }
618
+ const ahead = (aheadCounts.get(branch) ?? 0) > 0;
619
+ const cached = cache.get(branch);
620
+ if (opts.skipStatus || (cached && cached.head === entry.head)) {
621
+ const uncommitted = cached && cached.head === entry.head ? cached.uncommitted : false;
622
+ statuses.set(branch, { path: entry.path, dirty: uncommitted || ahead });
623
+ }
624
+ else {
625
+ toCheck.push({ branch, path: entry.path, head: entry.head });
626
+ // provisional: reflect `ahead` now, refine once status returns
627
+ statuses.set(branch, { path: entry.path, dirty: ahead });
628
+ }
629
+ }
630
+ let changed = false;
631
+ // Prune cache entries for branches we looked for that no longer have a
632
+ // worktree (collect first — don't delete while iterating the Map).
633
+ const stale = [];
634
+ for (const branch of cache.keys()) {
635
+ if (!seenBranches.has(branch) && wanted.has(branch))
636
+ stale.push(branch);
637
+ }
638
+ for (const branch of stale) {
639
+ cache.delete(branch);
640
+ changed = true;
641
+ }
642
+ // Fall back to per-branch rev-list when for-each-ref gave nothing (old git).
643
+ const needAheadFallback = aheadCounts.size === 0 && toCheck.length > 0 && !!opts.baseBranch;
644
+ const queue = [...toCheck];
645
+ const workers = Array.from({ length: Math.min(WORKTREE_STATUS_CONCURRENCY, queue.length) }, async () => {
646
+ for (let job = queue.shift(); job; job = queue.shift()) {
647
+ const [status, count] = await Promise.all([
648
+ gitAsync(job.path, ["status", "--porcelain"]),
649
+ needAheadFallback && opts.baseBranch !== job.branch
650
+ ? gitAsync(root, ["rev-list", "--count", `${opts.baseBranch}..${job.branch}`])
651
+ : Promise.resolve(null),
652
+ ]);
653
+ const uncommitted = status !== null && status !== "";
654
+ const ahead = needAheadFallback
655
+ ? count !== null && Number(count) > 0
656
+ : (aheadCounts.get(job.branch) ?? 0) > 0;
657
+ statuses.set(job.branch, { path: job.path, dirty: uncommitted || ahead });
658
+ cache.set(job.branch, { head: job.head, uncommitted });
659
+ changed = true;
660
+ }
661
+ });
662
+ await Promise.all(workers);
663
+ return { statuses, cache, changed };
664
+ }
665
+ /**
666
+ * Discard a task's worktree AND its branch so the next `ensureWorktree`
667
+ * re-creates both from the main checkout's HEAD — a clean restart. The main
668
+ * checkout itself is never touched. Fail-soft: false when git is missing,
669
+ * the branch is the main checkout, or a removal step failed.
670
+ */
671
+ export function resetWorktree(root, branch) {
672
+ const path = worktreePaths(root).get(branch);
673
+ if (path) {
674
+ let realRoot = root;
675
+ let realPath = path;
676
+ try {
677
+ realRoot = realpathSync(root);
678
+ }
679
+ catch {
680
+ /* keep the composed path */
681
+ }
682
+ try {
683
+ realPath = realpathSync(path);
684
+ }
685
+ catch {
686
+ /* keep the reported path */
687
+ }
688
+ if (realPath === realRoot)
689
+ return false;
690
+ if (!removeWorktree(root, branch))
691
+ return false;
692
+ }
693
+ if (localBranches(root).has(branch)) {
694
+ // Force: the branch may carry commits a clean restart is meant to discard.
695
+ if (git(root, ["branch", "-D", branch]) === null)
696
+ return false;
697
+ }
698
+ return true;
699
+ }
700
+ /**
701
+ * What the branch checked out in `worktree` has changed relative to its branch
702
+ * point — the merge-base with `baseBranch` — combining already-committed
703
+ * changes with the current uncommitted working-tree diff. This is the correct
704
+ * base for "did the agent implement anything": diffing against `baseBranch`
705
+ * itself would count unrelated main drift as the branch's own work. When the
706
+ * base cannot be resolved (no git, or the two branches share no ancestor)
707
+ * returns `{ base: null, paths: [] }` so callers can fail soft rather than
708
+ * block on a false positive.
709
+ */
710
+ export function branchChangesSinceBase(worktree, baseBranch) {
711
+ const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
712
+ if (!baseFull)
713
+ return { base: null, paths: [] };
714
+ const base = git(worktree, ["rev-parse", "--short", baseFull]) ?? baseFull;
715
+ const committed = git(worktree, ["diff", "--name-only", baseFull, "HEAD"])
716
+ ?.split("\n")
717
+ .map((s) => s.trim())
718
+ .filter(Boolean) ?? [];
719
+ const uncommitted = git(worktree, ["diff", "--cached", "--name-only"])
720
+ ?.split("\n")
721
+ .map((s) => s.trim())
722
+ .filter(Boolean) ?? [];
723
+ return { base, paths: [...new Set([...committed, ...uncommitted])] };
724
+ }
725
+ const MAX_DIFF_BYTES = 256_000;
726
+ const DIFF_SOURCE_PATHS = ["--", ".", ":(exclude)dist", ":(exclude)screenshots"];
727
+ /** Parse `git diff --numstat` output into file/line counts (shared by the sync and async variants below). */
728
+ function parseNumstat(statOutput) {
729
+ let filesChanged = 0;
730
+ let additions = 0;
731
+ let deletions = 0;
732
+ // Parse output like: "278\t7\tsrc/file.ts" ("-\t-\tpath" for binary files)
733
+ for (const line of statOutput.split("\n")) {
734
+ const match = line.match(/^(\d+|-)\t(\d+|-)\t/);
735
+ if (match) {
736
+ filesChanged++;
737
+ if (match[1] !== "-")
738
+ additions += parseInt(match[1], 10);
739
+ if (match[2] !== "-")
740
+ deletions += parseInt(match[2], 10);
741
+ }
742
+ }
743
+ return { filesChanged, additions, deletions };
744
+ }
745
+ /**
746
+ * Get diff statistics comparing a branch point to the current worktree.
747
+ * This deliberately includes committed, staged, and unstaged source edits so
748
+ * the task drawer can show an agent's work before its handoff commit.
749
+ * Generated build output is excluded because it is not task source.
750
+ * Returns file count and line additions/deletions.
751
+ */
752
+ export function getDiffStats(worktree, baseBranch) {
753
+ const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
754
+ if (!baseFull)
755
+ return { filesChanged: 0, additions: 0, deletions: 0 };
756
+ // --numstat gives exact per-file added/removed line counts, unlike --stat's
757
+ // ASCII bar (which git scales/truncates to fit terminal width and which
758
+ // this code previously had to reverse-engineer by proportional guessing —
759
+ // lossy and prone to drifting from the real totals, see 0248).
760
+ const statOutput = git(worktree, ["diff", "--numstat", baseFull, ...DIFF_SOURCE_PATHS]);
761
+ if (!statOutput)
762
+ return { filesChanged: 0, additions: 0, deletions: 0 };
763
+ return parseNumstat(statOutput);
764
+ }
765
+ /**
766
+ * Non-blocking counterpart to `getDiffStats`, built on `runGit` (`spawn`)
767
+ * instead of `git`'s `execFileSync`. Every task card on the Work board fetches
768
+ * its diff stats on mount (`TaskCard.vue`), so a board with N cards fires N
769
+ * of these on every reload/tab-switch; the synchronous version froze the
770
+ * entire server's event loop for the duration of all of them serially —
771
+ * including whatever `GET /api/tasks/:id` a drawer-opening click was waiting
772
+ * on — which is what made the drawer take several seconds to appear.
773
+ */
774
+ export async function getDiffStatsAsync(worktree, baseBranch) {
775
+ const base = await runGit(worktree, ["merge-base", baseBranch, "HEAD"], 4000);
776
+ const baseFull = base.status === 0 && !base.timedOut ? base.stdout.trim() : null;
777
+ if (!baseFull)
778
+ return { filesChanged: 0, additions: 0, deletions: 0 };
779
+ const diff = await runGit(worktree, ["diff", "--numstat", baseFull, ...DIFF_SOURCE_PATHS], 4000);
780
+ const statOutput = diff.status === 0 && !diff.timedOut ? diff.stdout.trim() : null;
781
+ if (!statOutput)
782
+ return { filesChanged: 0, additions: 0, deletions: 0 };
783
+ return parseNumstat(statOutput);
784
+ }
785
+ /**
786
+ * Get the full patch diff from a branch point to the current worktree.
787
+ * This includes committed, staged, and unstaged source edits, while leaving
788
+ * generated build output out of the task-facing code review view.
789
+ * Bounded at MAX_DIFF_BYTES to avoid sending giant payloads.
790
+ */
791
+ export async function getDiff(worktree, baseBranch) {
792
+ const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
793
+ if (!baseFull)
794
+ return { patch: "", truncated: false };
795
+ const run = await runGit(worktree, ["diff", "--patch", baseFull, ...DIFF_SOURCE_PATHS], 15000);
796
+ if (run.status !== 0 && run.stdout === "")
797
+ return { patch: "", truncated: false };
798
+ const buf = Buffer.from(run.stdout, "utf8");
799
+ if (buf.byteLength <= MAX_DIFF_BYTES)
800
+ return { patch: run.stdout, truncated: false };
801
+ let truncated = run.stdout;
802
+ while (Buffer.from(truncated, "utf8").byteLength > MAX_DIFF_BYTES) {
803
+ truncated = truncated.slice(0, -1024);
804
+ }
805
+ truncated += `\n\n--- diff truncated (${(buf.byteLength / 1024).toFixed(0)} kB total) ---`;
806
+ return { patch: truncated, truncated: true };
807
+ }
808
+ /**
809
+ * The paths changed between `baseBranch` and the current worktree HEAD
810
+ * (`git diff --name-only <baseBranch> HEAD`), used by the close-out gate's
811
+ * docs-only fast path (#0355) to decide whether the full build/check gate can
812
+ * be skipped.
813
+ *
814
+ * Unlike `getDiff`/`getDiffStats`, this deliberately does NOT go through
815
+ * `merge-base`: the caller wants the cumulative difference between the base
816
+ * branch tip and the candidate commit (which already contains the merge), not
817
+ * the branch's own commits. Returns `null` — never `[]` — when git errors or
818
+ * times out, so a caller can distinguish "no changes" from "could not tell"
819
+ * and fail safe by running the full gate.
820
+ */
821
+ export async function getChangedFilePaths(worktree, baseBranch, timeout = 10_000) {
822
+ const run = await runGit(worktree, ["diff", "--name-only", baseBranch, "HEAD"], timeout);
823
+ if (run.status !== 0 || run.timedOut)
824
+ return null;
825
+ return run.stdout
826
+ .split("\n")
827
+ .map((s) => s.trim())
828
+ .filter(Boolean);
829
+ }
830
+ /** Whether git is installed at all (independent of being inside a repo). */
831
+ export function gitAvailable(root) {
832
+ return git(root, ["--version"]) !== null;
833
+ }
834
+ /** `git config --get <key>` value, or null when unset or git is missing. */
835
+ export function gitConfig(root, key) {
836
+ const out = git(root, ["config", "--get", key]);
837
+ return out || null;
838
+ }
839
+ /** Run `git init` in root. True on success. */
840
+ export function gitInit(root) {
841
+ return git(root, ["init"]) !== null;
842
+ }
843
+ /**
844
+ * Commit the ENTIRE working tree. Only for a freshly scaffolded repo where
845
+ * nothing else is pre-staged (the guided `repoos init` flow) — do NOT use this
846
+ * where 0023's surgical `commitNewFile` applies. Fail-soft: null on failure.
847
+ */
848
+ export function gitCommitAll(root, message) {
849
+ if (git(root, ["add", "-A"]) === null)
850
+ return null;
851
+ if (git(root, ["commit", "-m", message]) === null)
852
+ return null;
853
+ return git(root, ["rev-parse", "--short", "HEAD"]);
854
+ }
855
+ /**
856
+ * Surgically commit a single new file with the user's own git identity.
857
+ *
858
+ * `git commit --only` refuses paths git doesn't know, so we first run
859
+ * `git add -N` (intent-to-add): it registers the path WITHOUT staging any
860
+ * content, then `git commit -o -m <msg> -- <file>` commits only that file's
861
+ * working-tree content and IGNORES whatever else is staged or dirty. Never
862
+ * `git add -A`, never a plain `git commit`, never amend/force, hooks run.
863
+ *
864
+ * Fail-soft: never throws, never leaves content partially staged. On any
865
+ * problem it returns `{ ok: false, reason }` and the caller keeps the file.
866
+ */
867
+ export function commitNewFile(root, absPath, message) {
868
+ const relPath = relative(root, absPath).split("\\").join("/");
869
+ if (isAbsolute(relPath) || relPath.startsWith("..")) {
870
+ return { ok: false, reason: `path is outside the repository: ${absPath}` };
871
+ }
872
+ if (!isGitRepo(root)) {
873
+ return { ok: false, reason: "not a git repository (is git installed?)" };
874
+ }
875
+ if (git(root, ["add", "-N", "--", relPath]) === null) {
876
+ return { ok: false, reason: "could not stage the file (mid-merge conflict?)" };
877
+ }
878
+ if (git(root, ["commit", "-o", "-m", message, "--", relPath]) === null) {
879
+ return { ok: false, reason: "commit failed (unconfigured identity, conflicts, or a hook)" };
880
+ }
881
+ const hash = git(root, ["rev-parse", "--short", "HEAD"]);
882
+ return hash ? { ok: true, hash } : { ok: true };
883
+ }
884
+ /**
885
+ * Whether `ancestor` is an ancestor of `descendant` (i.e. a fast-forward merge
886
+ * of `descendant` into a checkout on `ancestor` is possible). Uses
887
+ * `git merge-base --is-ancestor` exit codes; null when git is missing or the
888
+ * refs are invalid.
889
+ */
890
+ export function isAncestor(root, ancestor, descendant) {
891
+ const run = spawnSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
892
+ cwd: root,
893
+ timeout: 4000,
894
+ });
895
+ if (run.status === 0)
896
+ return true;
897
+ if (run.status === 1)
898
+ return false;
899
+ return null;
900
+ }
901
+ /**
902
+ * Cheap, non-destructive pre-flight for merging `branch` into the CURRENT
903
+ * checkout. Uses `git merge --no-commit` and always aborts, so the working
904
+ * tree is left untouched (blocking files may be committed first, as the real
905
+ * `mergeBranch` does, to keep the pre-flight honest).
906
+ *
907
+ * Returns `ok: true` for a clean merge (fast-forward or merge commit), and
908
+ * `ok: false` with the conflict file list when source files would conflict.
909
+ * `drifted` is true whenever main is not an ancestor of the branch.
910
+ */
911
+ export async function preflightMerge(root, branch, opts = {}) {
912
+ const head = currentBranch(root);
913
+ const drifted = head !== null && branch !== head && isAncestor(root, head, branch) !== true;
914
+ // A missing branch used to "pass" pre-flight (a `git merge` of a nonexistent
915
+ // ref fails without producing conflicts), so a branch that was already
916
+ // merged and cleaned up was mislabelled as a fresh failed merge. Fail fast
917
+ // with an explicit reason instead — the close-out's `alreadyMerged` handling
918
+ // resumes those retries before this is ever reached (#0130).
919
+ if (!localBranches(root).has(branch)) {
920
+ return { ok: false, drifted, conflicts: [], reason: `branch \`${branch}\` does not exist` };
921
+ }
922
+ let run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
923
+ if (run.status !== 0 && /would be overwritten by merge/.test(run.stderr)) {
924
+ const blocking = blockingFiles(run.stderr);
925
+ if (blocking.length > 0) {
926
+ for (const p of blocking)
927
+ git(root, ["add", "--", p]);
928
+ if (git(root, ["commit", "-m", "chore: sync working tree before merge"]) !== null) {
929
+ run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
930
+ }
931
+ }
932
+ }
933
+ if (run.status === 0) {
934
+ await runGit(root, ["merge", "--abort"], 4000);
935
+ return { ok: true, drifted, conflicts: [] };
936
+ }
937
+ const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
938
+ // Respect autoResolve semantics so generated files never surface as
939
+ // "conflicts" to the user.
940
+ const autoResolvable = (p) => (opts.autoResolve ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
941
+ const unresolved = conflicts.filter((p) => !autoResolvable(p));
942
+ await runGit(root, ["merge", "--abort"], 4000);
943
+ if (unresolved.length > 0) {
944
+ return {
945
+ ok: false,
946
+ drifted,
947
+ conflicts: unresolved,
948
+ reason: `merge conflict: ${unresolved.join(", ")}`,
949
+ };
950
+ }
951
+ return { ok: true, drifted, conflicts: [] };
952
+ }
953
+ /**
954
+ * Commit a single task file in the main checkout, tracking it first when it is
955
+ * untracked. Task files are the one path that is routinely edited on both sides
956
+ * of a close-out merge: an untracked file aborts `git merge` outright, and a
957
+ * dirty one aborts it with "local changes would be overwritten". Committing the
958
+ * main copy up front (fail-soft) removes both failure modes — the content is
959
+ * preserved in history either way. Returns false when git failed and the file
960
+ * is not clean; callers should treat that as a merge blocker.
961
+ */
962
+ export function commitTaskFile(root, absPath, message) {
963
+ const rel = relative(root, absPath);
964
+ if (rel.startsWith("..") || isAbsolute(rel))
965
+ return false;
966
+ const status = git(root, ["status", "--porcelain", "--", rel]);
967
+ if (status === null)
968
+ return false;
969
+ if (status.trim() === "")
970
+ return true;
971
+ if (git(root, ["add", "--", rel]) === null)
972
+ return false;
973
+ // `--only` with a pathspec commits just this file and leaves everything else
974
+ // already in the index staged (#0353): a plain `git commit` would sweep in
975
+ // whatever a human or agent had staged in the checkout under our message.
976
+ return git(root, ["commit", "-o", "-m", message, "--", rel]) !== null;
977
+ }
978
+ /**
979
+ * The dirty/uncommitted file paths in a checkout (`git status --porcelain`),
980
+ * including tracked modifications and untracked files, in repo-relative form.
981
+ * Used by the close-out flow to check `main` before merging: a dirty tree
982
+ * aborts `git merge`, so the UI surfaces these so it can offer to commit them.
983
+ *
984
+ * FAILS CLOSED: returns `[]` only for a genuinely clean tree (git exit 0 with
985
+ * empty output). Any git failure — a non-zero exit, a timeout, or git being
986
+ * unavailable — throws `GitDirtyCheckError` instead, so a caller cannot
987
+ * mistake an unknown state for a clean tree and silently proceed into a merge
988
+ * that git will abort. This is the fix for #0211, where the old fail-open
989
+ * behaved that way under load.
990
+ */
991
+ export class GitDirtyCheckError extends Error {
992
+ causeKind;
993
+ constructor(causeKind, detail) {
994
+ super(`could not determine the dirty state of the checkout${detail ? `: ${detail}` : ""}`);
995
+ this.name = "GitDirtyCheckError";
996
+ this.causeKind = causeKind;
997
+ }
998
+ }
999
+ export async function dirtyFiles(root) {
1000
+ const out = await runGit(root, ["status", "--porcelain"], 4000);
1001
+ // A genuinely clean tree is git exit 0 with empty output.
1002
+ if (out.status === 0 && (!out.stdout || out.stdout.trim() === ""))
1003
+ return [];
1004
+ // Fail closed: a timeout, a spawn failure (status null), or a non-zero exit
1005
+ // all mean "unknown", not "clean". Surface which so the caller can explain.
1006
+ if (out.timedOut) {
1007
+ throw new GitDirtyCheckError("timeout", "git status exceeded its time budget");
1008
+ }
1009
+ if (out.status === 0) {
1010
+ // Exit 0 with non-empty output: genuinely dirty files to list.
1011
+ return out.stdout
1012
+ .split("\n")
1013
+ .map((l) => l.replace(/\r$/, ""))
1014
+ .map((l) => {
1015
+ // Porcelain v1 short format is two status columns, a separator space,
1016
+ // then the repo-relative path (`XY path`). We read the raw stdout here
1017
+ // (not the shared `git()` helper, whose `.trim()` eats the first line's
1018
+ // leading status column) so the fixed-width parse stays column-accurate.
1019
+ const stripped = l.slice(3).trim();
1020
+ // Renames/renames-with-changes encode the old path before the new one.
1021
+ const arrow = stripped.indexOf(" -> ");
1022
+ return arrow === -1 ? stripped : stripped.slice(arrow + 4);
1023
+ })
1024
+ .filter(Boolean);
1025
+ }
1026
+ const detail = out.stderr.split("\n").filter(Boolean).slice(0, 2).join(" ").trim() ||
1027
+ (out.status === null ? "git did not run" : `git exited ${out.status}`);
1028
+ throw new GitDirtyCheckError(out.status === null ? "no-repo" : "git-error", detail);
1029
+ }
1030
+ /**
1031
+ * Stage and commit every dirty/uncommitted file in a checkout with a single
1032
+ * checkpoint commit so the tree is left clean and mergeable. Used before a
1033
+ * close-out merge when the user opts in ("Commit & continue"). Returns the
1034
+ * list of committed dirty files on success (matching `dirtyFiles`), or an
1035
+ * empty array when the tree was clean or the commit failed.
1036
+ */
1037
+ export async function commitDirtyFiles(root, message) {
1038
+ const files = await dirtyFiles(root);
1039
+ if (files.length === 0)
1040
+ return [];
1041
+ const add = await runGit(root, ["add", "-A"], 15_000);
1042
+ if (add.status !== 0)
1043
+ return [];
1044
+ const commit = await runGit(root, ["commit", "-m", message], 15_000);
1045
+ return commit.status === 0 ? files : [];
1046
+ }
1047
+ /**
1048
+ * The tab-indented paths git lists when a merge aborts because a dirty or
1049
+ * untracked working-tree file would be overwritten, e.g.:
1050
+ *
1051
+ * error: Your local changes to the following files would be overwritten by
1052
+ * merge:
1053
+ * dist/.build-info.json
1054
+ * Please commit your changes or stash them before you merge. Aborting
1055
+ */
1056
+ function blockingFiles(stderr) {
1057
+ return stderr
1058
+ .split("\n")
1059
+ .map((l) => l.replace(/\r$/, ""))
1060
+ .filter((l) => l.startsWith("\t") && l.trim() !== "")
1061
+ .map((l) => l.trim());
1062
+ }
1063
+ /**
1064
+ * Whether a path that conflicted between two sides is one the merge is allowed
1065
+ * to resolve on its own: it matches an `autoResolve` entry (generated output,
1066
+ * the closing task's own file) or an `autoResolveOurs` entry (unrelated task
1067
+ * files, where main's copy wins). A conflicted path matching neither is a REAL
1068
+ * conflict. This is the single definition of "a real conflict" shared by the
1069
+ * real merge and `mergeBranch`'s dry-run pre-flight, so the two can never drift.
1070
+ */
1071
+ function isResolvableConflict(path, opts) {
1072
+ const matches = (rule, p) => p === rule || p.startsWith(rule.endsWith("/") ? rule : rule + "/");
1073
+ return [...(opts.autoResolve ?? []), ...(opts.autoResolveOurs ?? [])].some((r) => matches(r, path));
1074
+ }
1075
+ /**
1076
+ * Whether `root`'s checkout is mid-operation (merge, rebase, cherry-pick,
1077
+ * revert). Used by `dryRunMergeBranch` to refuse a merge it could not cleanly
1078
+ * back out of. `git rev-parse --git-path` resolves the per-worktree location.
1079
+ */
1080
+ function hasInProgressGitOperation(root) {
1081
+ for (const name of [
1082
+ "MERGE_HEAD",
1083
+ "CHERRY_PICK_HEAD",
1084
+ "REVERT_HEAD",
1085
+ "rebase-merge",
1086
+ "rebase-apply",
1087
+ ]) {
1088
+ const path = git(root, ["rev-parse", "--git-path", name]);
1089
+ if (!path)
1090
+ continue;
1091
+ if (existsSync(isAbsolute(path) ? path : join(root, path)))
1092
+ return true;
1093
+ }
1094
+ return false;
1095
+ }
1096
+ /**
1097
+ * The dry-run half of `mergeBranch` (#0358): answer "would this merge hit a
1098
+ * real, non-auto-resolvable conflict?" without leaving any trace. Runs
1099
+ * `git merge --no-commit --no-ff` in the given checkout (the task's own
1100
+ * worktree for the close-out pre-flight) and ALWAYS aborts, so the working
1101
+ * tree, index and branch ref are exactly as found. `--no-ff` is required:
1102
+ * without it a fast-forward would move the branch ref and `--no-commit` would
1103
+ * not stop it.
1104
+ *
1105
+ * Classification reuses `isResolvableConflict`, the same predicate the real
1106
+ * merge uses. A merge that cannot run cleanly at all (a dirty checkout git
1107
+ * refuses to touch, unrelated histories, git missing) is reported as a
1108
+ * non-conflict failure so the caller can fail open into the normal flow.
1109
+ */
1110
+ async function dryRunMergeBranch(root, branch, opts) {
1111
+ // A checkout already mid-merge/rebase is not a state this check may touch:
1112
+ // its unmerged paths are not a property of the branch-vs-main merge, and
1113
+ // `merge --abort` would destroy the user's in-progress operation. Fail open.
1114
+ if (hasInProgressGitOperation(root)) {
1115
+ return {
1116
+ merged: false,
1117
+ ff: false,
1118
+ conflicts: [],
1119
+ reason: "pre-flight skipped: a merge or rebase is already in progress",
1120
+ };
1121
+ }
1122
+ const run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
1123
+ if (run.status === 0) {
1124
+ // A merge that makes no changes (branch already an ancestor of the
1125
+ // checkout) is reported as a clean, no-op success WITHOUT ever setting
1126
+ // MERGE_HEAD — there is nothing to abort, and calling `merge --abort`
1127
+ // anyway would itself fail ("MERGE_HEAD not found"). Only abort when
1128
+ // there is actually a merge in progress to undo.
1129
+ if (hasInProgressGitOperation(root)) {
1130
+ const abort = await runGit(root, ["merge", "--abort"], 4000);
1131
+ if (abort.status !== 0) {
1132
+ // The merge itself succeeded, but the checkout is now mid-merge and
1133
+ // we failed to undo it: the "clean merge" verdict is no longer
1134
+ // trustworthy and the caller must not act on it. Fail open.
1135
+ return {
1136
+ merged: false,
1137
+ ff: false,
1138
+ conflicts: [],
1139
+ reason: "pre-flight skipped: merge --abort failed, checkout left mid-merge",
1140
+ };
1141
+ }
1142
+ }
1143
+ return { merged: true, ff: false, conflicts: [] };
1144
+ }
1145
+ if (/would be overwritten by merge/.test(run.stderr)) {
1146
+ // Do NOT commit the blocking files here the way the real merge does: this
1147
+ // checkout belongs to the task's own worktree, so writing to its history
1148
+ // would be a side effect the pre-flight must never have. Fail open.
1149
+ return {
1150
+ merged: false,
1151
+ ff: false,
1152
+ conflicts: [],
1153
+ reason: "pre-flight skipped: working tree not clean",
1154
+ };
1155
+ }
1156
+ const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
1157
+ const blocking = conflicts.filter((p) => !isResolvableConflict(p, opts));
1158
+ const abort = await runGit(root, ["merge", "--abort"], 4000);
1159
+ if (abort.status !== 0) {
1160
+ // Same reasoning as above: the checkout's merge state is no longer known,
1161
+ // so a "conflict" verdict here would risk sending a repair engineer into
1162
+ // a worktree that is not actually mid-merge on the branch it expects, or
1163
+ // still mid-merge with conflicts unresolved.
1164
+ return {
1165
+ merged: false,
1166
+ ff: false,
1167
+ conflicts: [],
1168
+ reason: "pre-flight skipped: merge --abort failed, checkout left mid-merge",
1169
+ };
1170
+ }
1171
+ if (blocking.length > 0) {
1172
+ return { merged: false, ff: false, conflicts: blocking, reason: "merge conflict" };
1173
+ }
1174
+ if (conflicts.length > 0) {
1175
+ // Every conflicted path auto-resolves, so the real merge completes cleanly.
1176
+ return { merged: true, ff: false, conflicts: [] };
1177
+ }
1178
+ return {
1179
+ merged: false,
1180
+ ff: false,
1181
+ conflicts: [],
1182
+ reason: `${run.stderr}\n${run.stdout}`.trim().split("\n").filter(Boolean).slice(0, 4).join(" ") ||
1183
+ "pre-flight merge failed",
1184
+ };
1185
+ }
1186
+ /**
1187
+ * Merge `branch` into the CURRENT checkout — the main worktree, never the
1188
+ * task's worktree. Default merge semantics: fast-forward when main is an
1189
+ * ancestor of the branch, otherwise a merge commit (`--no-edit` so we never
1190
+ * block on an editor). Longer timeout than `git()`: real merges are slow.
1191
+ *
1192
+ * A dirty working tree aborts `git merge` outright (e.g. the build marker that
1193
+ * `repoos check` regenerates on every run). Rather than fail, the exact files
1194
+ * git refuses to overwrite are committed and the merge is retried once.
1195
+ *
1196
+ * On a conflict the merge is aborted (`git merge --abort`) so nothing is left
1197
+ * half-applied; the conflicted file list is returned and the branch stays
1198
+ * untouched. The exception: when every conflicted path is listed in
1199
+ * `opts.autoResolve`, the branch's version is taken (`--theirs`) and the merge
1200
+ * is completed — used for the task file, which always changes on both sides of
1201
+ * a close-out merge and whose branch version is authoritative for the task's
1202
+ * final state.
1203
+ *
1204
+ * `opts.dryRun` answers the same conflict question non-destructively (see
1205
+ * `dryRunMergeBranch`) — no commit, no branch move, the checkout restored.
1206
+ */
1207
+ export async function mergeBranch(root, branch, opts = {}) {
1208
+ if (opts.dryRun)
1209
+ return dryRunMergeBranch(root, branch, opts);
1210
+ const head = currentBranch(root);
1211
+ let ff = head !== null && branch !== head && isAncestor(root, head, branch) === true;
1212
+ let run = await runGit(root, ["merge", "--no-edit", branch], 60_000);
1213
+ if (run.status !== 0 && /would be overwritten by merge/.test(run.stderr)) {
1214
+ const blocking = blockingFiles(run.stderr);
1215
+ if (blocking.length > 0) {
1216
+ for (const p of blocking)
1217
+ git(root, ["add", "--", p]);
1218
+ if (git(root, ["commit", "-m", "chore: sync working tree before merge"]) !== null) {
1219
+ ff = false;
1220
+ run = await runGit(root, ["merge", "--no-edit", branch], 60_000);
1221
+ }
1222
+ }
1223
+ }
1224
+ const stderr = `${run.stderr}\n${run.stdout}`;
1225
+ if (run.status === 0) {
1226
+ return { merged: true, ff, conflicts: [] };
1227
+ }
1228
+ // Authoritative conflicted paths come from the merge-in-progress index, not
1229
+ // from parsing git's prose (modify/delete and rename messages mangle easily).
1230
+ const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
1231
+ if (conflicts.length > 0) {
1232
+ // A conflicted path is auto-resolvable when it matches an `autoResolve`
1233
+ // entry exactly or sits under a directory entry (e.g. "dist/").
1234
+ const autoResolvable = (p) => (opts.autoResolve ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
1235
+ const keepOurs = (p) => (opts.autoResolveOurs ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
1236
+ const blocking = conflicts.filter((p) => !isResolvableConflict(p, opts));
1237
+ if (conflicts.every((p) => autoResolvable(p) || keepOurs(p))) {
1238
+ // Task metadata may be updated concurrently. The closing task's branch
1239
+ // copy is authoritative, while unrelated task files must retain main's
1240
+ // newer activity/status. Resolve each direction explicitly rather than
1241
+ // applying `-X theirs` to every task file wholesale.
1242
+ for (const p of conflicts) {
1243
+ const side = autoResolvable(p) ? "--theirs" : "--ours";
1244
+ const resolved = await runGit(root, ["checkout", side, "--", p], 10_000);
1245
+ if (resolved.status !== 0)
1246
+ break;
1247
+ }
1248
+ const staged = await runGit(root, ["add", "-A", "--", ...conflicts], 10_000);
1249
+ if (staged.status === 0 && git(root, ["commit", "--no-edit"]) !== null) {
1250
+ return { merged: true, ff: false, conflicts: [] };
1251
+ }
1252
+ await runGit(root, ["merge", "--abort"], 4000);
1253
+ // Every conflicted path was auto-resolvable, but the resolution itself
1254
+ // failed to commit. Report that explicitly rather than a generic failure —
1255
+ // the auto-resolvable paths are not the real culprit, so they are excluded
1256
+ // from `conflicts` (nothing there genuinely blocks a hand-led resolution).
1257
+ return {
1258
+ merged: false,
1259
+ ff: false,
1260
+ conflicts: [],
1261
+ reason: `auto-resolve failed for ${conflicts.join(", ")}`,
1262
+ };
1263
+ }
1264
+ // A genuine, non-auto-resolvable conflict (or a mix of one with auto-
1265
+ // resolvable bookkeeping, e.g. the task's own file). Return ONLY the paths
1266
+ // that genuinely block the merge so a hand-led resolution — and the debugger
1267
+ // the user is routed to — sees the real culprit, never the task-file
1268
+ // bookkeeping that the close-out is supposed to auto-resolve anyway (#0271).
1269
+ // This is consistent with `preflightMerge`, which already filters to the
1270
+ // unresolved (non-auto-resolvable) paths.
1271
+ // Nothing may be left half-applied: back out of the merge entirely.
1272
+ await runGit(root, ["merge", "--abort"], 4000);
1273
+ return { merged: false, ff: false, conflicts: blocking, reason: "merge conflict" };
1274
+ }
1275
+ return {
1276
+ merged: false,
1277
+ ff: false,
1278
+ conflicts: [],
1279
+ reason: stderr.trim().split("\n").filter(Boolean).slice(0, 4).join(" ") || "merge failed",
1280
+ };
1281
+ }
1282
+ /**
1283
+ * Sync `branch` with main: merge the main checkout's current branch into
1284
+ * `branch`'s linked worktree. This is the repository's existing "sync with
1285
+ * main" operation — the same `mergeBranch` semantics the review-transition
1286
+ * auto-sync uses, so the automatic done-flow fallback and the manual route
1287
+ * never diverge. On conflict the merge is aborted (nothing half-applied) and
1288
+ * the conflict list is returned. Fail-soft: false when the branch has no
1289
+ * linked worktree or git is missing.
1290
+ */
1291
+ export async function syncBranchWithMain(root, branch, opts = {}) {
1292
+ const wtPath = worktreePathForBranch(root, branch);
1293
+ if (!wtPath) {
1294
+ return { ok: false, conflicts: [], reason: "no worktree for branch" };
1295
+ }
1296
+ const baseBranch = currentBranch(root) ?? "main";
1297
+ const result = await mergeBranch(wtPath, baseBranch, opts);
1298
+ if (result.merged)
1299
+ return { ok: true, conflicts: [] };
1300
+ return {
1301
+ ok: false,
1302
+ conflicts: result.conflicts,
1303
+ reason: result.conflicts.length
1304
+ ? `merge conflict: ${result.conflicts.join(", ")}`
1305
+ : (result.reason ?? "sync failed"),
1306
+ };
1307
+ }
1308
+ /**
1309
+ * Delete a local branch. The close-out flow only ever deletes a branch that
1310
+ * was just merged into main, so `-d` (refuses unless fully merged) is the
1311
+ * default. Pass `force: true` for a throwaway branch (a `repoos/integrate/*`
1312
+ * candidate) that git will not see as merged.
1313
+ * Fail-soft: false when the branch is gone, not merged, or git is missing —
1314
+ * the caller treats it as best-effort cleanup.
1315
+ */
1316
+ export function deleteBranch(root, branch, opts = {}) {
1317
+ return git(root, ["branch", opts.force ? "-D" : "-d", branch]) !== null;
1318
+ }
1319
+ /** `git worktree prune` — clears metadata for worktrees whose directory is gone. */
1320
+ export function pruneWorktrees(root) {
1321
+ git(root, ["worktree", "prune"]);
1322
+ }
1323
+ /**
1324
+ * Remove the linked worktree for `branch` and make sure it is really gone.
1325
+ * Forced when dirty — close-out content is preserved in the merged main, so
1326
+ * nothing is lost.
1327
+ *
1328
+ * Handles the two states a leak actually shows up in:
1329
+ * - directory + metadata present -> `git worktree remove --force`
1330
+ * - directory gone, metadata stale -> `remove` fails ("is not a working
1331
+ * tree"), so fall back to `git worktree prune`, then `rm -rf` any leftover
1332
+ * directory and prune once more.
1333
+ *
1334
+ * Returns false (without forcing anything) when `branch` is checked out in a
1335
+ * DIFFERENT worktree than expected, or is the main checkout — the caller asked
1336
+ * to remove the wrong thing.
1337
+ */
1338
+ export function removeWorktree(root, branch) {
1339
+ const path = worktreePaths(root).get(branch);
1340
+ if (!path)
1341
+ return true;
1342
+ let realRoot = root;
1343
+ try {
1344
+ realRoot = realpathSync(root);
1345
+ }
1346
+ catch {
1347
+ /* keep */
1348
+ }
1349
+ try {
1350
+ if (realpathSync(path) === realRoot)
1351
+ return false;
1352
+ }
1353
+ catch {
1354
+ /* dir gone — safe to continue */
1355
+ }
1356
+ const first = gitCapture(root, ["worktree", "remove", "--force", path]);
1357
+ if (first.status === 0)
1358
+ return true;
1359
+ if (/is a main working tree|checked out at/i.test(first.stderr))
1360
+ return false;
1361
+ pruneWorktrees(root);
1362
+ const second = gitCapture(root, ["worktree", "remove", "--force", path]);
1363
+ if (second.status === 0)
1364
+ return true;
1365
+ // Last resort: the registration is stale AND a directory is in the way.
1366
+ if (existsSync(path)) {
1367
+ try {
1368
+ rmSync(path, { recursive: true, force: true });
1369
+ }
1370
+ catch {
1371
+ /* best-effort */
1372
+ }
1373
+ }
1374
+ pruneWorktrees(root);
1375
+ // Success == the branch no longer resolves to any registered worktree.
1376
+ return !worktreePaths(root).has(branch);
1377
+ }
1378
+ /**
1379
+ * Prepare the main checkout for a hotfix task.
1380
+ *
1381
+ * 1. Create a `hotfix/<id>-<slug>` branch from main (when hotfixTarget is
1382
+ * "branch") or stay on main (when target is "main").
1383
+ * 2. Refuse if the main checkout is dirty.
1384
+ * 3. Return config.root as the working directory — the agent runs here.
1385
+ */
1386
+ export function ensureHotfix(root, branch, hotfixTarget) {
1387
+ if (!isGitRepo(root)) {
1388
+ return { ok: false, path: root, branch, reason: "not a git repository" };
1389
+ }
1390
+ const head = currentBranch(root);
1391
+ if (!head) {
1392
+ return { ok: false, path: root, branch, reason: "could not determine current branch" };
1393
+ }
1394
+ // For branch-mode hotfixes: create the hotfix branch if it doesn't exist,
1395
+ // then check it out. For main-mode: verify we're already on main.
1396
+ if (hotfixTarget === "branch") {
1397
+ if (head !== branch) {
1398
+ if (!localBranches(root).has(branch)) {
1399
+ if (git(root, ["checkout", "-b", branch]) === null) {
1400
+ return { ok: false, path: root, branch, reason: `could not create branch ${branch}` };
1401
+ }
1402
+ }
1403
+ else {
1404
+ if (git(root, ["checkout", branch]) === null) {
1405
+ return { ok: false, path: root, branch, reason: `could not checkout branch ${branch}` };
1406
+ }
1407
+ }
1408
+ }
1409
+ }
1410
+ else {
1411
+ if (head !== "main") {
1412
+ return {
1413
+ ok: false,
1414
+ path: root,
1415
+ branch,
1416
+ reason: "main-mode hotfix requires the main checkout to be on main",
1417
+ };
1418
+ }
1419
+ }
1420
+ return { ok: true, path: root, branch };
1421
+ }
1422
+ /**
1423
+ * Reset the main checkout back to `main` after a hotfix, discarding any
1424
+ * in-flight work. Only touches root when it's on a hotfix branch.
1425
+ */
1426
+ export function resetHotfix(root, branch) {
1427
+ // Only safe to reset if we're on the hotfix branch and it's not main.
1428
+ const head = currentBranch(root);
1429
+ if (!head || head === "main")
1430
+ return false;
1431
+ if (head !== branch)
1432
+ return false;
1433
+ return git(root, ["checkout", "main"]) !== null;
1434
+ }
1435
+ /**
1436
+ * Get agent-touched files in the working directory: the union of tracked
1437
+ * worktree-changed files and newly-added files since the last ref, excluding
1438
+ * dist/ and screenshots/. Returns an empty list on git failure.
1439
+ */
1440
+ export function agentTouchedFiles(root, sinceRef) {
1441
+ // Staged + unstaged changes (tracked files)
1442
+ const diff = git(root, ["diff", "--name-only", sinceRef, "--", "."])
1443
+ ?.split("\n")
1444
+ .map((s) => s.trim())
1445
+ .filter(Boolean) ?? [];
1446
+ // Untracked files that aren't in dist/ or screenshots/
1447
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard"])
1448
+ ?.split("\n")
1449
+ .map((s) => s.trim())
1450
+ .filter(Boolean)
1451
+ .filter((p) => !p.startsWith("dist/") && !p.startsWith("screenshots/")) ?? [];
1452
+ return [...new Set([...diff, ...untracked])];
1453
+ }