@loicngr/kobo 1.7.1 → 1.7.3

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 (196) hide show
  1. package/dist/mcp-server/kobo-tasks-handlers.js +8 -1
  2. package/dist/server/routes/health.js +12 -6
  3. package/dist/server/routes/settings.js +16 -0
  4. package/dist/server/routes/workspaces.js +206 -3
  5. package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
  6. package/dist/server/services/agent/engines/claude-code/resolve-binary.js +50 -0
  7. package/dist/server/services/agent/orchestrator.js +17 -5
  8. package/dist/server/services/auto-loop-service.js +7 -1
  9. package/dist/server/services/dev-server-service.js +2 -2
  10. package/dist/server/services/initial-prompt-template-service.js +48 -0
  11. package/dist/server/services/pr-watcher-service.js +63 -13
  12. package/dist/server/services/review-template-service.js +58 -0
  13. package/dist/server/services/settings-service.js +94 -2
  14. package/dist/server/services/wakeup-service.js +9 -1
  15. package/dist/server/services/workspace-service.js +21 -0
  16. package/dist/server/services/worktree-service.js +2 -2
  17. package/dist/server/utils/git-ops.js +94 -4
  18. package/dist/server/utils/project-slug.js +52 -0
  19. package/dist/server/utils/worktree-paths.js +12 -10
  20. package/package.json +1 -1
  21. package/src/client/dist/spa/assets/ActivityFeed-CKSqMR2v.js +7 -0
  22. package/src/client/dist/spa/assets/{ActivityFeed-LXnbg3ff.css → ActivityFeed-CroojlsI.css} +1 -1
  23. package/src/client/dist/spa/assets/ClosePopup-D_UAdwkA.js +1 -0
  24. package/src/client/dist/spa/assets/CreatePage-7cP4h19f.js +2 -0
  25. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +1 -0
  26. package/src/client/dist/spa/assets/DiffViewer-CdamEwIg.js +7 -0
  27. package/src/client/dist/spa/assets/{DiffViewer-D1Sdu307.css → DiffViewer-wFfQ9tcY.css} +1 -1
  28. package/src/client/dist/spa/assets/{HealthPage-CKyf7ky6.js → HealthPage-m4z-x5bo.js} +1 -1
  29. package/src/client/dist/spa/assets/MainLayout-CQBqYFNx.js +37 -0
  30. package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +1 -0
  31. package/src/client/dist/spa/assets/{QBadge-fsQ2AokU.js → QBadge-DWH42dbo.js} +1 -1
  32. package/src/client/dist/spa/assets/{QBtn-DHwAb18J.js → QBtn-a6jxWjmW.js} +1 -1
  33. package/src/client/dist/spa/assets/{QCheckbox-CcY7ZSk9.js → QCheckbox-D5jfsxLV.js} +1 -1
  34. package/src/client/dist/spa/assets/{QChip-BhT0W2Dg.js → QChip-ByxK0Tuf.js} +1 -1
  35. package/src/client/dist/spa/assets/QExpansionItem-CH1ipL9n.js +1 -0
  36. package/src/client/dist/spa/assets/{QIcon-B0-pH3Qs.js → QIcon-BJuyqdsT.js} +1 -1
  37. package/src/client/dist/spa/assets/QInput-Cm5-AGQ4.js +1 -0
  38. package/src/client/dist/spa/assets/{QItemLabel-DWwenW2S.js → QItemLabel-DrTxqTqV.js} +1 -1
  39. package/src/client/dist/spa/assets/{QItemSection-KFAnxzMK.js → QItemSection-5YpFpPDm.js} +1 -1
  40. package/src/client/dist/spa/assets/{QList-NmIE6Rd9.js → QList-D0FtnQJI.js} +1 -1
  41. package/src/client/dist/spa/assets/QMenu-B4xMxMGd.js +1 -0
  42. package/src/client/dist/spa/assets/QPage-ChUKoaKe.js +1 -0
  43. package/src/client/dist/spa/assets/{QRadio-DaZhdLCg.js → QRadio-B3aKjCVu.js} +1 -1
  44. package/src/client/dist/spa/assets/{QSpace-COlmM_4F.js → QSpace-CLtL3aPy.js} +1 -1
  45. package/src/client/dist/spa/assets/{QSpinnerDots-DwtnRN2r.js → QSpinnerDots-CszPQQ9J.js} +1 -1
  46. package/src/client/dist/spa/assets/QTabPanels-D2ks0UIA.js +1 -0
  47. package/src/client/dist/spa/assets/{QToggle-CGpiJLDJ.js → QToggle-1-N9qWq4.js} +1 -1
  48. package/src/client/dist/spa/assets/QTooltip-fDNzBEfN.js +1 -0
  49. package/src/client/dist/spa/assets/{SearchPage-Ce8Uc7Ol.js → SearchPage-DCRSQycR.js} +1 -1
  50. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +1 -0
  51. package/src/client/dist/spa/assets/SettingsPage-DStBGwIj.js +1 -0
  52. package/src/client/dist/spa/assets/TouchPan-DoE24Io3.js +1 -0
  53. package/src/client/dist/spa/assets/WorkspacePage-BstBxgN8.js +4 -0
  54. package/src/client/dist/spa/assets/{WorkspacePage-DQxGe62K.css → WorkspacePage-eymEd4kx.css} +1 -1
  55. package/src/client/dist/spa/assets/{build-path-tree-DRViYT3t.js → build-path-tree-B1Lvvqto.js} +1 -1
  56. package/src/client/dist/spa/assets/{cssMode-uAfRqG2Q.js → cssMode-o7NS-Oil.js} +1 -1
  57. package/src/client/dist/spa/assets/{documents-D6A3wRry.js → documents-kx0vLfSG.js} +1 -1
  58. package/src/client/dist/spa/assets/{editor.api-5GUlxvcL.js → editor.api-CNo9KwlJ.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.main-CSTJjBIa.js → editor.main-UyvgnhP6.js} +3 -3
  60. package/src/client/dist/spa/assets/expand-template-DqZgks9E.js +1 -0
  61. package/src/client/dist/spa/assets/{formatters-ejxELb0M.js → formatters-BD0_hovB.js} +1 -1
  62. package/src/client/dist/spa/assets/{freemarker2-BxBnI8Nb.js → freemarker2-BKWtNRQ9.js} +1 -1
  63. package/src/client/dist/spa/assets/{handlebars-DrbIsXmT.js → handlebars-BUhKrn3k.js} +1 -1
  64. package/src/client/dist/spa/assets/{html-DH7u_g5l.js → html-CrcvRgdj.js} +1 -1
  65. package/src/client/dist/spa/assets/{htmlMode-BlY9QO3f.js → htmlMode-Djjp-0pZ.js} +1 -1
  66. package/src/client/dist/spa/assets/i18n-DD341qPX.js +1 -0
  67. package/src/client/dist/spa/assets/index-DR1y9t94.js +2 -0
  68. package/src/client/dist/spa/assets/{javascript-B-AL31ke.js → javascript-DN_zCJwt.js} +1 -1
  69. package/src/client/dist/spa/assets/{jsonMode-Dx7CA4ag.js → jsonMode-B7uIpwZ9.js} +1 -1
  70. package/src/client/dist/spa/assets/{liquid--H7Vomnm.js → liquid-f3BGSOBM.js} +1 -1
  71. package/src/client/dist/spa/assets/{mdx-BOackeU6.js → mdx-jpEqsFXp.js} +1 -1
  72. package/src/client/dist/spa/assets/models-Bj-hfPO2.js +1 -0
  73. package/src/client/dist/spa/assets/{monaco.contribution-ydrMjZwK.js → monaco.contribution-D-UK6jlz.js} +2 -2
  74. package/src/client/dist/spa/assets/notifications-OnPq4FrH.js +1 -0
  75. package/src/client/dist/spa/assets/purify.es-DyEEb_DH.js +60 -0
  76. package/src/client/dist/spa/assets/{python-BWGSV-nk.js → python-CoiTKs0q.js} +1 -1
  77. package/src/client/dist/spa/assets/{razor-BGnl83cS.js → razor-BubwMw_m.js} +1 -1
  78. package/src/client/dist/spa/assets/render-chat-markdown-DwKtHD8J.js +1 -0
  79. package/src/client/dist/spa/assets/{tsMode-Chjqq1f3.js → tsMode-k_tAkDr_.js} +1 -1
  80. package/src/client/dist/spa/assets/{typescript-By7Y7PAP.js → typescript-DQQR6Y6R.js} +1 -1
  81. package/src/client/dist/spa/assets/use-checkbox-D7zmRxGI.js +1 -0
  82. package/src/client/dist/spa/assets/{use-id-CDuXkR0Z.js → use-id-CuaR1RiE.js} +1 -1
  83. package/src/client/dist/spa/assets/use-panel-D-8nAQns.js +1 -0
  84. package/src/client/dist/spa/assets/{xml-DoAeCRiy.js → xml-CaSyI8p6.js} +1 -1
  85. package/src/client/dist/spa/assets/{yaml-DlT7YOhG.js → yaml-BYsGcXIZ.js} +1 -1
  86. package/src/client/dist/spa/index.html +11 -13
  87. package/src/client/dist/spa/sounds/ca_va_peter.mp3 +0 -0
  88. package/src/client/dist/spa/sounds/dry-fart.mp3 +0 -0
  89. package/src/client/dist/spa/sounds/faaah.mp3 +0 -0
  90. package/src/client/dist/spa/sounds/for-shure.mp3 +0 -0
  91. package/src/client/dist/spa/sounds/travail_termine.mp3 +0 -0
  92. package/src/mcp-server/kobo-tasks-handlers.ts +10 -1
  93. package/src/client/dist/spa/assets/ActivityFeed-Chn8aZvi.js +0 -7
  94. package/src/client/dist/spa/assets/ClosePopup-BUlGXTqh.js +0 -1
  95. package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +0 -1
  96. package/src/client/dist/spa/assets/CreatePage-BGtqoZ8d.js +0 -2
  97. package/src/client/dist/spa/assets/DiffViewer-qjJ-biOw.js +0 -7
  98. package/src/client/dist/spa/assets/MainLayout-B07zv82Z.css +0 -1
  99. package/src/client/dist/spa/assets/MainLayout-Br3jmaOw.js +0 -37
  100. package/src/client/dist/spa/assets/QExpansionItem-BnIPCzXR.js +0 -1
  101. package/src/client/dist/spa/assets/QInput-D4WJro4e.js +0 -1
  102. package/src/client/dist/spa/assets/QMenu-0LsqhRZT.js +0 -1
  103. package/src/client/dist/spa/assets/QPage-Cu7zkfc6.js +0 -1
  104. package/src/client/dist/spa/assets/QResizeObserver-Cf79V-VZ.js +0 -1
  105. package/src/client/dist/spa/assets/QScrollArea-BDCKOKuE.js +0 -1
  106. package/src/client/dist/spa/assets/QTabPanels-Ctnrqvp9.js +0 -1
  107. package/src/client/dist/spa/assets/QTooltip-B3CmRx4j.js +0 -1
  108. package/src/client/dist/spa/assets/SettingsPage-8N0X7B7o.css +0 -1
  109. package/src/client/dist/spa/assets/SettingsPage-BaaSJ3eJ.js +0 -1
  110. package/src/client/dist/spa/assets/WorkspacePage-wZUUTDzp.js +0 -4
  111. package/src/client/dist/spa/assets/expand-template-zA3pTyIP.js +0 -1
  112. package/src/client/dist/spa/assets/i18n-B41j--A3.js +0 -1
  113. package/src/client/dist/spa/assets/index-DoYBJtQA.js +0 -2
  114. package/src/client/dist/spa/assets/is-BbsvEMaT.js +0 -1
  115. package/src/client/dist/spa/assets/marked.esm-DLCrAGtO.js +0 -60
  116. package/src/client/dist/spa/assets/models-BPfFBcxr.js +0 -1
  117. package/src/client/dist/spa/assets/settings-lT4GB-uB.js +0 -1
  118. package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +0 -1
  119. package/src/client/dist/spa/assets/use-checkbox-DzHmcu7s.js +0 -1
  120. package/src/client/dist/spa/assets/use-panel-DWX2aNMM.js +0 -1
  121. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-B8bB5DBd.js → _plugin-vue_export-helper-Cj6tcsj6.js} +0 -0
  122. /package/src/client/dist/spa/assets/{abap-DzK-OTGh.js → abap-DiwvWnMr.js} +0 -0
  123. /package/src/client/dist/spa/assets/{apex-Bj60_dRt.js → apex-CmtZjKlf.js} +0 -0
  124. /package/src/client/dist/spa/assets/{azcli-B6NwaBAZ.js → azcli-DL2My_i-.js} +0 -0
  125. /package/src/client/dist/spa/assets/{bat-bf7wXV68.js → bat-B-nC98wG.js} +0 -0
  126. /package/src/client/dist/spa/assets/{bicep-C_bg8UgA.js → bicep-Ju5MwOgh.js} +0 -0
  127. /package/src/client/dist/spa/assets/{cameligo-CTWw4D4B.js → cameligo-8Eu1TyBr.js} +0 -0
  128. /package/src/client/dist/spa/assets/{clojure-CgdPoH0r.js → clojure-u-RpMkH3.js} +0 -0
  129. /package/src/client/dist/spa/assets/{coffee-gHQfdA5M.js → coffee-CdA7bbTe.js} +0 -0
  130. /package/src/client/dist/spa/assets/{cpp-BM4Jj4aW.js → cpp-CzNFP8ks.js} +0 -0
  131. /package/src/client/dist/spa/assets/{csharp-D8-bh4Cd.js → csharp-j1LThmcE.js} +0 -0
  132. /package/src/client/dist/spa/assets/{csp-CXBxRx0n.js → csp-CLRC61y6.js} +0 -0
  133. /package/src/client/dist/spa/assets/{css-DKjIxrmY.js → css-r6rC_7P2.js} +0 -0
  134. /package/src/client/dist/spa/assets/{cypher-C5e5inIh.js → cypher-CW08XVUh.js} +0 -0
  135. /package/src/client/dist/spa/assets/{dart-BhRHHm4x.js → dart-Cs9aL5T_.js} +0 -0
  136. /package/src/client/dist/spa/assets/{dockerfile-DW5REF8E.js → dockerfile-BWM0M184.js} +0 -0
  137. /package/src/client/dist/spa/assets/{ecl-Bw4Hg3n_.js → ecl-MJJuer5P.js} +0 -0
  138. /package/src/client/dist/spa/assets/{elixir-DHmoBvpZ.js → elixir-D2AIuXqn.js} +0 -0
  139. /package/src/client/dist/spa/assets/{flow9-BsFExz3v.js → flow9-B2H24giC.js} +0 -0
  140. /package/src/client/dist/spa/assets/{fsharp-BaeLhgfq.js → fsharp-CMk2OIJN.js} +0 -0
  141. /package/src/client/dist/spa/assets/{go-Bd-NFKIC.js → go-BrMkuJg0.js} +0 -0
  142. /package/src/client/dist/spa/assets/{graphql-DZVerJfy.js → graphql-PSR1UKGv.js} +0 -0
  143. /package/src/client/dist/spa/assets/{hcl-CAVzrZfH.js → hcl-DAQrbDOW.js} +0 -0
  144. /package/src/client/dist/spa/assets/{ini-CyXdX58t.js → ini-0TG5BxW0.js} +0 -0
  145. /package/src/client/dist/spa/assets/{java-B5pNgvhy.js → java-rgorz17v.js} +0 -0
  146. /package/src/client/dist/spa/assets/{julia-XRhmV3AN.js → julia-C8VMdHm8.js} +0 -0
  147. /package/src/client/dist/spa/assets/{kobo-commands-DiUm1Y34.js → kobo-commands-w8VepGvD.js} +0 -0
  148. /package/src/client/dist/spa/assets/{kotlin-DOd3J5vr.js → kotlin-CllWo3gX.js} +0 -0
  149. /package/src/client/dist/spa/assets/{less-veZSnyw6.js → less-Cgca25AP.js} +0 -0
  150. /package/src/client/dist/spa/assets/{lexon-QWGkuK0H.js → lexon-D0GHdBaw.js} +0 -0
  151. /package/src/client/dist/spa/assets/{lua-CYGpjuO5.js → lua-DmRsNG-P.js} +0 -0
  152. /package/src/client/dist/spa/assets/{m3-yNnrZkdc.js → m3-BgL5dNKT.js} +0 -0
  153. /package/src/client/dist/spa/assets/{markdown-BCSWEPSX.js → markdown-BuJfycGS.js} +0 -0
  154. /package/src/client/dist/spa/assets/{mips-OpYmcC30.js → mips-C9m_93PR.js} +0 -0
  155. /package/src/client/dist/spa/assets/{msdax-2oxoTO9Z.js → msdax-CpFHC9OI.js} +0 -0
  156. /package/src/client/dist/spa/assets/{mysql-5KlC-K_9.js → mysql-qFvltsqN.js} +0 -0
  157. /package/src/client/dist/spa/assets/{objective-c-CcDCgtLx.js → objective-c-Bnmr858J.js} +0 -0
  158. /package/src/client/dist/spa/assets/{pascal-BZGsbaEV.js → pascal-WP0_D5AO.js} +0 -0
  159. /package/src/client/dist/spa/assets/{pascaligo-DtD5qU3G.js → pascaligo-Blom4Rij.js} +0 -0
  160. /package/src/client/dist/spa/assets/{perl-C1jNNS3E.js → perl-B-vk8g64.js} +0 -0
  161. /package/src/client/dist/spa/assets/{pgsql-CT0fhiZa.js → pgsql-Cgvz6v67.js} +0 -0
  162. /package/src/client/dist/spa/assets/{php-D6DrXoPM.js → php-8a3Lrw9m.js} +0 -0
  163. /package/src/client/dist/spa/assets/{pla-b3-HN2pF.js → pla-DuFqEZ8V.js} +0 -0
  164. /package/src/client/dist/spa/assets/{postiats-Bin2ApVS.js → postiats-DkLtSgkp.js} +0 -0
  165. /package/src/client/dist/spa/assets/{powerquery-7ASnn-ZG.js → powerquery-BJ1aNepW.js} +0 -0
  166. /package/src/client/dist/spa/assets/{powershell-t4p7sU1H.js → powershell-rE98k687.js} +0 -0
  167. /package/src/client/dist/spa/assets/{protobuf-BUGeWa_j.js → protobuf-CUheFacr.js} +0 -0
  168. /package/src/client/dist/spa/assets/{pug-BuKcgC9s.js → pug-LDcAMD8w.js} +0 -0
  169. /package/src/client/dist/spa/assets/{qsharp-DSMtI_O7.js → qsharp-DUKSQoR1.js} +0 -0
  170. /package/src/client/dist/spa/assets/{r-DMlFgn7A.js → r-D-QApv87.js} +0 -0
  171. /package/src/client/dist/spa/assets/{redis-cXItkC5u.js → redis-SXdDyWR9.js} +0 -0
  172. /package/src/client/dist/spa/assets/{redshift-BZVbW7HE.js → redshift-Y6lsCryn.js} +0 -0
  173. /package/src/client/dist/spa/assets/{restructuredtext-BzjxwS8h.js → restructuredtext-edObr9a8.js} +0 -0
  174. /package/src/client/dist/spa/assets/{ruby-C5nyLV4l.js → ruby-CNnUfF-8.js} +0 -0
  175. /package/src/client/dist/spa/assets/{rust-BcmMsHdf.js → rust-IHUZWzBr.js} +0 -0
  176. /package/src/client/dist/spa/assets/{sb-Dnb1iy6B.js → sb-DrUvY44N.js} +0 -0
  177. /package/src/client/dist/spa/assets/{scala-anMIFYpA.js → scala-B4hbXGLM.js} +0 -0
  178. /package/src/client/dist/spa/assets/{scheme-BItQTe08.js → scheme-BGrd12j3.js} +0 -0
  179. /package/src/client/dist/spa/assets/{scss-BOv51BJ5.js → scss-x5G1ES4U.js} +0 -0
  180. /package/src/client/dist/spa/assets/{shell-BsRYRTNN.js → shell-DOehe2Y8.js} +0 -0
  181. /package/src/client/dist/spa/assets/{solidity-BtuLgGDx.js → solidity-BeRvcwWV.js} +0 -0
  182. /package/src/client/dist/spa/assets/{sophia-B0Vkc5MF.js → sophia-DZbkUNjy.js} +0 -0
  183. /package/src/client/dist/spa/assets/{sparql-B7lvkZQM.js → sparql-B7_oi5-h.js} +0 -0
  184. /package/src/client/dist/spa/assets/{sql-DvP5MpA3.js → sql-CTlsFWVE.js} +0 -0
  185. /package/src/client/dist/spa/assets/{st-GVUeyB3U.js → st-DJVEJdPE.js} +0 -0
  186. /package/src/client/dist/spa/assets/{swift-DSPIoCjm.js → swift-CwhT3fYa.js} +0 -0
  187. /package/src/client/dist/spa/assets/{systemverilog-Icj2-k23.js → systemverilog-BQN63pkN.js} +0 -0
  188. /package/src/client/dist/spa/assets/{tcl-Cd8KQcm-.js → tcl-DqwfpskA.js} +0 -0
  189. /package/src/client/dist/spa/assets/{touch-Co9pfjUU.js → touch-HRdTUO2o.js} +0 -0
  190. /package/src/client/dist/spa/assets/{twig-CBHmt8z3.js → twig-BiyenUgc.js} +0 -0
  191. /package/src/client/dist/spa/assets/{typespec-Ckc037mq.js → typespec-CWOJribt.js} +0 -0
  192. /package/src/client/dist/spa/assets/{use-quasar-Cc4smfg5.js → use-quasar-Sdcq6zzV.js} +0 -0
  193. /package/src/client/dist/spa/assets/{vb-B97GW9Wb.js → vb-Cq5F87m3.js} +0 -0
  194. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BcfTCFFS.js} +0 -0
  195. /package/src/client/dist/spa/assets/{wgsl-DIKmb3YH.js → wgsl-BAvW2lVr.js} +0 -0
  196. /package/src/client/dist/spa/{notification.mp3 → sounds/hey.mp3} +0 -0
