@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,1299 @@
1
+ /**
2
+ * Orchestrates individual close-out jobs through their lifecycle. Each job:
3
+ * 1. Validates the candidate away from live main (temporary worktree/branch)
4
+ * 2. Checks for main SHA drift and rebuilds if needed
5
+ * 3. Publishes only a green candidate while holding the repo lock
6
+ * 4. Cleans up the candidate and task resources
7
+ *
8
+ * Phases track recovery: if interrupted mid-flight, retry resumes from the current phase.
9
+ */
10
+ import { readFileSync, existsSync, symlinkSync, readdirSync } from "node:fs";
11
+ import { join, relative } from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { currentBranch, runGit, worktreePathForBranch, ensureWorktree, removeWorktree, deleteBranch, pruneWorktrees, isGitRepo, commitDirtyFiles, mergeBranch, dirtyFiles, getDiff, getDiffStats, getChangedFilePaths, GitDirtyCheckError, } from "../core/git.js";
14
+ import { sweepAndWarn } from "../core/worktree-gc.js";
15
+ import { redactSecrets, stripAnsi } from "./done.js";
16
+ import { markTaskReleased } from "./write.js";
17
+ import { saveDiffSnapshot } from "./diff-snapshot.js";
18
+ import { parseTask } from "../core/task.js";
19
+ import { summarizeCheckFailure } from "../core/check-failure-summary.js";
20
+ // Candidate branch prefix. Must be a valid git refname: a leading dot is
21
+ // rejected by git (`'.repoos/integrate/…' is not a valid branch name`), which
22
+ // silently failed every close-out job at worktree creation.
23
+ const CANDIDATE_BRANCH_PREFIX = "repoos/integrate/";
24
+ /** Shared literal for the failed job phase so recovery paths stay consistent. */
25
+ const PHASE_FAILED = "failed";
26
+ /**
27
+ * Cap on consecutive publish-time "main advanced" resyncs (#0386). Every
28
+ * OTHER retry path in the close-out pipeline is capped (#0216's validate
29
+ * retry, handoff.ts's MAX_*_RETRY_ATTEMPTS) — this one wasn't, so on a busy
30
+ * board a task could restart forever, never winning the race to publish.
31
+ * Deliberately generous: a single trivial `docs(<id>): update task` commit
32
+ * for an unrelated task is common and harmless to resync past once or
33
+ * twice; this only fires once retries are clearly not converging.
34
+ */
35
+ const MAX_PUBLISH_DRIFT_RETRIES = 5;
36
+ function candidateBranchName(taskId) {
37
+ return `${CANDIDATE_BRANCH_PREFIX}${taskId}`;
38
+ }
39
+ /**
40
+ * The merge that just ran left the candidate at exactly base main — it added
41
+ * nothing. That is legitimate when the feature branch was already fully
42
+ * integrated (a re-run of a close-out, a branch cherry-picked onto main
43
+ * earlier): the close-out is idempotent and the task genuinely has nothing
44
+ * left to land.
45
+ *
46
+ * It is a BUG when the branch still carries commits main lacks and the merge —
47
+ * or, far more often, the conflict auto-resolution in `mergeBranch` — silently
48
+ * dropped them. Left unguarded, the gate then runs `build` + `check` against
49
+ * what is effectively bare main (which trivially passes) and the task publishes
50
+ * as `done` with none of its code integrated. This bit #0306/#0307/#0309/#0312.
51
+ *
52
+ * Returns an actionable failure reason in the bug case, `null` when the no-op
53
+ * is legitimate (or when git errored and a downstream step should surface it).
54
+ */
55
+ export async function detectDroppedMerge(root, mainBranch, featureBranch, postMergeHead, baseMainSha) {
56
+ if (postMergeHead !== baseMainSha)
57
+ return null;
58
+ // `git diff --quiet A...B` — exit 0: B adds nothing over the merge-base
59
+ // (branch already in main → legit); exit 1: B carries a real delta that the
60
+ // merge failed to bring across; >1: git error, leave it for a later step.
61
+ const delta = await runGit(root, ["diff", "--quiet", `${mainBranch}...${featureBranch}`], 15_000);
62
+ if (delta.status !== 1)
63
+ return null;
64
+ return (`merge of ${featureBranch} produced no change to ${mainBranch}, but the branch still carries ` +
65
+ `commits ${mainBranch} does not have — the merge, or its conflict auto-resolution, dropped them. ` +
66
+ `Rebase ${featureBranch} onto ${mainBranch} and resolve the conflicts there, then retry.`);
67
+ }
68
+ /**
69
+ * Make main's `<workDir>/*.md` state authoritative for every task file the
70
+ * merged candidate changed that is NOT the closing task's own file, and commit
71
+ * the restore.
72
+ *
73
+ * A feature branch can carry stale edits to other tasks' files — a concurrent
74
+ * board write, a `repoos` CLI call in the worktree, a partial merge — that git
75
+ * folds in without a conflict (main untouched since the merge-base), so the
76
+ * close-out's conflict-only `autoResolveOurs` never catches them and the stale
77
+ * version publishes to main. This closes that gap: a foreign file modified or
78
+ * deleted by the branch is restored to main's copy; one the branch newly added
79
+ * is removed (a close-out never creates another task's file). Returns the paths
80
+ * touched (empty when there were none); throws only if the git ops fail.
81
+ *
82
+ * Exported for tests.
83
+ */
84
+ export async function resetForeignWorkFiles(opts) {
85
+ const { candidateWtPath, baseMainSha, workDir, ownWorkFile } = opts;
86
+ const changed = await runGit(candidateWtPath, ["diff", "--name-only", `${baseMainSha}..HEAD`, "--", workDir], 10_000);
87
+ if (changed.status !== 0)
88
+ return [];
89
+ const foreign = changed.stdout
90
+ .split("\n")
91
+ .map((s) => s.trim())
92
+ .filter((p) => p && p.endsWith(".md") && p !== ownWorkFile);
93
+ if (foreign.length === 0)
94
+ return [];
95
+ // Partition by whether the file exists at main's tip: modified/deleted files
96
+ // get main's version back; branch-added files get removed.
97
+ const onMain = [];
98
+ const addedByBranch = [];
99
+ for (const p of foreign) {
100
+ const exists = await runGit(candidateWtPath, ["cat-file", "-e", `${baseMainSha}:${p}`], 5_000);
101
+ (exists.status === 0 ? onMain : addedByBranch).push(p);
102
+ }
103
+ if (onMain.length > 0) {
104
+ const restore = await runGit(candidateWtPath, ["checkout", baseMainSha, "--", ...onMain], 15_000);
105
+ if (restore.status !== 0) {
106
+ throw new Error(`could not restore unrelated task files: ${restore.stderr.trim()}`);
107
+ }
108
+ await runGit(candidateWtPath, ["add", "--", ...onMain], 10_000);
109
+ }
110
+ if (addedByBranch.length > 0) {
111
+ const removed = await runGit(candidateWtPath, ["rm", "-q", "--", ...addedByBranch], 10_000);
112
+ if (removed.status !== 0) {
113
+ throw new Error(`could not drop stray task files: ${removed.stderr.trim()}`);
114
+ }
115
+ }
116
+ const staged = await runGit(candidateWtPath, ["diff", "--cached", "--quiet"], 10_000);
117
+ if (staged.status !== 1)
118
+ return []; // nothing actually differed after all
119
+ await runGit(candidateWtPath, [
120
+ "commit",
121
+ "--no-edit",
122
+ "-m",
123
+ `chore: keep main's copy of ${foreign.length} unrelated task file(s) during close-out`,
124
+ ], 10_000);
125
+ return foreign;
126
+ }
127
+ /**
128
+ * Whether a candidate's merged diff is documentation-only, so the expensive
129
+ * build + check gate in `validateCandidate` can be skipped (#0355).
130
+ *
131
+ * Deliberately literal and mechanical: a path qualifies only if it lives under
132
+ * `docs/` or `user-docs/`, or ends in `.md`. The closing task's own
133
+ * `work/<id>-*.md` file is the common `.md` case — it always changes, and
134
+ * `resetForeignWorkFiles` has already restored every OTHER task's work file to
135
+ * main's copy before this runs, so no foreign task file reaches this check.
136
+ * Anything else — `repoos.toml`, `package.json`, `src/`, `scripts/`,
137
+ * `.github/`, … — disqualifies the fast path. No scoring, no "mostly docs".
138
+ *
139
+ * An empty path list is vacuously docs-only: a legitimate no-op merge (the
140
+ * branch was already fully integrated) leaves nothing for build/test to
141
+ * examine, and the dropped-merge guard in `validateCandidate` runs earlier and
142
+ * already rejects an empty merge that actually swallowed real branch work.
143
+ */
144
+ export function isDocsOnlyChange(paths) {
145
+ return paths.every((p) => p.startsWith("docs/") || p.startsWith("user-docs/") || p.endsWith(".md"));
146
+ }
147
+ /**
148
+ * Resolve the repository's actual default branch name.
149
+ * Tries (in order):
150
+ * 1. git symbolic-ref refs/remotes/origin/HEAD (when remote exists)
151
+ * 2. git config --get init.defaultBranch
152
+ * 3. currently checked-out branch
153
+ * 4. fallback to "main"
154
+ */
155
+ /**
156
+ * Whether this project is expected to build its own `dist/cli/index.js` at
157
+ * all. True only for RepoOS itself (self-hosting) and any other managed
158
+ * project that happens to declare the same `bin` target — the vast majority
159
+ * of managed projects (a Vue app, a backend service, ...) never produce a
160
+ * CLI there, so a missing local CLI for them is normal, not a regression.
161
+ * Detected via `package.json`'s `bin` field rather than a name check so any
162
+ * project that legitimately ships a `dist/cli/index.js` binary is covered.
163
+ */
164
+ function expectsOwnCli(wtPath) {
165
+ try {
166
+ const pkg = JSON.parse(readFileSync(join(wtPath, "package.json"), "utf8"));
167
+ const binPaths = typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin ?? {});
168
+ return binPaths.some((p) => p.replace(/^\.\//, "") === "dist/cli/index.js");
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ async function resolveDefaultBranch(root) {
175
+ // Try remote HEAD first (most reliable for cloned repos)
176
+ const remoteHeadRes = await runGit(root, ["symbolic-ref", "refs/remotes/origin/HEAD"], 4000);
177
+ if (remoteHeadRes.status === 0) {
178
+ // Output format: "ref: refs/remotes/origin/main" -> extract "main"
179
+ const match = remoteHeadRes.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
180
+ if (match)
181
+ return match[1];
182
+ }
183
+ // Try init.defaultBranch config
184
+ const configRes = await runGit(root, ["config", "--get", "init.defaultBranch"], 4000);
185
+ if (configRes.status === 0 && configRes.stdout.trim()) {
186
+ return configRes.stdout.trim();
187
+ }
188
+ // Fall back to currently checked-out branch
189
+ const currentRes = await runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"], 4000);
190
+ if (currentRes.status === 0 && currentRes.stdout.trim() && currentRes.stdout.trim() !== "HEAD") {
191
+ return currentRes.stdout.trim();
192
+ }
193
+ // Final fallback
194
+ return "main";
195
+ }
196
+ // How much of a failed command's output to keep as the failure reason.
197
+ const TAIL_LINES = 15;
198
+ const TAIL_MAX_CHARS = 800;
199
+ /**
200
+ * Locate a task's file directly on disk by id, independent of the live
201
+ * index's freshness. Used only as a fallback when `getTask` misses — the
202
+ * normal, common path is the index lookup, which is far cheaper than a
203
+ * directory scan. Task ids are unique by filename convention (`<id>-*.md`).
204
+ */
205
+ function findTaskFileById(root, workDir, taskId) {
206
+ const dir = join(root, workDir);
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(dir);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ const match = entries.find((f) => f.startsWith(`${taskId}-`) && f.endsWith(".md"));
215
+ return match ? join(dir, match) : null;
216
+ }
217
+ /**
218
+ * The useful part of a failed command's combined output. bun/npm wrap a child
219
+ * failure with a generic trailing line (`error: script "repoos" exited with
220
+ * code 1`) that is useless as a reason on its own, so keep the last several
221
+ * meaningful lines — the real cause (a failing test, a compiler error) sits
222
+ * just above the wrapper.
223
+ *
224
+ * The reason is persisted to `.repoos/integration-jobs/<id>.json` and shown
225
+ * verbatim in the UI, so it must be free of ANSI escapes (the gate's test
226
+ * output is colored) and of anything that looks like a credential. When the
227
+ * excerpt exceeds the character cap it is cut from the front at a WORD
228
+ * boundary — cutting mid-word produced reasons like `check failed: …eletion
229
+ * detected by…` (0215) that read as garbage.
230
+ */
231
+ export function tailLine(stdout, stderr) {
232
+ const cleaned = redactSecrets(stripAnsi(`${stdout}\n${stderr}`));
233
+ // Vitest's final lines are usually the least useful part of a failure: a
234
+ // partial received/expected diff followed by its generic runner summary.
235
+ // Prefer its explicit FAIL block when present; the full transcript remains
236
+ // available in the task's Debug tab.
237
+ const testFailure = summarizeCheckFailure(cleaned);
238
+ if (testFailure)
239
+ return testFailure;
240
+ const lines = cleaned
241
+ .split("\n")
242
+ .map((l) => l.trim())
243
+ .filter(Boolean);
244
+ if (lines.length === 0)
245
+ return "unknown error";
246
+ let tail = lines.slice(-TAIL_LINES).join("\n");
247
+ if (tail.length > TAIL_MAX_CHARS) {
248
+ const cut = tail.length - TAIL_MAX_CHARS;
249
+ const lastWs = Math.max(tail.lastIndexOf(" ", cut), tail.lastIndexOf("\n", cut));
250
+ const start = lastWs >= 0 ? lastWs + 1 : cut;
251
+ return `…${tail.slice(start)}`;
252
+ }
253
+ return tail;
254
+ }
255
+ function runProcess(cmd, args, opts) {
256
+ return new Promise((resolve) => {
257
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env });
258
+ let stdout = "";
259
+ let stderr = "";
260
+ let timedOut = false;
261
+ let errorCode;
262
+ let settled = false;
263
+ let timer;
264
+ const finish = (status) => {
265
+ if (settled)
266
+ return;
267
+ settled = true;
268
+ if (timer)
269
+ clearTimeout(timer);
270
+ resolve({ status, stdout, stderr, timedOut, errorCode });
271
+ };
272
+ child.stdout.on("data", (d) => {
273
+ const text = d.toString("utf8");
274
+ stdout += text;
275
+ opts.onChunk?.(text);
276
+ });
277
+ child.stderr.on("data", (d) => {
278
+ const text = d.toString("utf8");
279
+ stderr += text;
280
+ opts.onChunk?.(text);
281
+ });
282
+ child.on("error", (err) => {
283
+ errorCode = err.code;
284
+ finish(null);
285
+ });
286
+ child.on("close", (code) => finish(code));
287
+ timer = setTimeout(() => {
288
+ timedOut = true;
289
+ child.kill("SIGKILL");
290
+ }, opts.timeout);
291
+ });
292
+ }
293
+ /**
294
+ * True when the command couldn't be launched at all (not installed), as
295
+ * opposed to running and failing. Only then is falling back to another tool
296
+ * (npm instead of bun) useful; a build that ran and failed is the real result.
297
+ */
298
+ function commandMissing(res) {
299
+ return res.errorCode === "ENOENT" || res.errorCode === "EACCES";
300
+ }
301
+ /** Candidate validation and publication orchestrator for one job. */
302
+ export class CloseOutOrchestrator {
303
+ config;
304
+ coordinator;
305
+ repoLock;
306
+ rootLock;
307
+ getTask;
308
+ onProgress;
309
+ logger;
310
+ onMergeConflict;
311
+ remoteValidator;
312
+ taskChecks;
313
+ onTaskCheckEvent;
314
+ constructor(config, coordinator, repoLock, rootLock, getTask, onProgress, logger,
315
+ /**
316
+ * Fired when `validating` fails on a REAL, named merge conflict (never
317
+ * for the task's own bookkeeping file or generated paths, which
318
+ * auto-resolve; never for infra failures). The job itself is still
319
+ * marked `failed` as before — this is a separate, best-effort trigger
320
+ * to auto-resume the engineer to fix it and resubmit (#0271 follow-up).
321
+ */
322
+ onMergeConflict,
323
+ /**
324
+ * Runs the expensive half of the gate (build + test) on a cloud VM when
325
+ * `config.remoteValidation.enabled` — see validateCandidate. Undefined
326
+ * disables remote validation regardless of config.
327
+ */
328
+ remoteValidator,
329
+ /** Records the MTD merge-gate `repoos check` run for the Debug tab (0310). */
330
+ taskChecks, onTaskCheckEvent) {
331
+ this.config = config;
332
+ this.coordinator = coordinator;
333
+ this.repoLock = repoLock;
334
+ this.rootLock = rootLock;
335
+ this.getTask = getTask;
336
+ this.onProgress = onProgress;
337
+ this.logger = logger;
338
+ this.onMergeConflict = onMergeConflict;
339
+ this.remoteValidator = remoteValidator;
340
+ this.taskChecks = taskChecks;
341
+ this.onTaskCheckEvent = onTaskCheckEvent;
342
+ }
343
+ /**
344
+ * Whether the task is, on disk, already `done`. The live index is an
345
+ * in-memory cache that can lag the actual file (a duplicate/stale close-out
346
+ * — #0289 — can run right after the first successful publish, before the
347
+ * index rebuilds), so this reads the authoritative task file directly,
348
+ * falling back to the index only when the file cannot be located or parsed.
349
+ */
350
+ taskIsDone(taskId) {
351
+ const live = this.getTask?.(taskId);
352
+ const workDir = this.config.workDir;
353
+ const path = live?.absPath ?? (workDir ? findTaskFileById(this.config.root, workDir, taskId) : null);
354
+ if (path && existsSync(path)) {
355
+ try {
356
+ const task = parseTask({
357
+ content: readFileSync(path, "utf8"),
358
+ absPath: path,
359
+ root: this.config.root,
360
+ defaultStatus: this.config.defaultStatus ?? "inbox",
361
+ defaultAssignee: this.config.defaultAssignee ?? "unassigned",
362
+ });
363
+ if (task.status === "done")
364
+ return true;
365
+ }
366
+ catch {
367
+ /* fall through to the index below */
368
+ }
369
+ }
370
+ return live?.status === "done";
371
+ }
372
+ /**
373
+ * Record a close-out job failure, OR reconcile it away when it is moot.
374
+ *
375
+ * A failure is moot when the task is already `done`: the failing job is a
376
+ * duplicate/stale enqueue against a task that already finished successfully
377
+ * through an earlier job (#0289). That earlier close-out's cleanup deleted
378
+ * the worktree/branch, which is exactly why a later redundant enqueue fails
379
+ * (e.g. "worktree not found"). There is no gate failure here needing a
380
+ * human's attention, and the failed job record would otherwise sit forever
381
+ * with no path to resolution, so it is dropped instead.
382
+ *
383
+ * Returns the `{ ok, reason }` the caller should return: `ok: true` for a
384
+ * reconciled (moot) failure so the pipeline treats it as a normal completion
385
+ * and never surfaces it as an actionable error; `ok: false` for a genuine
386
+ * failure, which is recorded as `failed` (and `onRecorded`, e.g. the merge-
387
+ * conflict retry, runs) so the UI and status bar can surface it.
388
+ */
389
+ /**
390
+ * Tear down the `repoos/integrate/<id>` candidate worktree + branch. Safe to
391
+ * call whether or not the candidate exists. The candidate is a throwaway
392
+ * scratch area rebuilt from scratch on the next run, so its branch is
393
+ * force-deleted (git will not see it as merged after a failed job).
394
+ *
395
+ * Runs on EVERY terminal job outcome — success (`cleanup`), genuine failure,
396
+ * and moot/reconciled — so a candidate never outlives its job. Without this,
397
+ * every failed close-out leaked a worktree that inflated cold-boot `git
398
+ * status` fan-out until someone ran gc.
399
+ */
400
+ removeCandidate(taskId) {
401
+ const root = this.config.root;
402
+ const branch = candidateBranchName(taskId);
403
+ removeWorktree(root, branch);
404
+ deleteBranch(root, branch, { force: true });
405
+ pruneWorktrees(root);
406
+ }
407
+ failOrReconcile(job, failedPhase, reason, onRecorded) {
408
+ this.removeCandidate(job.taskId);
409
+ if (this.taskIsDone(job.taskId)) {
410
+ this.logger?.integration(job.taskId, "info", "close-out failure is moot — task already done; dropping job", { reason });
411
+ this.coordinator.removeJob(job.taskId);
412
+ return { ok: true };
413
+ }
414
+ this.coordinator.updateJob(job.taskId, { phase: PHASE_FAILED, failedPhase, reason });
415
+ onRecorded?.();
416
+ return { ok: false, reason };
417
+ }
418
+ /**
419
+ * Process the next job in the queue: validate and publish it.
420
+ * Phases are atomic; a retry at any phase resumes from that phase.
421
+ * May return early if the job transitions back to an earlier phase (e.g., on main drift).
422
+ */
423
+ async processNext() {
424
+ const job = this.coordinator.peekNext();
425
+ if (!job)
426
+ return { ok: true, reason: "queue empty" };
427
+ return this.processJob(job);
428
+ }
429
+ async processJob(job) {
430
+ const root = this.config.root;
431
+ this.logger?.integration(job.taskId, "info", `Processing job phase: ${job.phase}`, {
432
+ taskId: job.taskId,
433
+ phase: job.phase,
434
+ });
435
+ try {
436
+ if (!isGitRepo(root)) {
437
+ return { ok: false, reason: "not a git repository" };
438
+ }
439
+ // Transition from queued to syncing.
440
+ if (job.phase === "queued") {
441
+ const updated = this.coordinator.updateJob(job.taskId, {
442
+ phase: "syncing",
443
+ startedAt: new Date().toISOString(),
444
+ });
445
+ if (!updated)
446
+ return { ok: false, reason: "job disappeared" };
447
+ job = updated;
448
+ }
449
+ // Syncing phase: ensure the candidate worktree exists and is up to date with main.
450
+ if (job.phase === "syncing") {
451
+ const syncRes = await this.syncCandidate(job);
452
+ if (!syncRes.ok) {
453
+ this.logger?.integration(job.taskId, "error", "sync failed", { reason: syncRes.reason });
454
+ if (syncRes.conflict) {
455
+ // A real conflict caught by the pre-flight (#0358): the candidate
456
+ // worktree was never created, but the outcome is identical to one
457
+ // found during `validating` — same reason format, same
458
+ // non-retryable handling, same repair handoff. Recorded against
459
+ // `validating` so nothing downstream can tell the timing moved.
460
+ return this.failOrReconcile(job, "validating", syncRes.reason, () => this.onMergeConflict?.(job.taskId, syncRes.reason));
461
+ }
462
+ return this.failOrReconcile(job, "syncing", syncRes.reason);
463
+ }
464
+ job = this.coordinator.updateJob(job.taskId, { phase: "validating" });
465
+ }
466
+ // Validating phase: run the full gate (build, check) on the candidate.
467
+ //
468
+ // The gate is retried once before the job is failed (#0216). A false
469
+ // failure here is expensive — the branch is green, but the task is
470
+ // stranded in review and the user cannot tell contention from a real
471
+ // regression. The retry also classifies the failure, which is the part
472
+ // that actually helps: a genuine defect reproduces with the SAME reason
473
+ // (Node-version-dependent breakage did exactly this on #0205, failing
474
+ // identically twice), whereas load-induced failures land on a different
475
+ // test each run (#0211 failed on two unrelated tests). Two attempts is
476
+ // the cap — this must never loop.
477
+ if (job.phase === "validating") {
478
+ let validateRes = await this.validateCandidate(job);
479
+ if (!validateRes.ok && validateRes.retryable === false) {
480
+ // Deterministic by construction — a second run proves nothing and
481
+ // costs the user another full gate cycle.
482
+ this.logger?.integration(job.taskId, "error", "validation failed (non-retryable)", {
483
+ reason: validateRes.reason,
484
+ });
485
+ // A named, real conflict (not the task's own bookkeeping file or a
486
+ // generated path — those auto-resolve inside validateCandidate and
487
+ // never reach here) is the one non-retryable failure with an
488
+ // actionable fix: merge main into the FEATURE branch and resolve it
489
+ // there. Give the engineer a shot at that automatically instead of
490
+ // leaving the job sitting `failed` until a human notices (#0271
491
+ // follow-up).
492
+ const conflict = validateRes.reason?.startsWith("merge conflict in ");
493
+ return this.failOrReconcile(job, "validating", validateRes.reason, conflict ? () => this.onMergeConflict?.(job.taskId, validateRes.reason) : undefined);
494
+ }
495
+ if (!validateRes.ok) {
496
+ const firstReason = validateRes.reason ?? "unknown";
497
+ validateRes = await this.validateCandidate(job);
498
+ if (!validateRes.ok) {
499
+ const secondReason = validateRes.reason ?? "unknown";
500
+ const reason = firstReason === secondReason
501
+ ? `${secondReason} — reproduced identically on retry, so this is a real failure in the branch, not machine load`
502
+ : `${secondReason} — NOTE: the first attempt failed differently (${firstReason}). Two unrelated failures point at machine load or infrastructure rather than a regression in this branch; check for stray serve processes and retry.`;
503
+ return this.failOrReconcile(job, "validating", reason);
504
+ }
505
+ }
506
+ job = this.coordinator.updateJob(job.taskId, {
507
+ phase: "publishing",
508
+ candidateSha: validateRes.candidateSha,
509
+ });
510
+ }
511
+ // Publishing phase: merge candidate to live main, holding the repo lock.
512
+ if (job.phase === "publishing") {
513
+ const pubRes = await this.publishCandidate(job);
514
+ if (!pubRes.ok) {
515
+ // Check if the job phase was changed by publishCandidate() (e.g., drift handling).
516
+ // If it was moved back to "syncing" for retry, don't overwrite it to "failed".
517
+ const currentJob = this.coordinator.getJob(job.taskId);
518
+ if (currentJob && currentJob.phase === "syncing") {
519
+ // Drift detected and handled - will retry on next processNext() call
520
+ this.logger?.integration(job.taskId, "info", "main drifted during publish — resyncing", {
521
+ reason: pubRes.reason,
522
+ });
523
+ return pubRes;
524
+ }
525
+ this.logger?.integration(job.taskId, "error", "publish failed", {
526
+ reason: pubRes.reason,
527
+ });
528
+ return this.failOrReconcile(job, "publishing", pubRes.reason);
529
+ }
530
+ job = this.coordinator.updateJob(job.taskId, { phase: "cleanup" });
531
+ }
532
+ // Cleanup phase: remove candidate worktree, delete candidate branch, remove task worktree.
533
+ if (job.phase === "cleanup") {
534
+ const cleanRes = await this.cleanup(job);
535
+ if (!cleanRes.ok) {
536
+ // Log but don't fail: the merge succeeded, so task is done even if cleanup is messy.
537
+ this.logger?.integration(job.taskId, "warn", "cleanup warning", {
538
+ reason: cleanRes.reason,
539
+ });
540
+ console.warn(`Cleanup warning for task ${job.taskId}: ${cleanRes.reason}`);
541
+ }
542
+ job = this.coordinator.updateJob(job.taskId, { phase: "done" });
543
+ this.logger?.integration(job.taskId, "info", "close-out complete — published to main");
544
+ }
545
+ return { ok: true };
546
+ }
547
+ catch (err) {
548
+ const reason = err instanceof Error ? err.message : "unknown error";
549
+ this.logger?.integration(job.taskId, "error", "orchestrator error", { reason });
550
+ return this.failOrReconcile(job, job.phase ?? "unknown", `orchestrator error: ${reason}`);
551
+ }
552
+ }
553
+ /**
554
+ * Cheap, non-destructive pre-flight (#0358): would merging the task's feature
555
+ * branch into current main hit a REAL (non-auto-resolvable) conflict?
556
+ *
557
+ * Runs BEFORE any candidate worktree exists, in the feature branch's own
558
+ * worktree (`worktreePathForBranch`, already checked out and cheap to use)
559
+ * against the CURRENT main SHA, reusing `mergeBranch`'s dry-run mode so the
560
+ * classification is literally the real merge's — never a second, drifting
561
+ * definition of "a real conflict". Returns the conflict reason in the exact
562
+ * format `validateCandidate` uses, or `null` when there is no real conflict.
563
+ *
564
+ * Fail-open by construction: a missing feature worktree, any git error, or a
565
+ * dry-run that cannot run cleanly all return `null`, so the caller falls
566
+ * through to the existing sync/validate flow rather than blocking on a broken
567
+ * optimization.
568
+ */
569
+ async preflightConflict(job, baseMainSha) {
570
+ const root = this.config.root;
571
+ const featureBranch = job.branch ?? job.taskId;
572
+ // No worktree to run against: let the normal flow report the real failure
573
+ // ("feature branch … worktree not found") rather than guessing here.
574
+ const featureWtPath = worktreePathForBranch(root, featureBranch);
575
+ if (!featureWtPath)
576
+ return null;
577
+ const task = this.getTask?.(job.taskId);
578
+ const autoResolve = ["dist/", "screenshots/", ...(task ? [relative(root, task.absPath)] : [])];
579
+ // Same semantics as `validateCandidate`: unrelated task files keep main's
580
+ // side, everything else in `autoResolve` takes the branch's side. Only the
581
+ // real-conflict classification is needed here, so the direction is moot.
582
+ const autoResolveOurs = ["work/"];
583
+ let preflight;
584
+ try {
585
+ preflight = await mergeBranch(featureWtPath, baseMainSha, {
586
+ autoResolve,
587
+ autoResolveOurs,
588
+ dryRun: true,
589
+ });
590
+ }
591
+ catch (err) {
592
+ this.logger?.integration(job.taskId, "warn", "pre-flight conflict check failed — falling through to the normal sync/validate flow", { reason: err instanceof Error ? err.message : String(err) });
593
+ return null;
594
+ }
595
+ if (preflight.merged || preflight.conflicts.length === 0)
596
+ return null;
597
+ return (`merge conflict in ${preflight.conflicts.join(", ")} — resolve it in ` +
598
+ `the feature branch's own worktree (merge main into the branch), then retry`);
599
+ }
600
+ async syncCandidate(job) {
601
+ this.onProgress?.("sync");
602
+ const root = this.config.root;
603
+ const branch = candidateBranchName(job.taskId);
604
+ // Resolve the repository's actual default branch
605
+ const mainBranch = await resolveDefaultBranch(root);
606
+ // Record main SHA at validation start.
607
+ const mainShaRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
608
+ if (mainShaRes.status !== 0) {
609
+ return { ok: false, reason: "could not get main SHA" };
610
+ }
611
+ const baseMainSha = mainShaRes.stdout.trim();
612
+ this.coordinator.updateJob(job.taskId, { baseMainSha });
613
+ // Pre-flight (#0358): a real, non-auto-resolvable conflict against current
614
+ // main is fully knowable from cheap git plumbing, so detect it BEFORE the
615
+ // candidate worktree is created and route straight to the conflict-repair
616
+ // handoff the validating phase already uses. This job then never pays for
617
+ // a candidate worktree (or a doomed merge) just to rediscover a conflict
618
+ // that was predictable in advance. Any failure of the check itself is
619
+ // fail-open — fall through to the normal sync/validate flow unchanged.
620
+ const preflightReason = await this.preflightConflict(job, baseMainSha);
621
+ if (preflightReason) {
622
+ return { ok: false, conflict: true, reason: preflightReason };
623
+ }
624
+ // Ensure candidate worktree exists and is on main.
625
+ const wtRes = ensureWorktree(root, branch, `candidate-${job.taskId}`);
626
+ if (!wtRes.ok) {
627
+ return { ok: false, reason: `could not create candidate worktree: ${wtRes.reason}` };
628
+ }
629
+ // A fresh candidate worktree has no dependencies, and the gate below runs a
630
+ // full `bun run build` + check. Reuse the main checkout's node_modules via
631
+ // a symlink instead of a slow cold install; fail-soft so a missing install
632
+ // surfaces as a build/check error rather than a misleading sync failure.
633
+ const candidateNodeModules = join(wtRes.path, "node_modules");
634
+ if (!existsSync(candidateNodeModules)) {
635
+ const rootNodeModules = join(root, "node_modules");
636
+ if (existsSync(rootNodeModules)) {
637
+ try {
638
+ symlinkSync(rootNodeModules, candidateNodeModules, "dir");
639
+ }
640
+ catch {
641
+ /* fail-soft: the build step will report the real error */
642
+ }
643
+ }
644
+ }
645
+ // Reset candidate to main so it's a clean base for the merge.
646
+ const resetRes = await runGit(wtRes.path, ["reset", "--hard", mainBranch], 30_000);
647
+ if (resetRes.status !== 0) {
648
+ return { ok: false, reason: `could not reset candidate to main: ${resetRes.stderr}` };
649
+ }
650
+ // Validate and record the feature branch SHA.
651
+ const taskBranch = job.branch ?? job.taskId;
652
+ // Check if the feature branch exists (critical: avoid merging unrelated history)
653
+ const taskWtPath = worktreePathForBranch(root, taskBranch);
654
+ if (!taskWtPath) {
655
+ return { ok: false, reason: `feature branch ${taskBranch} worktree not found` };
656
+ }
657
+ const branchShaRes = await runGit(taskWtPath, ["rev-parse", "HEAD"], 4000);
658
+ if (branchShaRes.status !== 0) {
659
+ return { ok: false, reason: "could not get feature branch SHA" };
660
+ }
661
+ this.coordinator.updateJob(job.taskId, { branchSha: branchShaRes.stdout.trim() });
662
+ return { ok: true, candidateSha: baseMainSha };
663
+ }
664
+ /**
665
+ * Merge the feature branch into the candidate, then run the gate on it.
666
+ *
667
+ * `retryable: false` marks a failure that cannot possibly resolve on a second
668
+ * identical run — a merge conflict is the same conflict every time — so the
669
+ * caller can skip its retry instead of spending another few minutes proving
670
+ * the point. Everything else defaults to retryable: build and check failures
671
+ * are where genuine flakiness lives.
672
+ */
673
+ async validateCandidate(job) {
674
+ const root = this.config.root;
675
+ const branch = candidateBranchName(job.taskId);
676
+ const wtPath = worktreePathForBranch(root, branch);
677
+ if (!wtPath) {
678
+ return { ok: false, reason: "candidate worktree not found" };
679
+ }
680
+ // Check for main SHA changes. If main advanced, discard candidate and rebuild.
681
+ const mainBranch = await resolveDefaultBranch(root);
682
+ const currentMainRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
683
+ if (currentMainRes.status !== 0) {
684
+ return { ok: false, reason: "could not get current main SHA" };
685
+ }
686
+ const currentMainSha = currentMainRes.stdout.trim();
687
+ if (job.baseMainSha && currentMainSha !== job.baseMainSha) {
688
+ // Main advanced: discard candidate, rebuild from new SHA, and revalidate.
689
+ removeWorktree(root, branch);
690
+ this.coordinator.updateJob(job.taskId, {
691
+ phase: "syncing",
692
+ baseMainSha: null,
693
+ candidateSha: null,
694
+ });
695
+ // The resync runs the same pre-flight; a conflict it finds must keep the
696
+ // non-retryable classification so the caller still routes it to repair.
697
+ const resync = await this.syncCandidate(job);
698
+ if (!resync.ok && resync.conflict) {
699
+ return { ok: false, retryable: false, reason: resync.reason };
700
+ }
701
+ return resync;
702
+ }
703
+ // Merge feature branch into candidate.
704
+ const featureBranch = job.branch ?? job.taskId;
705
+ // Verify feature branch still exists before attempting merge
706
+ const branchListRes = await runGit(root, ["branch", "--list", featureBranch], 4000);
707
+ if (branchListRes.status !== 0 || !branchListRes.stdout.trim()) {
708
+ return { ok: false, reason: `feature branch ${featureBranch} no longer exists` };
709
+ }
710
+ // In the candidate worktree, merge the feature branch from its location.
711
+ // dist/ and screenshots/ are generated output — the build step right
712
+ // after this merge (below) regenerates them from source regardless of
713
+ // what the merge produced, so a conflict there must never block the
714
+ // merge. The task's own doc file routinely differs between main and the
715
+ // branch (status/review_rounds bookkeeping on either side), so its
716
+ // branch version is taken as authoritative, same as the legacy done.ts
717
+ // close-out path. Reuses the existing, tested autoResolve semantics in
718
+ // core/git.ts rather than reimplementing conflict resolution here.
719
+ //
720
+ // dist/ is gitignored on main as of 2026-08-15 (see docs/dogfooding-vs-
721
+ // general.md), so most new merges won't touch this entry at all — a
722
+ // branch that never modified dist/ resolves as a clean deletion. It stays
723
+ // in the list because a branch cut BEFORE that change can still have
724
+ // dist/ tracked and modified; mergeBranch's `-X theirs` fallback already
725
+ // handles that as a modify/delete conflict. Safe to drop once no such
726
+ // branch remains, but harmless to leave indefinitely.
727
+ const task = this.getTask?.(job.taskId);
728
+ const autoResolve = ["dist/", "screenshots/", ...(task ? [relative(root, task.absPath)] : [])];
729
+ // The task currently closing is authoritative on its branch. Other task
730
+ // files can change independently on main (for example, a CTO nudge), so
731
+ // preserve main's version for those rather than blocking close-out.
732
+ const autoResolveOurs = ["work/"];
733
+ this.onProgress?.("merge");
734
+ const merge = await mergeBranch(wtPath, featureBranch, { autoResolve, autoResolveOurs });
735
+ if (!merge.merged) {
736
+ // A conflict is a property of the two trees, not of the machine. Retrying
737
+ // re-derives the identical conflict; the fix is always to merge main into
738
+ // the feature branch and resolve it there (see docs/close-out-pipeline.md).
739
+ return {
740
+ ok: false,
741
+ retryable: false,
742
+ reason: merge.conflicts.length
743
+ ? `merge conflict in ${merge.conflicts.join(", ")} — resolve it in the feature branch's own worktree (merge main into the branch), then retry`
744
+ : (merge.reason ?? "merge failed"),
745
+ };
746
+ }
747
+ // The merge "succeeded" but must have actually changed something, unless
748
+ // the branch was already fully in main. A no-op merge that swallowed real
749
+ // branch work would otherwise sail through the gate (it validates bare
750
+ // main) and publish the task as done with nothing integrated (#0306 et al).
751
+ const postMergeHead = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
752
+ if (postMergeHead.status === 0) {
753
+ const dropped = await detectDroppedMerge(root, mainBranch, featureBranch, postMergeHead.stdout.trim(), currentMainSha);
754
+ if (dropped) {
755
+ return { ok: false, retryable: false, reason: dropped };
756
+ }
757
+ }
758
+ // A clean merge still carries any edits the feature branch made to OTHER
759
+ // tasks' work/*.md files (branch changed them, main did not touch them since
760
+ // the merge-base → git merged them with no conflict, so `autoResolveOurs`,
761
+ // which only runs on conflicts, never fired). Restore main's copy of each.
762
+ // Observed live: #0319's close-out published #0202/#0275 frontmatter drift.
763
+ try {
764
+ const reset = await resetForeignWorkFiles({
765
+ candidateWtPath: wtPath,
766
+ baseMainSha: currentMainSha,
767
+ workDir: this.config.workDir,
768
+ ownWorkFile: task ? relative(root, task.absPath) : null,
769
+ });
770
+ if (reset.length > 0) {
771
+ this.logger?.integration(job.taskId, "info", "restored unrelated task files to main during close-out", { files: reset });
772
+ }
773
+ }
774
+ catch (err) {
775
+ this.logger?.integration(job.taskId, "warn", "could not restore unrelated task files during close-out", { reason: err instanceof Error ? err.message : String(err) });
776
+ }
777
+ // Check for merge conflict markers in text files (unresolved conflicts in content).
778
+ const lsRes = await runGit(wtPath, ["ls-files"], 5000);
779
+ if (lsRes.status === 0) {
780
+ const files = lsRes.stdout.split("\n").filter(Boolean);
781
+ const textExtensions = [".json", ".md", ".ts", ".tsx", ".js", ".jsx", ".css", ".html"];
782
+ for (const file of files) {
783
+ if (textExtensions.some((ext) => file.endsWith(ext))) {
784
+ try {
785
+ const content = readFileSync(join(wtPath, file), "utf8");
786
+ // Check for git conflict markers: exactly 7 of each character at line start
787
+ if (/^<{7}$|^={7}$|^>{7}$/m.test(content)) {
788
+ return { ok: false, reason: `unresolved conflict markers in ${file}` };
789
+ }
790
+ }
791
+ catch {
792
+ /* skip unreadable files */
793
+ }
794
+ }
795
+ }
796
+ }
797
+ // Docs-only fast path (#0355): a candidate whose merged diff touches
798
+ // nothing but documentation cannot be affected by build/typecheck/tests/
799
+ // ui-smoke, so the whole expensive post-merge gate is skipped. The
800
+ // predicate is literal — ANY path outside the allowlist (one line in
801
+ // src/, repoos.toml, package.json, scripts/, .github/, …) runs the full
802
+ // gate unchanged — and a git error (`null`) always fails safe to the full
803
+ // gate too.
804
+ const changedPaths = await getChangedFilePaths(wtPath, mainBranch);
805
+ const docsOnly = changedPaths !== null && isDocsOnlyChange(changedPaths);
806
+ if (docsOnly) {
807
+ this.logger?.integration(job.taskId, "info", "docs-only candidate — skipping the full build + check gate (no code/source paths changed)", { paths: changedPaths });
808
+ }
809
+ else {
810
+ // Run the post-merge gate: build + check via bun/npm.
811
+ this.onProgress?.("build");
812
+ let buildRes = await runProcess("bun", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
813
+ if (commandMissing(buildRes)) {
814
+ buildRes = await runProcess("npm", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
815
+ }
816
+ if (buildRes.status !== 0) {
817
+ return { ok: false, reason: `build failed: ${tailLine(buildRes.stdout, buildRes.stderr)}` };
818
+ }
819
+ // Remote Validation Runner (docs/remote-validation.md): hand the expensive
820
+ // half of the gate — `bun install` + `bun run build` + `bun run test` — to a
821
+ // disposable cloud VM, which is where MTD keeps failing under local memory
822
+ // pressure. On a remote pass, the LOCAL `repoos check` below runs only the
823
+ // cheap static guards + UI smoke (REPOOS_SKIP_TESTS=1). On a transient infra
824
+ // failure the job fails RETRYABLY (resumes from this phase) unless
825
+ // `remoteValidation.fallbackToLocal` is set; a real remote test failure is
826
+ // non-retryable — fix it in the feature branch and resubmit.
827
+ let skipTestsLocally = false;
828
+ if (this.remoteValidator && this.config.remoteValidation?.enabled) {
829
+ this.onProgress?.("check");
830
+ const headRes = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
831
+ if (headRes.status !== 0) {
832
+ return { ok: false, reason: "could not resolve candidate HEAD before remote validation" };
833
+ }
834
+ const remote = await this.remoteValidator.validate({
835
+ taskId: job.taskId,
836
+ worktreePath: wtPath,
837
+ candidateSha: headRes.stdout.trim(),
838
+ });
839
+ if (remote.ok) {
840
+ skipTestsLocally = true;
841
+ }
842
+ else if (remote.transient && !this.config.remoteValidation.fallbackToLocal) {
843
+ return {
844
+ ok: false,
845
+ retryable: true,
846
+ reason: `${remote.detail ?? "remote validation unavailable"} — the branch IS merged into the candidate; retrying resumes from the check step`,
847
+ };
848
+ }
849
+ else if (!remote.transient) {
850
+ return {
851
+ ok: false,
852
+ retryable: false,
853
+ reason: `remote validation failed: ${remote.detail ?? "build or test suite failed on the runner"} — fix it in the feature branch and resubmit`,
854
+ };
855
+ }
856
+ else {
857
+ this.logger?.integration(job.taskId, "warn", "remote validation unavailable — falling back to the full local gate (remoteValidation.fallbackToLocal)", { detail: remote.detail });
858
+ }
859
+ }
860
+ this.onProgress?.("check");
861
+ // The candidate's OWN freshly-built CLI comes first, same as the legacy
862
+ // done.ts close-out gate (#0130): a globally linked `repoos` resolves
863
+ // build freshness and gate code against its own install snapshot, which
864
+ // can disagree with the checkout actually being validated here. Running
865
+ // `check` via the candidate's own `dist/cli/index.js` guarantees the gate
866
+ // evaluates the exact code that was just merged and built above.
867
+ // The build above already ran `bun run build` with nothing changed since,
868
+ // and `bun run build` is staleness-aware now (#0377), so `check`'s own
869
+ // "Full build" step detects the fresh marker and skips itself — no
870
+ // private skip env flag needed.
871
+ const checkEnv = {
872
+ ...process.env,
873
+ ...(skipTestsLocally ? { REPOOS_SKIP_TESTS: "1" } : {}),
874
+ };
875
+ const localCli = join(wtPath, "dist", "cli", "index.js");
876
+ const localCliPresent = existsSync(localCli);
877
+ const checkHandle = this.taskChecks && this.onTaskCheckEvent
878
+ ? this.taskChecks.start(job.taskId, "merge-gate", this.onTaskCheckEvent)
879
+ : undefined;
880
+ const rawCheck = (cli, args) => runProcess(cli, args, {
881
+ cwd: wtPath,
882
+ timeout: 600_000,
883
+ env: checkEnv,
884
+ onChunk: checkHandle?.chunk,
885
+ });
886
+ // A check whose ONLY failure is a stale build marker: the same marker the
887
+ // close-out build above should have refreshed. This is the self-resolving
888
+ // staleness pattern (#0276 Flavour B) — refreshing the marker and re-running
889
+ // the identical check on the same tree passes. Cases that are NOT this (a
890
+ // genuine non-staleness failure, or the local CLI being absent entirely) must
891
+ // not be absorbed; see the branching below.
892
+ const isStalenessFailure = (res) => /stale build|no build found|build-info\.json|build is stale|cannot verify build freshness/i.test(`${res.stdout}\n${res.stderr}`);
893
+ let checkRes;
894
+ // Why the check result deviates from a plain local-CLI pass:
895
+ // 'local-ok' — candidate's own CLI passed (common case)
896
+ // 'absorbed' — local CLI reported staleness; marker refreshed and
897
+ // re-check passed on the same tree (self-resolving)
898
+ // 'fallback' — local CLI failed for a genuine non-staleness reason;
899
+ // fell through to the global CLI fallback
900
+ // 'local-missing'— candidate's own CLI was absent AND this project is
901
+ // meant to build one — only the global CLI fallback
902
+ // could run (Flavour A, not self-resolving)
903
+ // 'no-cli-expected' — candidate's own CLI was absent, but this project
904
+ // never builds a dist/cli/index.js at all (the common
905
+ // case for a managed web/backend project) — the
906
+ // fallback is expected, not a regression signal
907
+ let outcome;
908
+ if (!localCliPresent) {
909
+ // Only a project that is itself meant to build dist/cli/index.js
910
+ // (RepoOS self-hosting, or another project with the same bin target)
911
+ // can suffer the #0213/3fbbd707 CLI-selection regression, where the
912
+ // global CLI fallback compares this candidate's src hash against a
913
+ // DIFFERENT install's marker — a guaranteed "stale" mismatch regardless
914
+ // of how fresh the candidate really is. For every other managed
915
+ // project (the common case) there was never a local CLI to find, so a
916
+ // missing one is expected, not a regression; labelling it as one on
917
+ // every such MTD (#0345) buried the real failure reason behind a false
918
+ // lead.
919
+ const cliExpected = expectsOwnCli(wtPath);
920
+ checkRes = await rawCheck("repoos", ["check"]);
921
+ outcome = cliExpected ? "local-missing" : "no-cli-expected";
922
+ if (cliExpected) {
923
+ this.logger?.integration(job.taskId, "error", "candidate dist/cli/index.js is missing — gate fell back to the globally linked repoos; any 'stale' result here is a CLI-selection regression (#0276 Flavour A), not self-resolving staleness");
924
+ }
925
+ }
926
+ else {
927
+ checkRes = await rawCheck(process.execPath, [localCli, "check"]);
928
+ if (checkRes.status === 0) {
929
+ outcome = "local-ok";
930
+ }
931
+ else if (isStalenessFailure(checkRes)) {
932
+ // Self-resolving build staleness: only the stale-marker report failed,
933
+ // and that same marker is what `bun run build` below refreshes. Refresh
934
+ // it provably for the current source — `bun run build` is
935
+ // staleness-aware (#0377) and rebuilds because the marker is stale —
936
+ // then re-run the SAME check on the same candidate tree. Bounded to
937
+ // this one re-check — it never loops, and it stays inside
938
+ // validateCandidate rather than triggering an extra orchestrator-level
939
+ // retry / re-sync / debugger.
940
+ this.logger?.integration(job.taskId, "info", "check reported self-resolving build staleness — refreshing marker and re-checking the same tree in place (no debugger detour)");
941
+ await runProcess("bun", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
942
+ checkRes = await rawCheck(process.execPath, [localCli, "check"]);
943
+ if (checkRes.status !== 0) {
944
+ checkHandle?.done(checkRes.status);
945
+ return {
946
+ ok: false,
947
+ reason: `check failed after in-place staleness re-check: ${tailLine(checkRes.stdout, checkRes.stderr)}`,
948
+ };
949
+ }
950
+ outcome = "absorbed";
951
+ }
952
+ else {
953
+ // Genuine non-staleness failure from the local CLI: preserve the prior
954
+ // fallback behaviour (retry via the global repoos, then bun run repoos).
955
+ outcome = "fallback";
956
+ checkRes = await rawCheck("repoos", ["check"]);
957
+ if (checkRes.status !== 0) {
958
+ checkRes = await rawCheck("bun", ["run", "repoos", "check"]);
959
+ }
960
+ }
961
+ }
962
+ checkHandle?.done(checkRes.status);
963
+ if (checkRes.status !== 0) {
964
+ return {
965
+ ok: false,
966
+ reason: outcome === "local-missing"
967
+ ? `check failed: the candidate's own dist/cli/index.js was not used (CLI-selection regression — the globally linked repoos evaluates a different install's build marker). ${tailLine(checkRes.stdout, checkRes.stderr)}`
968
+ : `check failed: ${tailLine(checkRes.stdout, checkRes.stderr)}`,
969
+ };
970
+ }
971
+ }
972
+ // Candidate is green. Capture its SHA.
973
+ const candidateShaRes = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
974
+ if (candidateShaRes.status !== 0) {
975
+ return { ok: false, reason: "could not get candidate SHA after validation" };
976
+ }
977
+ // Capture the reviewable source diff before publication removes the task
978
+ // branch/worktree. The candidate is based on the exact main SHA checked
979
+ // above and contains the validated feature merge, so this records the
980
+ // precise change that is about to land (not a later, polluted main diff).
981
+ const snapshotDiff = await getDiff(wtPath, mainBranch);
982
+ const snapshotStats = getDiffStats(wtPath, mainBranch);
983
+ saveDiffSnapshot(root, this.config.cacheDir, job.taskId, snapshotStats, snapshotDiff);
984
+ return { ok: true, candidateSha: candidateShaRes.stdout.trim() };
985
+ }
986
+ async publishCandidate(job) {
987
+ const root = this.config.root;
988
+ const mainBranch = await resolveDefaultBranch(root);
989
+ const branch = candidateBranchName(job.taskId);
990
+ const wtPath = worktreePathForBranch(root, branch);
991
+ if (!wtPath) {
992
+ return { ok: false, reason: "candidate worktree missing at publish time" };
993
+ }
994
+ // Acquire the repository lock before publishing.
995
+ if (this.repoLock) {
996
+ let acquireAttempts = 0;
997
+ while (!this.repoLock.acquire(job.taskId) && acquireAttempts < 60) {
998
+ await new Promise((resolve) => setTimeout(resolve, 100));
999
+ acquireAttempts++;
1000
+ }
1001
+ if (acquireAttempts >= 60) {
1002
+ return { ok: false, reason: "could not acquire publication lock (timeout)" };
1003
+ }
1004
+ }
1005
+ // Publishing mutates the live checkout. It must therefore mutually
1006
+ // exclude a hotfix, which also owns that checkout for its entire run.
1007
+ // Acquire after the publication lock so concurrent close-outs retain
1008
+ // their existing serialization, but before inspecting or merging main.
1009
+ if (this.rootLock && !this.rootLock.acquire(job.taskId, "close-out")) {
1010
+ const holder = this.rootLock.getHolder();
1011
+ this.repoLock?.release(job.taskId);
1012
+ return {
1013
+ ok: false,
1014
+ reason: `main checkout is held by ${holder?.kind ?? "another operation"} (task #${holder?.taskId ?? "unknown"}); wait for it to finish before moving this task to done`,
1015
+ };
1016
+ }
1017
+ try {
1018
+ // Final SHA check: ensure candidate is still based on current main (holding the lock).
1019
+ const currentMainRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
1020
+ if (currentMainRes.status !== 0) {
1021
+ return { ok: false, reason: "could not verify main before publish" };
1022
+ }
1023
+ const currentMainSha = currentMainRes.stdout.trim();
1024
+ if (job.baseMainSha !== currentMainSha) {
1025
+ // Main advanced between validation and publishing. Every OTHER retry
1026
+ // path in this close-out pipeline is capped (#0216's validate-phase
1027
+ // 2-attempt cap; handoff.ts's MAX_*_RETRY_ATTEMPTS for merge-conflict/
1028
+ // check-failure/handoff-signal repairs) — this one wasn't, so a task
1029
+ // whose sync→validate→publish cycle takes longer than the interval
1030
+ // between OTHER tasks landing on main could retry forever, never
1031
+ // winning the race. Reproduced live twice in one session (#0376,
1032
+ // #0382): 3-4 consecutive resets, ~2 minutes each, on a busy board.
1033
+ const driftCount = (job.publishDriftCount ?? 0) + 1;
1034
+ if (driftCount > MAX_PUBLISH_DRIFT_RETRIES) {
1035
+ // Deliberately do NOT set phase: "syncing" here — leaving it
1036
+ // unchanged (still "publishing") means processJob's own existing
1037
+ // `currentJob.phase === "syncing"` check below is false, so it
1038
+ // falls through to failOrReconcile exactly the way a genuine
1039
+ // publish failure already does. No new give-up path needed; this
1040
+ // reuses the one that's already there.
1041
+ return {
1042
+ ok: false,
1043
+ reason: `main advanced ${driftCount} times in a row while trying to publish — giving up ` +
1044
+ "rather than retrying forever. The branch itself is fine, it's just losing the race " +
1045
+ "to land; retry Move-to-done once main quiets down.",
1046
+ };
1047
+ }
1048
+ removeWorktree(root, branch);
1049
+ this.coordinator.updateJob(job.taskId, {
1050
+ phase: "syncing",
1051
+ baseMainSha: null,
1052
+ candidateSha: null,
1053
+ publishDriftCount: driftCount,
1054
+ });
1055
+ return { ok: false, reason: "main advanced, revalidating" };
1056
+ }
1057
+ // Ensure the main checkout is on the actual main branch before merging.
1058
+ // A branch-mode hotfix leaves the main checkout on its hotfix branch
1059
+ // (ensureHotfix checks it out there). Merging the candidate into the
1060
+ // hotfix branch instead of main silently succeeds (FF or no-op) but
1061
+ // leaves main unchanged and blocks hotfix-branch cleanup. Switch to
1062
+ // main first so the merge, dirty check, and subsequent branch deletion
1063
+ // all target the correct branch.
1064
+ const currentHead = currentBranch(root);
1065
+ if (currentHead && currentHead !== mainBranch) {
1066
+ const checkoutRes = await runGit(root, ["checkout", mainBranch], 10_000);
1067
+ if (checkoutRes.status !== 0) {
1068
+ return {
1069
+ ok: false,
1070
+ reason: `could not switch main checkout from ${currentHead} to ${mainBranch} before publishing (${checkoutRes.stderr.trim()}). The candidate was NOT merged; retry.`,
1071
+ };
1072
+ }
1073
+ }
1074
+ // Publish-time dirty-main guard (#0211): the main working tree can be
1075
+ // dirtied between enqueue and publish (validation runs minutes-long
1076
+ // builds in the candidate worktree while `repoos check` regenerates a
1077
+ // dirty `dist/` on main). Re-check right before the merge so a dirty
1078
+ // main is surfaced as an actionable message instead of git's raw
1079
+ // "your local changes would be overwritten" at the tail of a long job.
1080
+ // Fails closed: an error/timeout in the check is "unknown", never
1081
+ // "clean", so we never merge blindly.
1082
+ let dirtyOnMain;
1083
+ try {
1084
+ dirtyOnMain = await dirtyFiles(root);
1085
+ }
1086
+ catch (err) {
1087
+ if (err instanceof GitDirtyCheckError) {
1088
+ return {
1089
+ ok: false,
1090
+ reason: `could not verify main is clean at publish time (${err.message}). The candidate was NOT merged; retry, or commit/stash main's working tree first.`,
1091
+ };
1092
+ }
1093
+ throw err;
1094
+ }
1095
+ if (dirtyOnMain.length > 0) {
1096
+ // Auto-checkpoint routine, server-written churn instead of blocking
1097
+ // the merge on it (#0271 follow-up, confirmed live: #0293's close-out
1098
+ // was refused because an UNRELATED task's `work/*.md` file — an
1099
+ // activity-log stamp from a normal status/override change — was
1100
+ // dirty on main at publish time). Every routine task write already
1101
+ // auto-commits this exact way via `commitTaskFile`/`recordChange`
1102
+ // elsewhere in the system; a task file being dirty for a moment
1103
+ // between that write and this check is expected traffic on a busy
1104
+ // board, not a signal something risky is in flight.
1105
+ //
1106
+ // `repoos.toml` gets the same treatment: it's always written whole by
1107
+ // the settings API (agent config, maxActiveTasks, etc.), never
1108
+ // hand-edited mid-change the way a source file might be, so a dirty
1109
+ // moment there is the same class of routine churn, not a signal of
1110
+ // in-progress human work.
1111
+ //
1112
+ // Scoped narrowly: only when EVERY dirty path is under the work dir
1113
+ // or is exactly `repoos.toml` — anything else (source, other config,
1114
+ // a stray build artifact) still fails closed exactly as before,
1115
+ // since that's genuinely ambiguous and worth a human's attention
1116
+ // rather than a blind auto-commit.
1117
+ const workPrefix = `${this.config.workDir}/`;
1118
+ const isSafeChurn = (p) => p.startsWith(workPrefix) || p === "repoos.toml";
1119
+ const onlySafeChurn = dirtyOnMain.every(isSafeChurn);
1120
+ if (onlySafeChurn) {
1121
+ try {
1122
+ await commitDirtyFiles(root, "chore: checkpoint bookkeeping/config before publish");
1123
+ }
1124
+ catch {
1125
+ return {
1126
+ ok: false,
1127
+ reason: `main has ${dirtyOnMain.length} uncommitted file${dirtyOnMain.length === 1 ? "" : "s"} at publish time and the automatic checkpoint commit failed: ${dirtyOnMain.slice(0, 8).join(", ")}${dirtyOnMain.length > 8 ? ", …" : ""}. The candidate was NOT merged; commit or stash those on main and retry.`,
1128
+ };
1129
+ }
1130
+ }
1131
+ else {
1132
+ return {
1133
+ ok: false,
1134
+ reason: `main has ${dirtyOnMain.length} uncommitted file${dirtyOnMain.length === 1 ? "" : "s"} at publish time, so the merge would abort: ${dirtyOnMain.slice(0, 8).join(", ")}${dirtyOnMain.length > 8 ? ", …" : ""}. The candidate was NOT merged; commit or stash those on main (or use "Commit & continue") and retry.`,
1135
+ };
1136
+ }
1137
+ }
1138
+ // Merge candidate to live main. Reuses mergeBranch's tested
1139
+ // autoResolve semantics (already used above, in the opposite
1140
+ // direction, to merge main into the candidate worktree) instead of a
1141
+ // hand-rolled ff-only/no-edit pair with no conflict recovery — that
1142
+ // older version left a genuine conflict as a dangling, uncommitted
1143
+ // `git merge` on the LIVE main checkout (MERGE_HEAD set, conflict
1144
+ // markers on disk) rather than aborting it, so every later retry's
1145
+ // dirty-main check just re-discovered the same stuck merge and
1146
+ // reported it as generic "uncommitted changes" (task #0338's
1147
+ // close-out hit exactly this on work/0338-*.md — main had picked up
1148
+ // its own bookkeeping commits for that same task file while the
1149
+ // candidate was in flight, a routine, expected divergence that should
1150
+ // never have blocked a merge in the first place).
1151
+ // dist/screenshots/ are generated output (never worth keeping main's
1152
+ // stale copy over the candidate's); every work/*.md file keeps MAIN's
1153
+ // copy — main is authoritative for task bookkeeping by publish time
1154
+ // (routine writes land there throughout the task's lifetime), the
1155
+ // reverse of the validate-phase merge above where the candidate's own
1156
+ // file is what's being tested in isolation.
1157
+ const publishMerge = await mergeBranch(root, branch, {
1158
+ autoResolve: ["dist/", "screenshots/"],
1159
+ autoResolveOurs: ["work/"],
1160
+ });
1161
+ if (!publishMerge.merged) {
1162
+ if (publishMerge.conflicts.length > 0) {
1163
+ return {
1164
+ ok: false,
1165
+ reason: `main has a genuine merge conflict with the candidate (not auto-resolvable): ${publishMerge.conflicts.slice(0, 8).join(", ")}${publishMerge.conflicts.length > 8 ? ", …" : ""}. The candidate was NOT merged and the attempt was cleanly aborted; resolve this on the branch (merge main into it) and retry.`,
1166
+ };
1167
+ }
1168
+ // A dirty main that slipped in between the check and the merge
1169
+ // surfaces as git's raw "would be overwritten" failure with no
1170
+ // conflict list — re-frame as an actionable instruction.
1171
+ if (/would be overwritten by merge/i.test(publishMerge.reason ?? "")) {
1172
+ return {
1173
+ ok: false,
1174
+ reason: `main has uncommitted files blocking the merge. The candidate was NOT merged; commit or stash those on main (or use "Commit & continue") and retry.`,
1175
+ };
1176
+ }
1177
+ return {
1178
+ ok: false,
1179
+ reason: `could not merge to main: ${publishMerge.reason ?? "unknown error"}`,
1180
+ };
1181
+ }
1182
+ // Guard against the same failure class detectDroppedMerge exists for
1183
+ // (#0306/#0307/#0309/#0312, see its docstring above): the autoResolve
1184
+ // this merge just used is new here (the old ff-only/no-edit pair had
1185
+ // none), so confirm it didn't silently discard real candidate content
1186
+ // the way conflict auto-resolution has before. Cheap and specific:
1187
+ // only fires when the merge produced no change at all AND the branch
1188
+ // still genuinely differs from main.
1189
+ const postMergeHead = await runGit(root, ["rev-parse", "HEAD"], 4000);
1190
+ if (postMergeHead.status === 0) {
1191
+ const dropped = await detectDroppedMerge(root, mainBranch, branch, postMergeHead.stdout.trim(), currentMainSha);
1192
+ if (dropped) {
1193
+ return { ok: false, reason: dropped };
1194
+ }
1195
+ }
1196
+ // Rebuild the live checkout's dist/ so the running `repoos serve`
1197
+ // process (which loads compiled JS at boot and never reloads on its
1198
+ // own) has something newer to notice. dist/ is gitignored, so the
1199
+ // merge above brought in new source but left dist/ exactly as it was.
1200
+ // This runs while rootLock/repoLock are still held (released in the
1201
+ // `finally` below), so ReloadManager's closingOut() check correctly
1202
+ // parks the resulting build for a user-triggered reload instead of
1203
+ // restarting the server out from under this still-running publish.
1204
+ // Fail-soft: the merge already succeeded, so a failed rebuild here
1205
+ // must not fail the whole publish — it just leaves dist/ stale, the
1206
+ // same pre-existing failure mode this is fixing, now at least logged.
1207
+ //
1208
+ // Skip entirely when there's no package.json: neither `bun run build`
1209
+ // nor the `npm run build` fallback can ever succeed without one, so
1210
+ // attempting them is two guaranteed-useless subprocess spawns every
1211
+ // time — real cost under load, and observed contributing to orphaned
1212
+ // build processes outliving a fixture test's own vitest timeout
1213
+ // (testTimeout: 15_000 vs. up to 300s per attempt here). Root without
1214
+ // a package.json only happens in this orchestrator's own unit-test
1215
+ // fixtures (bare git repos with no app to build) — a real repo always
1216
+ // has one, so this never skips a build that could have mattered.
1217
+ const canBuild = existsSync(join(root, "package.json"));
1218
+ let rebuildRes;
1219
+ if (canBuild) {
1220
+ rebuildRes = await runProcess("bun", ["run", "build"], { cwd: root, timeout: 300_000 });
1221
+ if (commandMissing(rebuildRes)) {
1222
+ rebuildRes = await runProcess("npm", ["run", "build"], { cwd: root, timeout: 300_000 });
1223
+ }
1224
+ }
1225
+ if (!canBuild) {
1226
+ console.error(`Post-merge rebuild of ${root} skipped for task ${job.taskId}: no package.json`);
1227
+ }
1228
+ else if (rebuildRes && rebuildRes.status !== 0) {
1229
+ console.error(`Post-merge rebuild of ${root} failed for task ${job.taskId}: ${tailLine(rebuildRes.stdout, rebuildRes.stderr)}`);
1230
+ }
1231
+ this.onProgress?.("done");
1232
+ return { ok: true };
1233
+ }
1234
+ finally {
1235
+ this.rootLock?.release(job.taskId);
1236
+ // Always release the lock when done publishing.
1237
+ if (this.repoLock) {
1238
+ this.repoLock.release(job.taskId);
1239
+ }
1240
+ }
1241
+ }
1242
+ async cleanup(job) {
1243
+ const root = this.config.root;
1244
+ const featureBranch = job.branch ?? job.taskId;
1245
+ // Candidate worktree + throwaway branch.
1246
+ this.removeCandidate(job.taskId);
1247
+ // Task's feature worktree + branch. The branch was just merged into main, so
1248
+ // `-d` is correct; a false return means the worktree is still registered
1249
+ // (leak) — log the path so a human/gc can see which one.
1250
+ if (!removeWorktree(root, featureBranch)) {
1251
+ const stuck = worktreePathForBranch(root, featureBranch);
1252
+ this.logger?.integration(job.taskId, "warn", "feature worktree not removed at close-out", {
1253
+ branch: featureBranch,
1254
+ path: stuck ?? "unknown",
1255
+ });
1256
+ console.warn(`Close-out for ${job.taskId}: feature worktree for ${featureBranch} still registered (${stuck ?? "unknown"})`);
1257
+ }
1258
+ deleteBranch(root, featureBranch);
1259
+ pruneWorktrees(root);
1260
+ // Mark the task as done in the main checkout.
1261
+ //
1262
+ // The live index can miss a lookup right after a reload (its in-memory
1263
+ // rebuild is not instant, and this cleanup step can land in that window —
1264
+ // observed live, 2026-08-15, #0195: publish succeeded, main fast-forwarded
1265
+ // to the candidate, but this step silently no-op'd — `task` was undefined,
1266
+ // the `if (task)` guard skipped markTaskReleased with no error, and the
1267
+ // task sat published-but-not-released until a manual retry). Falling back
1268
+ // to a direct on-disk lookup by id makes this step independent of index
1269
+ // freshness — the file is exactly what markTaskReleased writes to anyway.
1270
+ try {
1271
+ const absPath = this.getTask?.(job.taskId)?.absPath ??
1272
+ findTaskFileById(root, this.config.workDir, job.taskId);
1273
+ if (absPath) {
1274
+ markTaskReleased(this.config, absPath);
1275
+ }
1276
+ else {
1277
+ console.error(`Could not locate task ${job.taskId} on disk to mark it released — publish succeeded but release marking was skipped`);
1278
+ }
1279
+ }
1280
+ catch (err) {
1281
+ console.error(`Failed to mark task ${job.taskId} as done:`, err);
1282
+ // Don't fail cleanup - the important part (merge) already succeeded
1283
+ }
1284
+ // Opportunistic GC: every close-out is a natural moment to reclaim
1285
+ // `repoos/integrate/*` candidates that failed jobs left behind (this
1286
+ // task's own were just removed above). Conservative — never touches
1287
+ // `feat/*`. Also logs a "run `repoos gc`" warning if the worktree count is
1288
+ // over the advisory ceiling. Fail-soft and not awaited-critical.
1289
+ sweepAndWarn(this.config, {
1290
+ activeJobIds: new Set(this.coordinator
1291
+ .allJobs()
1292
+ .filter((j) => j.phase !== "done" && j.phase !== "failed")
1293
+ .map((j) => j.taskId)),
1294
+ threshold: this.config.worktreeWarnThreshold,
1295
+ log: (level, msg, meta) => this.logger?.integration(job.taskId, level, msg, meta),
1296
+ });
1297
+ return { ok: true };
1298
+ }
1299
+ }