@@ -2,7 +2,7 @@ import { getPrStatusAsync } from '../utils/git-ops.js';
2
2
  import { stopDevServer } from './dev-server-service.js';
3
3
  import { destroyTerminal } from './terminal-service.js';
4
4
  import { emitEphemeral } from './websocket-service.js';
5
- import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
5
+ import { archiveWorkspace, listWorkspaces, updateWorkspaceSourceBranch } from './workspace-service.js';
6
6
  // ── PR Watcher ────────────────────────────────────────────────────────────────
7
7
  // Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
8
8
  // automatically archive the corresponding workspace.
@@ -14,8 +14,7 @@ import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
14
14
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
15
15
  let timer = null;
16
16
  let checking = false;
17
- /** Tracks the last known PR state per workspace to detect transitions. */
18
- const lastKnownState = new Map();
17
+ const lastKnownPr = new Map();
19
18
  /**
20
19
  * Read-only snapshot of PR states known to the watcher, keyed by workspace id.
21
20
  * Used by the drawer to show a small PR-open indicator without N separate
@@ -25,17 +24,24 @@ const lastKnownState = new Map();
25
24
  */
26
25
  export function getAllPrStates() {
27
26
  const out = {};
28
- for (const [id, state] of lastKnownState) {
29
- out[id] = state;
27
+ for (const [id, known] of lastKnownPr) {
28
+ out[id] = known.state;
30
29
  }
31
30
  return out;
32
31
  }
33
- async function checkPrStatuses() {
32
+ /**
33
+ * Test-only escape hatch — drops the in-memory cache so each test starts
34
+ * from a clean slate. Not part of the public API.
35
+ */
36
+ export function _resetForTest() {
37
+ lastKnownPr.clear();
38
+ }
39
+ export async function checkPrStatuses() {
34
40
  const workspaces = listWorkspaces(false); // non-archived only
35
41
  // Clean up entries for workspaces that no longer exist
36
- for (const id of lastKnownState.keys()) {
42
+ for (const id of lastKnownPr.keys()) {
37
43
  if (!workspaces.some((ws) => ws.id === id)) {
38
- lastKnownState.delete(id);
44
+ lastKnownPr.delete(id);
39
45
  }
40
46
  }
41
47
  for (const ws of workspaces) {
@@ -46,10 +52,15 @@ async function checkPrStatuses() {
46
52
  const pr = await getPrStatusAsync(ws.projectPath, ws.workingBranch);
47
53
  if (!pr)
48
54
  continue;
49
- const prev = lastKnownState.get(ws.id);
50
- lastKnownState.set(ws.id, pr.state);
51
- // Only archive on a transition FROM OPEN not on first sight of CLOSED/MERGED
52
- if (prev === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
55
+ const prev = lastKnownPr.get(ws.id);
56
+ // We delay updating `lastKnownPr` until after the actions succeed.
57
+ // Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
58
+ // throws (transient DB issue, race with workspace deletion), the cache
59
+ // already holds the new base and the user never sees the toast — the
60
+ // next tick computes `prev.base === pr.base` and treats it as no-op.
61
+ // Archive on a transition FROM OPEN to CLOSED/MERGED. Skips the
62
+ // base-change detection below — archiving wins.
63
+ if (prev?.state === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
53
64
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
54
65
  // Best-effort cleanup (same as manual archive): stop dev server + terminal.
55
66
  // Agent is already not running here (guarded above).
@@ -66,12 +77,51 @@ async function checkPrStatuses() {
66
77
  // Terminal may not exist — ignore
67
78
  }
68
79
  archiveWorkspace(ws.id);
69
- lastKnownState.delete(ws.id);
80
+ lastKnownPr.delete(ws.id);
70
81
  emitEphemeral(ws.id, 'workspace:archived', {
71
82
  reason: `PR ${pr.state.toLowerCase()}`,
72
83
  prUrl: pr.url,
73
84
  });
85
+ continue; // do not run base-change detection on a workspace we just archived
86
+ }
87
+ // Base-branch change detection. Only relevant for OPEN PRs — closed/
88
+ // merged PRs don't accept base changes. Skip if the GitHub response
89
+ // didn't include a baseRefName (defensive against malformed data).
90
+ if (pr.state !== 'OPEN' || !pr.base) {
91
+ // Still update the cache for the state — keeps the OPEN→CLOSED/MERGED
92
+ // archiving logic working on the next tick.
93
+ lastKnownPr.set(ws.id, { state: pr.state, base: prev?.base });
94
+ continue;
95
+ }
96
+ // Comparison baseline:
97
+ // - If we've seen this workspace before, use the previous `base`.
98
+ // - Otherwise (first sight after boot/unarchive), compare with the
99
+ // `sourceBranch` recorded in the database — that catches base changes
100
+ // that happened while Kobo was offline.
101
+ const previousBase = prev?.base ?? ws.sourceBranch;
102
+ if (previousBase === pr.base) {
103
+ // No-op path: still record the base so subsequent ticks have a baseline.
104
+ lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
105
+ continue;
106
+ }
107
+ console.log(`[pr-watcher] PR base changed for workspace '${ws.name}': ${previousBase} → ${pr.base}`);
108
+ try {
109
+ updateWorkspaceSourceBranch(ws.id, pr.base);
110
+ }
111
+ catch (err) {
112
+ console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
113
+ // Don't poison the cache: leave the previous entry (or absence) so
114
+ // the next tick retries the detection.
115
+ continue;
74
116
  }
117
+ // Both the persistence and the emit are part of "we successfully
118
+ // observed a base change" — only NOW commit the new state to the cache.
119
+ lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
120
+ emitEphemeral(ws.id, 'pr:base-changed', {
121
+ oldBase: previousBase,
122
+ newBase: pr.base,
123
+ prUrl: pr.url,
124
+ });
75
125
  }
76
126
  catch (err) {
77
127
  console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ export const DEFAULT_REVIEW_PROMPT_TEMPLATE = `You are reviewing code changes on workspace "{{workspace_name}}" in project {{project_name}}.
3
+
4
+ Branch: {{branch_name}} (base: {{source_branch}})
5
+ Base commit: {{base_commit}}
6
+
7
+ If a code-review skill is available (e.g. superpowers:requesting-code-review), invoke it to drive this review. Otherwise follow the steps below directly.
8
+
9
+ ## Scope
10
+
11
+ Review ALL changes — both committed and uncommitted in the working tree:
12
+ - \`git diff {{base_commit}}..HEAD\` — committed changes on this branch
13
+ - \`git status\` and \`git diff\` — uncommitted changes (staged + unstaged)
14
+
15
+ ## Diff summary
16
+ {{diff_stats}}
17
+
18
+ ## Commits
19
+ {{commits}}
20
+
21
+ ## Additional instructions
22
+ {{additional_instructions}}
23
+
24
+ ## Output
25
+
26
+ If no review skill is available, structure your reply as:
27
+ 1. Summary — what changed and why
28
+ 2. Issues — bugs, regressions, security or perf concerns (with file:line)
29
+ 3. Suggestions — refactor / improvement opportunities
30
+ 4. Tests — coverage gaps
31
+ 5. Verdict — ship / fix-then-ship / blocked
32
+ `;
33
+ function buildVariableMap(ctx) {
34
+ return {
35
+ project_name: path.basename(ctx.workspace.projectPath),
36
+ workspace_name: ctx.workspace.name,
37
+ branch_name: ctx.workspace.workingBranch,
38
+ source_branch: ctx.workspace.sourceBranch,
39
+ base_commit: ctx.baseCommit,
40
+ commits: ctx.commits,
41
+ diff_stats: ctx.diffStats,
42
+ notion_url: ctx.workspace.notionUrl ?? '',
43
+ additional_instructions: ctx.additionalInstructions.length > 0 ? ctx.additionalInstructions : '(none)',
44
+ };
45
+ }
46
+ /**
47
+ * Render a review prompt template by substituting {{variable}} placeholders.
48
+ * Pure: no I/O, no side effects. Unknown variables are left intact.
49
+ */
50
+ export function renderReviewTemplate(template, ctx) {
51
+ const vars = buildVariableMap(ctx);
52
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
53
+ if (Object.hasOwn(vars, name)) {
54
+ return vars[name];
55
+ }
56
+ return match;
57
+ });
58
+ }
@@ -4,7 +4,9 @@ import { WORKTREES_PATH } from '../../shared/consts.js';
4
4
  import { listClaudeMcpEntries } from '../utils/mcp-client.js';
5
5
  import { getSettingsPath } from '../utils/paths.js';
6
6
  import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
7
- const DEFAULT_GIT_CONVENTIONS = `# Git conventions
7
+ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT } from './initial-prompt-template-service.js';
8
+ import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from './review-template-service.js';
9
+ export const DEFAULT_GIT_CONVENTIONS = `# Git conventions
8
10
 
9
11
  ## Commits
10
12
  - Use Conventional Commits: \`type(scope): subject\`
@@ -29,7 +31,7 @@ const DEFAULT_GIT_CONVENTIONS = `# Git conventions
29
31
  - Never skip hooks (--no-verify) unless the user explicitly asks
30
32
  - Always inspect \`git status\` and \`git diff\` before staging
31
33
  `;
32
- const DEFAULT_PR_PROMPT_TEMPLATE = `A pull request has been opened: {{pr_url}} (#{{pr_number}})
34
+ export const DEFAULT_PR_PROMPT_TEMPLATE = `A pull request has been opened: {{pr_url}} (#{{pr_number}})
33
35
 
34
36
  Context:
35
37
  - Workspace: {{workspace_name}}
@@ -200,6 +202,68 @@ const settingsMigrations = [
200
202
  global.worktreesPath = sanitizeWorktreesPath(global.worktreesPath);
201
203
  },
202
204
  },
205
+ {
206
+ version: 12,
207
+ name: 'add-audio-notification-sound',
208
+ migrate({ global }) {
209
+ if (typeof global.audioNotificationSound !== 'string' || global.audioNotificationSound.length === 0) {
210
+ global.audioNotificationSound = 'hey.mp3';
211
+ }
212
+ },
213
+ },
214
+ {
215
+ version: 13,
216
+ name: 'add-audio-notification-volume',
217
+ migrate({ global }) {
218
+ const v = global.audioNotificationVolume;
219
+ if (typeof v !== 'number' || Number.isNaN(v) || v < 0 || v > 1) {
220
+ global.audioNotificationVolume = 1;
221
+ }
222
+ },
223
+ },
224
+ {
225
+ version: 14,
226
+ name: 'add-worktrees-prefix-by-project',
227
+ migrate({ global }) {
228
+ if (typeof global.worktreesPrefixByProject !== 'boolean') {
229
+ global.worktreesPrefixByProject = false;
230
+ }
231
+ },
232
+ },
233
+ {
234
+ version: 15,
235
+ name: 'add-review-prompt-template',
236
+ migrate({ global, projects }) {
237
+ if (typeof global.reviewPromptTemplate !== 'string') {
238
+ global.reviewPromptTemplate = DEFAULT_REVIEW_PROMPT_TEMPLATE;
239
+ }
240
+ for (const p of projects) {
241
+ if (typeof p.reviewPromptTemplate !== 'string') {
242
+ p.reviewPromptTemplate = '';
243
+ }
244
+ }
245
+ },
246
+ },
247
+ {
248
+ version: 16,
249
+ name: 'add-notion-sentry-initial-prompts',
250
+ migrate({ global, projects }) {
251
+ if (typeof global.notionInitialPromptTemplate !== 'string') {
252
+ global.notionInitialPromptTemplate = DEFAULT_NOTION_INITIAL_PROMPT;
253
+ }
254
+ if (typeof global.sentryInitialPromptTemplate !== 'string') {
255
+ global.sentryInitialPromptTemplate = DEFAULT_SENTRY_INITIAL_PROMPT;
256
+ }
257
+ for (const p of projects) {
258
+ if (typeof p.notionInitialPromptTemplate !== 'string') {
259
+ p.notionInitialPromptTemplate = '';
260
+ }
261
+ if (typeof p.sentryInitialPromptTemplate !== 'string') {
262
+ p.sentryInitialPromptTemplate = '';
263
+ }
264
+ }
265
+ },
266
+ },
203
267
  ];
204
268
  /** Current settings schema version — always equals the highest migration version. */
205
269
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -230,10 +294,15 @@ function defaultSettings() {
230
294
  defaultModel: 'claude-opus-4-7',
231
295
  dangerouslySkipPermissions: true,
232
296
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
297
+ reviewPromptTemplate: DEFAULT_REVIEW_PROMPT_TEMPLATE,
298
+ notionInitialPromptTemplate: DEFAULT_NOTION_INITIAL_PROMPT,
299
+ sentryInitialPromptTemplate: DEFAULT_SENTRY_INITIAL_PROMPT,
233
300
  gitConventions: DEFAULT_GIT_CONVENTIONS,
234
301
  editorCommand: '',
235
302
  browserNotifications: true,
236
303
  audioNotifications: true,
304
+ audioNotificationSound: 'hey.mp3',
305
+ audioNotificationVolume: 1,
237
306
  notionStatusProperty: '',
238
307
  notionInProgressStatus: '',
239
308
  defaultPermissionMode: 'plan',
@@ -241,6 +310,7 @@ function defaultSettings() {
241
310
  sentryMcpKey: '',
242
311
  tags: [...DEFAULT_WORKSPACE_TAGS],
243
312
  worktreesPath: WORKTREES_PATH,
313
+ worktreesPrefixByProject: false,
244
314
  },
245
315
  projects: [],
246
316
  };
@@ -253,6 +323,9 @@ function defaultProjectSettings(projectPath) {
253
323
  defaultModel: '',
254
324
  dangerouslySkipPermissions: true,
255
325
  prPromptTemplate: '',
326
+ reviewPromptTemplate: '',
327
+ notionInitialPromptTemplate: '',
328
+ sentryInitialPromptTemplate: '',
256
329
  gitConventions: '',
257
330
  setupScript: '',
258
331
  devServer: {
@@ -426,6 +499,9 @@ export function getEffectiveSettings(projectPath) {
426
499
  model: settings.global.defaultModel,
427
500
  dangerouslySkipPermissions: settings.global.dangerouslySkipPermissions,
428
501
  prPromptTemplate: settings.global.prPromptTemplate,
502
+ reviewPromptTemplate: settings.global.reviewPromptTemplate,
503
+ notionInitialPromptTemplate: settings.global.notionInitialPromptTemplate,
504
+ sentryInitialPromptTemplate: settings.global.sentryInitialPromptTemplate,
429
505
  gitConventions: settings.global.gitConventions,
430
506
  sourceBranch: '',
431
507
  devServer: null,
@@ -438,6 +514,9 @@ export function getEffectiveSettings(projectPath) {
438
514
  model: project.defaultModel || settings.global.defaultModel,
439
515
  dangerouslySkipPermissions: project.dangerouslySkipPermissions ?? settings.global.dangerouslySkipPermissions,
440
516
  prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
517
+ reviewPromptTemplate: project.reviewPromptTemplate || settings.global.reviewPromptTemplate,
518
+ notionInitialPromptTemplate: project.notionInitialPromptTemplate || settings.global.notionInitialPromptTemplate,
519
+ sentryInitialPromptTemplate: project.sentryInitialPromptTemplate || settings.global.sentryInitialPromptTemplate,
441
520
  gitConventions: project.gitConventions || settings.global.gitConventions,
442
521
  sourceBranch: project.defaultSourceBranch,
443
522
  devServer: project.devServer,
@@ -453,10 +532,15 @@ export function updateGlobalSettings(data) {
453
532
  'defaultModel',
454
533
  'dangerouslySkipPermissions',
455
534
  'prPromptTemplate',
535
+ 'reviewPromptTemplate',
536
+ 'notionInitialPromptTemplate',
537
+ 'sentryInitialPromptTemplate',
456
538
  'gitConventions',
457
539
  'editorCommand',
458
540
  'browserNotifications',
459
541
  'audioNotifications',
542
+ 'audioNotificationSound',
543
+ 'audioNotificationVolume',
460
544
  'notionStatusProperty',
461
545
  'notionInProgressStatus',
462
546
  'defaultPermissionMode',
@@ -464,6 +548,7 @@ export function updateGlobalSettings(data) {
464
548
  'sentryMcpKey',
465
549
  'tags',
466
550
  'worktreesPath',
551
+ 'worktreesPrefixByProject',
467
552
  ];
468
553
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
469
554
  if (filtered.tags !== undefined) {
@@ -473,6 +558,10 @@ export function updateGlobalSettings(data) {
473
558
  .filter((t) => t.length > 0 && t.length <= 50)))
474
559
  : settings.global.tags;
475
560
  }
561
+ if (filtered.audioNotificationVolume !== undefined) {
562
+ const v = Number(filtered.audioNotificationVolume);
563
+ filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
564
+ }
476
565
  if (filtered.worktreesPath !== undefined) {
477
566
  filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
478
567
  ensureGlobalWorktreesRootExists(filtered.worktreesPath);
@@ -505,6 +594,9 @@ export function upsertProject(projectPath, data) {
505
594
  'defaultModel',
506
595
  'dangerouslySkipPermissions',
507
596
  'prPromptTemplate',
597
+ 'reviewPromptTemplate',
598
+ 'notionInitialPromptTemplate',
599
+ 'sentryInitialPromptTemplate',
508
600
  'gitConventions',
509
601
  'setupScript',
510
602
  'devServer',
@@ -1,6 +1,8 @@
1
1
  import { getDb } from '../db/index.js';
2
+ import { slugifyProjectName } from '../utils/project-slug.js';
2
3
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
3
4
  import * as orchestrator from './agent/orchestrator.js';
5
+ import * as settingsService from './settings-service.js';
4
6
  import { emitEphemeral } from './websocket-service.js';
5
7
  const MIN_DELAY_SECONDS = 60;
6
8
  const MAX_DELAY_SECONDS = 3600;
@@ -132,7 +134,13 @@ function fire(workspaceId) {
132
134
  emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
133
135
  return;
134
136
  }
135
- const worktreePath = wsRow.worktree_path ?? resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch);
137
+ const globalSettings = settingsService.getGlobalSettings();
138
+ const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
139
+ const projectSlug = globalSettings.worktreesPrefixByProject
140
+ ? slugifyProjectName(projectSettings?.displayName ?? '', wsRow.project_path)
141
+ : undefined;
142
+ const worktreePath = wsRow.worktree_path ??
143
+ resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch, globalSettings.worktreesPath, projectSlug);
136
144
  // Narrow against the four known values; unknowns → 'bypass'.
137
145
  const stored = wsRow.agent_permission_mode;
138
146
  const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
@@ -217,6 +217,27 @@ export function updateWorkingBranch(id, workingBranch) {
217
217
  }
218
218
  return getWorkspace(id);
219
219
  }
220
+ /**
221
+ * Update the source branch in the database. Called by the PR watcher when
222
+ * GitHub reports a different `baseRefName` for the workspace's PR.
223
+ * Does NOT touch the worktree — the user (or the agent) decides when to
224
+ * rebase the local branch onto the new base.
225
+ */
226
+ export function updateWorkspaceSourceBranch(id, sourceBranch) {
227
+ const sanitized = sourceBranch.trim();
228
+ if (!sanitized) {
229
+ throw new Error('Source branch cannot be empty');
230
+ }
231
+ const db = getDb();
232
+ const now = new Date().toISOString();
233
+ const result = db
234
+ .prepare('UPDATE workspaces SET source_branch = ?, updated_at = ? WHERE id = ?')
235
+ .run(sanitized, now, id);
236
+ if (result.changes === 0) {
237
+ throw new Error(`Workspace '${id}' not found`);
238
+ }
239
+ return getWorkspace(id);
240
+ }
220
241
  /** Update the on-disk worktree path. Used by rename / resync-branch on owned worktrees. */
221
242
  export function updateWorktreePath(id, newPath) {
222
243
  const db = getDb();
@@ -49,12 +49,12 @@ function removeFromExclude(projectPath, worktreePath) {
49
49
  fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
50
50
  }
51
51
  /** Create a git worktree for the given branch. Returns the worktree path. */
52
- export function createWorktree(projectPath, branchName, sourceBranch, worktreesPath) {
52
+ export function createWorktree(projectPath, branchName, sourceBranch, worktreesPath, projectSlug) {
53
53
  const worktreesDir = resolveWorktreesRoot(projectPath, worktreesPath);
54
54
  if (!fs.existsSync(worktreesDir)) {
55
55
  fs.mkdirSync(worktreesDir, { recursive: true });
56
56
  }
57
- const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath);
57
+ const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath, projectSlug);
58
58
  try {
59
59
  // Use origin/<sourceBranch> as the base so the worktree starts from the
60
60
  // freshly-fetched remote ref (fetchSourceBranch is always called first).
@@ -267,6 +267,22 @@ export function getCommitCount(repoPath, base, head) {
267
267
  return 0;
268
268
  }
269
269
  }
270
+ /**
271
+ * Count commits in `base` that are not in `head` — i.e. how far `head` lags
272
+ * behind `base`. Mirrors `getCommitCount` but in reverse direction.
273
+ * Returns 0 on failure.
274
+ */
275
+ export function getCommitsBehind(repoPath, base, head) {
276
+ try {
277
+ const ref = resolveBase(repoPath, base);
278
+ const output = git(repoPath, ['rev-list', '--count', `${head}..${ref}`]);
279
+ const n = parseInt(output.trim(), 10);
280
+ return Number.isFinite(n) ? n : 0;
281
+ }
282
+ catch {
283
+ return 0;
284
+ }
285
+ }
270
286
  /** Return structured diff shortstat between two refs (three-dot merge base). */
271
287
  export function getStructuredDiffStatsBetween(repoPath, base, head) {
272
288
  try {
@@ -340,6 +356,40 @@ export function listBranchCommits(repoPath, sourceBranch, workingBranch, limit =
340
356
  }
341
357
  return commits;
342
358
  }
359
+ /**
360
+ * List commits on `sourceBranch` that are NOT yet on `workingBranch` —
361
+ * i.e. commits the working branch is "behind" by. Mirror of `listBranchCommits`
362
+ * in the opposite direction. Up to `limit` commits, most recent first.
363
+ */
364
+ export function listCommitsBehind(repoPath, sourceBranch, workingBranch, limit = 50) {
365
+ const sourceRef = resolveBase(repoPath, sourceBranch);
366
+ const FORMAT = '--pretty=format:%H%x00%h%x00%s%x00%an%x00%aI';
367
+ let raw;
368
+ try {
369
+ raw = git(repoPath, ['log', `${workingBranch}..${sourceRef}`, `--max-count=${limit}`, FORMAT]);
370
+ }
371
+ catch {
372
+ return [];
373
+ }
374
+ if (!raw)
375
+ return [];
376
+ const commits = [];
377
+ for (const line of raw.split('\n')) {
378
+ if (!line)
379
+ continue;
380
+ const [sha, shortSha, subject, author, date] = line.split('\x00');
381
+ if (!sha)
382
+ continue;
383
+ commits.push({
384
+ sha,
385
+ shortSha: shortSha ?? '',
386
+ subject: subject ?? '',
387
+ author: author ?? '',
388
+ date: date ?? '',
389
+ });
390
+ }
391
+ return commits;
392
+ }
343
393
  /** Get the GitHub PR URL for a branch using `gh pr view`. Returns null if no PR exists. */
344
394
  export function getPrUrl(repoPath, branchName) {
345
395
  try {
@@ -355,14 +405,18 @@ export function getPrUrl(repoPath, branchName) {
355
405
  /** Get the state and URL of the PR for a branch. Returns null if no PR exists. */
356
406
  export function getPrStatus(repoPath, branchName) {
357
407
  try {
358
- const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
408
+ const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url,baseRefName'], {
359
409
  cwd: repoPath,
360
410
  encoding: 'utf-8',
361
411
  }).trim();
362
412
  if (!raw)
363
413
  return null;
364
414
  const parsed = JSON.parse(raw);
365
- return { state: parsed.state, url: parsed.url };
415
+ return {
416
+ state: parsed.state,
417
+ url: parsed.url,
418
+ base: parsed.baseRefName || undefined,
419
+ };
366
420
  }
367
421
  catch {
368
422
  return null;
@@ -644,6 +698,21 @@ export function getDiffStatsBetween(repoPath, base, head) {
644
698
  return '';
645
699
  }
646
700
  }
701
+ /**
702
+ * Return `git diff --stat HEAD` output (working tree vs HEAD) as a single string.
703
+ * Empty string if the working tree is clean or the command fails. Best-effort: never throws.
704
+ */
705
+ export function getWorkingTreeDiffStats(repoPath) {
706
+ try {
707
+ return execFileSync('git', ['diff', '--stat', 'HEAD'], {
708
+ cwd: repoPath,
709
+ encoding: 'utf-8',
710
+ });
711
+ }
712
+ catch {
713
+ return '';
714
+ }
715
+ }
647
716
  // ── Async versions ───────────────────────────────────────────────────────────
648
717
  // Non-blocking alternatives for hot paths (pr-watcher, route handlers).
649
718
  /** Async version of getPrUrl. Returns null if no PR exists. */
@@ -662,7 +731,7 @@ export async function getPrUrlAsync(repoPath, branchName) {
662
731
  /** Async version of getPrStatus. Returns null if no PR exists. */
663
732
  export async function getPrStatusAsync(repoPath, branchName) {
664
733
  try {
665
- const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
734
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url,baseRefName'], {
666
735
  cwd: repoPath,
667
736
  encoding: 'utf-8',
668
737
  });
@@ -670,7 +739,11 @@ export async function getPrStatusAsync(repoPath, branchName) {
670
739
  if (!raw)
671
740
  return null;
672
741
  const parsed = JSON.parse(raw);
673
- return { state: parsed.state, url: parsed.url };
742
+ return {
743
+ state: parsed.state,
744
+ url: parsed.url,
745
+ base: parsed.baseRefName || undefined,
746
+ };
674
747
  }
675
748
  catch {
676
749
  return null;
@@ -689,3 +762,20 @@ export async function getUnpushedCountAsync(repoPath) {
689
762
  return -1; // no upstream
690
763
  }
691
764
  }
765
+ /**
766
+ * Best-effort async `git fetch <remote> <branch>`. Never throws — by contract,
767
+ * suitable for both fire-and-forget and `await` use without try/catch at the
768
+ * call site. Logs a warning on failure but resolves cleanly.
769
+ *
770
+ * Mirrors the sync `fetchSourceBranch` sibling, including the optional `remote`
771
+ * parameter (defaults to `'origin'`).
772
+ */
773
+ export async function fetchSourceBranchAsync(repoPath, branch, remote = 'origin') {
774
+ try {
775
+ await execFileAsync('git', ['fetch', remote, branch], { cwd: repoPath });
776
+ }
777
+ catch (err) {
778
+ const msg = err instanceof Error ? err.message : String(err);
779
+ console.warn(`[git-ops] fetchSourceBranchAsync(${remote}/${branch}) failed: ${msg}`);
780
+ }
781
+ }
@@ -0,0 +1,52 @@
1
+ import path from 'node:path';
2
+ const WINDOWS_RESERVED = new Set([
3
+ 'con',
4
+ 'prn',
5
+ 'aux',
6
+ 'nul',
7
+ 'com1',
8
+ 'com2',
9
+ 'com3',
10
+ 'com4',
11
+ 'com5',
12
+ 'com6',
13
+ 'com7',
14
+ 'com8',
15
+ 'com9',
16
+ 'lpt1',
17
+ 'lpt2',
18
+ 'lpt3',
19
+ 'lpt4',
20
+ 'lpt5',
21
+ 'lpt6',
22
+ 'lpt7',
23
+ 'lpt8',
24
+ 'lpt9',
25
+ ]);
26
+ function slugifyOne(value) {
27
+ return value
28
+ .normalize('NFD')
29
+ .replace(/[̀-ͯ]/g, '')
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '');
33
+ }
34
+ /**
35
+ * Produce a cross-OS-safe directory name (Linux + macOS + Windows) from a
36
+ * project's display name, falling back to the path basename and finally to
37
+ * the literal `'project'`. Output is guaranteed to be non-empty and to avoid
38
+ * Windows reserved names (CON, PRN, COM1..9, LPT1..9, AUX, NUL).
39
+ */
40
+ export function slugifyProjectName(displayName, projectPath) {
41
+ const fromDisplay = slugifyOne((displayName ?? '').trim());
42
+ if (fromDisplay)
43
+ return guard(fromDisplay);
44
+ const basename = path.basename(projectPath ?? '');
45
+ const fromBasename = slugifyOne(basename);
46
+ if (fromBasename)
47
+ return guard(fromBasename);
48
+ return 'project';
49
+ }
50
+ function guard(slug) {
51
+ return WINDOWS_RESERVED.has(slug) ? `${slug}-project` : slug;
52
+ }
@@ -115,20 +115,22 @@ export function resolveGlobalWorktreesRoot(configuredPath) {
115
115
  return flavor.isAbsolute(expanded) ? flavor.normalize(expanded) : null;
116
116
  }
117
117
  /** Resolve the full on-disk path for a workspace worktree. */
118
- export function resolveWorkspaceWorktreePath(projectPath, workingBranch, configuredPath) {
118
+ export function resolveWorkspaceWorktreePath(projectPath, workingBranch, configuredPath, projectSlug) {
119
119
  const root = resolveWorktreesRoot(projectPath, configuredPath);
120
- return pathFlavor(projectPath, root).join(root, ...branchPathSegments(workingBranch));
120
+ const flavor = pathFlavor(projectPath, root);
121
+ const slugSegment = projectSlug && projectSlug.length > 0 ? [projectSlug] : [];
122
+ return flavor.join(root, ...slugSegment, ...branchPathSegments(workingBranch));
121
123
  }
122
124
  /** Resolve a renamed worktree next to its current path when the current path still matches its branch. */
123
- export function resolveSiblingWorkspaceWorktreePath(projectPath, worktreePath, currentBranch, nextBranch) {
125
+ export function resolveSiblingWorkspaceWorktreePath(projectPath, worktreePath, currentBranch, nextBranch, projectSlug) {
124
126
  const flavor = pathFlavor(projectPath, worktreePath);
125
127
  const normalizedWorktreePath = flavor.normalize(worktreePath);
126
- const currentBranchPath = flavor.join(...branchPathSegments(currentBranch));
127
- const currentBranchSuffix = `${flavor.sep}${currentBranchPath}`;
128
- const comparableWorktreePath = flavor === path.win32 ? normalizedWorktreePath.toLowerCase() : normalizedWorktreePath;
129
- const comparableSuffix = flavor === path.win32 ? currentBranchSuffix.toLowerCase() : currentBranchSuffix;
130
- const root = comparableWorktreePath.endsWith(comparableSuffix)
131
- ? normalizedWorktreePath.slice(0, -currentBranchSuffix.length)
128
+ const slugSegment = projectSlug && projectSlug.length > 0 ? [projectSlug] : [];
129
+ const currentSuffix = `${flavor.sep}${flavor.join(...slugSegment, ...branchPathSegments(currentBranch))}`;
130
+ const comparablePath = flavor === path.win32 ? normalizedWorktreePath.toLowerCase() : normalizedWorktreePath;
131
+ const comparableSuffix = flavor === path.win32 ? currentSuffix.toLowerCase() : currentSuffix;
132
+ const root = comparablePath.endsWith(comparableSuffix)
133
+ ? normalizedWorktreePath.slice(0, -currentSuffix.length)
132
134
  : resolveWorktreesRoot(projectPath);
133
- return flavor.join(root, ...branchPathSegments(nextBranch));
135
+ return flavor.join(root, ...slugSegment, ...branchPathSegments(nextBranch));
134
136
  }