@loicngr/kobo 1.4.5 → 1.4.7

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 (179) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +2 -2
  3. package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +37 -2
  5. package/dist/server/db/index.js +5 -0
  6. package/dist/server/db/migrations.js +9 -0
  7. package/dist/server/db/schema.js +2 -0
  8. package/dist/server/index.js +17 -18
  9. package/dist/server/routes/dev-server.js +1 -0
  10. package/dist/server/routes/git.js +1 -0
  11. package/dist/server/routes/images.js +3 -0
  12. package/dist/server/routes/notion.js +1 -0
  13. package/dist/server/routes/settings.js +1 -0
  14. package/dist/server/routes/workspaces.js +166 -51
  15. package/dist/server/services/agent-manager.js +35 -9
  16. package/dist/server/services/image-service.js +2 -1
  17. package/dist/server/services/notion-service.js +14 -18
  18. package/dist/server/services/pr-watcher-service.js +2 -0
  19. package/dist/server/services/settings-service.js +33 -6
  20. package/dist/server/services/setup-script-service.js +1 -0
  21. package/dist/server/services/websocket-service.js +8 -9
  22. package/dist/server/services/workspace-service.js +33 -2
  23. package/dist/server/services/worktree-service.js +4 -2
  24. package/dist/server/utils/git-ops.js +19 -5
  25. package/dist/server/utils/process-tracker.js +7 -0
  26. package/package.json +1 -1
  27. package/src/client/dist/spa/assets/ActivityFeed-BXrsWU-N.css +1 -0
  28. package/src/client/dist/spa/assets/ActivityFeed-Zg4aFWHr.js +68 -0
  29. package/src/client/dist/spa/assets/CreatePage-B4NEzk8h.js +2 -0
  30. package/src/client/dist/spa/assets/CreatePage-Cb9tgQ57.css +1 -0
  31. package/src/client/dist/spa/assets/DiffViewer-CSwamnmH.js +2 -0
  32. package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +1 -0
  33. package/src/client/dist/spa/assets/MainLayout-DYu8fNdb.css +1 -0
  34. package/src/client/dist/spa/assets/MainLayout-To97f8bb.js +2 -0
  35. package/src/client/dist/spa/assets/QBadge-BBHYbjmH.js +1 -0
  36. package/src/client/dist/spa/assets/QCheckbox-C3OW_sBe.js +1 -0
  37. package/src/client/dist/spa/assets/QExpansionItem--CUnMqsJ.js +1 -0
  38. package/src/client/dist/spa/assets/QPage-DfVYLit9.js +1 -0
  39. package/src/client/dist/spa/assets/QSpinnerDots-DxFX2A_Y.js +1 -0
  40. package/src/client/dist/spa/assets/QTabPanels-CQiX55Zs.js +1 -0
  41. package/src/client/dist/spa/assets/QTooltip-DDQMcKoX.js +1 -0
  42. package/src/client/dist/spa/assets/SettingsPage-CEJA3OTN.css +1 -0
  43. package/src/client/dist/spa/assets/SettingsPage-DHVfZP9g.js +1 -0
  44. package/src/client/dist/spa/assets/TouchPan-DmgViFM9.js +1 -0
  45. package/src/client/dist/spa/assets/WorkspacePage-BV77y-1V.css +1 -0
  46. package/src/client/dist/spa/assets/WorkspacePage-BfUdWLME.js +2 -0
  47. package/src/client/dist/spa/assets/_plugin-vue_export-helper-D5gezOWD.js +1 -0
  48. package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-4AF4Bwjq.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Ctxw4rqS.js} +1 -1
  50. package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-BZM2ZVF-.js} +3 -3
  51. package/src/client/dist/spa/assets/format-DhM1gNfW.js +1 -0
  52. package/src/client/dist/spa/assets/formatters-BpjOVbqs.js +6 -0
  53. package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-nlLLg2wF.js} +1 -1
  54. package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-D1p1nhG6.js} +1 -1
  55. package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-B6WFBXSs.js} +1 -1
  56. package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-DLQXzxD7.js} +1 -1
  57. package/src/client/dist/spa/assets/i18n-CrbCJQHz.js +1 -0
  58. package/src/client/dist/spa/assets/i18n-uUJIn7l1.js +1 -0
  59. package/src/client/dist/spa/assets/index-CkkvRhkB.js +5 -0
  60. package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-Cuk4WgU2.js} +1 -1
  61. package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-CvIzW7YR.js} +1 -1
  62. package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BESgd7r_.js} +1 -1
  63. package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CjeTpIfQ.js} +1 -1
  64. package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-BdsaXuzN.js} +2 -2
  65. package/src/client/dist/spa/assets/nodes-Bj1I9JfN.js +1 -0
  66. package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-DjonvvLh.js} +1 -1
  67. package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CYj_TCQ0.js} +1 -1
  68. package/src/client/dist/spa/assets/settings-DQXlzOR-.js +1 -0
  69. package/src/client/dist/spa/assets/touch-Dm5n4n0E.js +1 -0
  70. package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-C8aV_UsX.js} +1 -1
  71. package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-Clp9vIWN.js} +1 -1
  72. package/src/client/dist/spa/assets/use-checkbox-Cfet_Yar.js +1 -0
  73. package/src/client/dist/spa/assets/use-quasar-Da_Py0Ib.js +1 -0
  74. package/src/client/dist/spa/assets/vue-i18n-nv59vAyH.js +3 -0
  75. package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-6gY0RxjJ.js} +1 -1
  76. package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-IQGDhYaJ.js} +1 -1
  77. package/src/client/dist/spa/index.html +5 -4
  78. package/src/client/dist/spa/notification.mp3 +0 -0
  79. package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
  80. package/src/mcp-server/kobo-tasks-server.ts +39 -2
  81. package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
  82. package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
  83. package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
  84. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
  85. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
  86. package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
  87. package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
  88. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
  89. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
  90. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
  91. package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
  92. package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
  93. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
  94. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
  95. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
  96. package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
  97. package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
  98. package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
  99. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
  100. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
  101. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +0 -1
  102. package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
  103. package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
  104. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
  105. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
  106. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +0 -1
  107. package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
  108. package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
  109. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +0 -3
  110. /package/src/client/dist/spa/assets/{abap-Co3wj02O.js → abap-DVDKJwpW.js} +0 -0
  111. /package/src/client/dist/spa/assets/{apex-CUKwGs62.js → apex-DjF5nFAs.js} +0 -0
  112. /package/src/client/dist/spa/assets/{azcli-DMImymmY.js → azcli-HEPMaiDV.js} +0 -0
  113. /package/src/client/dist/spa/assets/{bat--P_y70-E.js → bat-D6epFECU.js} +0 -0
  114. /package/src/client/dist/spa/assets/{bicep-C3w6oSfK.js → bicep-BspG10fo.js} +0 -0
  115. /package/src/client/dist/spa/assets/{cameligo-D9NSR4Rj.js → cameligo-DM9kSiq7.js} +0 -0
  116. /package/src/client/dist/spa/assets/{clojure-BMcQme0t.js → clojure-CZn9pzfW.js} +0 -0
  117. /package/src/client/dist/spa/assets/{coffee-BbMZaWx7.js → coffee-Bu_NglwI.js} +0 -0
  118. /package/src/client/dist/spa/assets/{cpp-CbrtEGgw.js → cpp-0KJLHDue.js} +0 -0
  119. /package/src/client/dist/spa/assets/{csharp-Bc0fjUxA.js → csharp-DNzOiVZu.js} +0 -0
  120. /package/src/client/dist/spa/assets/{csp-DmbXuMT0.js → csp-CUkzJlR0.js} +0 -0
  121. /package/src/client/dist/spa/assets/{css-gdwCt5by.js → css-Uav73wXk.js} +0 -0
  122. /package/src/client/dist/spa/assets/{cypher-ocmmfoQr.js → cypher-CP6eoQBS.js} +0 -0
  123. /package/src/client/dist/spa/assets/{dart-DbZ5eklb.js → dart-Bdl32fSd.js} +0 -0
  124. /package/src/client/dist/spa/assets/{dockerfile-BLaMayDc.js → dockerfile-BIRJ7ZNM.js} +0 -0
  125. /package/src/client/dist/spa/assets/{ecl-LxXpHirr.js → ecl-Di24nx2U.js} +0 -0
  126. /package/src/client/dist/spa/assets/{elixir-C_geKt5o.js → elixir-DzFG1iYF.js} +0 -0
  127. /package/src/client/dist/spa/assets/{flow9-DE2fI2ca.js → flow9-DNgNh1TU.js} +0 -0
  128. /package/src/client/dist/spa/assets/{fsharp-CJD6fImD.js → fsharp-CuO6_Oy9.js} +0 -0
  129. /package/src/client/dist/spa/assets/{go-jUCqQ7bD.js → go-Pj7ToRvM.js} +0 -0
  130. /package/src/client/dist/spa/assets/{graphql-rw7g9h7D.js → graphql-BoaedU4s.js} +0 -0
  131. /package/src/client/dist/spa/assets/{hcl-BKX27Mn7.js → hcl-olXtyJcc.js} +0 -0
  132. /package/src/client/dist/spa/assets/{ini-CrXjga2H.js → ini-BvGNUo-D.js} +0 -0
  133. /package/src/client/dist/spa/assets/{java-D4jksGBb.js → java-Z9-7Isu7.js} +0 -0
  134. /package/src/client/dist/spa/assets/{julia-CbWxfkeS.js → julia-Bdcb8Lkm.js} +0 -0
  135. /package/src/client/dist/spa/assets/{kotlin-B26Yx80V.js → kotlin-DR_I1UW_.js} +0 -0
  136. /package/src/client/dist/spa/assets/{less-DFzn-zC9.js → less-DZxcoWKd.js} +0 -0
  137. /package/src/client/dist/spa/assets/{lexon-C-w-W8Yv.js → lexon-s17AK9YH.js} +0 -0
  138. /package/src/client/dist/spa/assets/{lua-CHuE_HoG.js → lua-BzLfjAeg.js} +0 -0
  139. /package/src/client/dist/spa/assets/{m3-DEFZN2qS.js → m3-DN3Xgolo.js} +0 -0
  140. /package/src/client/dist/spa/assets/{markdown-Cbt4TlFt.js → markdown-DCCTbSQf.js} +0 -0
  141. /package/src/client/dist/spa/assets/{mips-C6m4XECw.js → mips-lh5qv6lw.js} +0 -0
  142. /package/src/client/dist/spa/assets/{msdax-un0CFb_S.js → msdax-ikHtaqdR.js} +0 -0
  143. /package/src/client/dist/spa/assets/{mysql-CuAPeiOV.js → mysql-yyeeFEN0.js} +0 -0
  144. /package/src/client/dist/spa/assets/{objective-c-DLVMdxAC.js → objective-c-BCpwk8Ct.js} +0 -0
  145. /package/src/client/dist/spa/assets/{pascal-BGCThuPY.js → pascal-PL6H1Gn7.js} +0 -0
  146. /package/src/client/dist/spa/assets/{pascaligo-DfxSVpdo.js → pascaligo-BCtUX6M4.js} +0 -0
  147. /package/src/client/dist/spa/assets/{perl-BOE6y94t.js → perl-DuluA5AL.js} +0 -0
  148. /package/src/client/dist/spa/assets/{pgsql-Dn7JkY4F.js → pgsql-B4LkSOFV.js} +0 -0
  149. /package/src/client/dist/spa/assets/{php-r1gD0KyT.js → php-B3Ske963.js} +0 -0
  150. /package/src/client/dist/spa/assets/{pla-CgXknhb0.js → pla-B0vdKSVA.js} +0 -0
  151. /package/src/client/dist/spa/assets/{postiats-CsIEtnRB.js → postiats-lYIY9h0z.js} +0 -0
  152. /package/src/client/dist/spa/assets/{powerquery-yNJCmC_6.js → powerquery-e_CNZlRH.js} +0 -0
  153. /package/src/client/dist/spa/assets/{powershell-CQcz1SqH.js → powershell-B7ny7eNr.js} +0 -0
  154. /package/src/client/dist/spa/assets/{protobuf-BmC34uvO.js → protobuf-T5b6INIm.js} +0 -0
  155. /package/src/client/dist/spa/assets/{pug-C20znvWM.js → pug-DB5iDfTd.js} +0 -0
  156. /package/src/client/dist/spa/assets/{qsharp-B7bnARMS.js → qsharp-DwB29woK.js} +0 -0
  157. /package/src/client/dist/spa/assets/{r-ClvcLdqC.js → r-DGAconUr.js} +0 -0
  158. /package/src/client/dist/spa/assets/{redis-DCyda7_S.js → redis-CBlUxt29.js} +0 -0
  159. /package/src/client/dist/spa/assets/{redshift-BtWDr4pb.js → redshift-DUdydw8b.js} +0 -0
  160. /package/src/client/dist/spa/assets/{restructuredtext-CLcnlkhl.js → restructuredtext-CpFidj3o.js} +0 -0
  161. /package/src/client/dist/spa/assets/{ruby-DY0SOSSZ.js → ruby-DSI1pDHV.js} +0 -0
  162. /package/src/client/dist/spa/assets/{rust-JQd-fJZI.js → rust-DuAjdlB2.js} +0 -0
  163. /package/src/client/dist/spa/assets/{sb-BV2j8yFF.js → sb-DHK18yUj.js} +0 -0
  164. /package/src/client/dist/spa/assets/{scala-DwbnREDs.js → scala-OUOqN_hV.js} +0 -0
  165. /package/src/client/dist/spa/assets/{scheme-CrtA-vei.js → scheme-1CW-bJnQ.js} +0 -0
  166. /package/src/client/dist/spa/assets/{scss-VxQz3zmI.js → scss-C5o7X-EM.js} +0 -0
  167. /package/src/client/dist/spa/assets/{shell-CP9faqFI.js → shell-CtGzMV7Q.js} +0 -0
  168. /package/src/client/dist/spa/assets/{solidity-9IIb0b89.js → solidity-DPkz2VqZ.js} +0 -0
  169. /package/src/client/dist/spa/assets/{sophia-D2LQU2AD.js → sophia-nnptfdLN.js} +0 -0
  170. /package/src/client/dist/spa/assets/{sparql-DONCa5dy.js → sparql-C6bexdnc.js} +0 -0
  171. /package/src/client/dist/spa/assets/{sql-DaAAHGEt.js → sql-CcLlvkTm.js} +0 -0
  172. /package/src/client/dist/spa/assets/{st-CRY2V-j3.js → st-C7Y7CLp_.js} +0 -0
  173. /package/src/client/dist/spa/assets/{swift-BlKbfloF.js → swift-7Jj4IMNp.js} +0 -0
  174. /package/src/client/dist/spa/assets/{systemverilog-B_h9Q_T_.js → systemverilog-BBh4Cn1v.js} +0 -0
  175. /package/src/client/dist/spa/assets/{tcl-C4wN3A6M.js → tcl-BivXXY1v.js} +0 -0
  176. /package/src/client/dist/spa/assets/{twig-DDdaBLC9.js → twig-CuImSpsA.js} +0 -0
  177. /package/src/client/dist/spa/assets/{typespec-Dc1ipt8A.js → typespec-s1tz7uxH.js} +0 -0
  178. /package/src/client/dist/spa/assets/{vb-C4BXIvrh.js → vb-AURJL6ia.js} +0 -0
  179. /package/src/client/dist/spa/assets/{wgsl-XVg3Pi-r.js → wgsl-Dq1-vM4I.js} +0 -0
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
3
3
  import { getPackageVersion } from '../utils/paths.js';
4
4
  // Gherkin keywords (French and English)
5
5
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
6
- // C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
7
6
  const nextRpcId = (() => {
8
7
  let counter = 1;
9
8
  return () => counter++;
@@ -32,11 +31,7 @@ export function parseNotionUrl(url) {
32
31
  // Convert 32 hex chars to UUID format: 8-4-4-4-12
33
32
  return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
34
33
  }
35
- /**
36
- * Send a JSON-RPC request to the MCP process and read the response.
37
- * M4: parameter renamed from `process` to `mcpProcess` to avoid shadowing global `process`.
38
- * C1: 30s timeout added to prevent hanging indefinitely.
39
- */
34
+ /** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
40
35
  export async function callMcpTool(mcpProcess, toolName, args) {
41
36
  const id = nextRpcId();
42
37
  const request = JSON.stringify({
@@ -54,7 +49,6 @@ export async function callMcpTool(mcpProcess, toolName, args) {
54
49
  return;
55
50
  }
56
51
  let buffer = '';
57
- // C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
58
52
  const timeout = setTimeout(() => {
59
53
  mcpProcess.stdout?.removeListener('data', onData);
60
54
  mcpProcess.stdout?.removeListener('error', onError);
@@ -142,12 +136,7 @@ function spawnMcpProcess() {
142
136
  });
143
137
  return mcpProcess;
144
138
  }
145
- /**
146
- * Initialize the MCP server by sending an initialize request.
147
- * I1: notifications/initialized is sent after receiving the initialize response.
148
- * I4: onData listener is removed in the reject path.
149
- * C1: 10s timeout added.
150
- */
139
+ /** Initialize the MCP server by sending an initialize handshake (10s timeout). */
151
140
  async function initializeMcp(mcpProcess) {
152
141
  const id = nextRpcId();
153
142
  const request = JSON.stringify({
@@ -166,7 +155,6 @@ async function initializeMcp(mcpProcess) {
166
155
  return;
167
156
  }
168
157
  let buffer = '';
169
- // C1: 10s timeout for initialization — I7: kill the MCP process on timeout
170
158
  const timeout = setTimeout(() => {
171
159
  mcpProcess.stdout?.removeListener('data', onData);
172
160
  mcpProcess.kill();
@@ -185,7 +173,6 @@ async function initializeMcp(mcpProcess) {
185
173
  if (parsed.id === id) {
186
174
  clearTimeout(timeout);
187
175
  mcpProcess.stdout?.removeListener('data', onData);
188
- // I1: Send notifications/initialized AFTER receiving the initialize response
189
176
  const initialized = JSON.stringify({
190
177
  jsonrpc: '2.0',
191
178
  method: 'notifications/initialized',
@@ -199,7 +186,6 @@ async function initializeMcp(mcpProcess) {
199
186
  }
200
187
  }
201
188
  };
202
- // I4: onError handler to clean up listener on error
203
189
  const onError = (err) => {
204
190
  clearTimeout(timeout);
205
191
  mcpProcess.stdout?.removeListener('data', onData);
@@ -244,6 +230,7 @@ function extractTextFromRichText(richText) {
244
230
  })
245
231
  .join('');
246
232
  }
233
+ /** Parse Notion block children into structured goal, todos, and Gherkin features. */
247
234
  export function parseBlocks(blocks) {
248
235
  const todos = [];
249
236
  const gherkinFeatures = [];
@@ -336,6 +323,7 @@ export async function extractNotionPage(notionUrl) {
336
323
  const pageRaw = await callMcpTool(mcpProcess, 'API-retrieve-a-page', { page_id: pageId });
337
324
  const pageResult = unwrapMcpResult(pageRaw);
338
325
  let title = '';
326
+ let ticketId = '';
339
327
  if (pageResult && typeof pageResult === 'object') {
340
328
  const result = pageResult;
341
329
  const properties = result.properties;
@@ -344,7 +332,15 @@ export async function extractNotionPage(notionUrl) {
344
332
  const propObj = prop;
345
333
  if (propObj.type === 'title' && Array.isArray(propObj.title)) {
346
334
  title = extractTextFromRichText(propObj.title);
347
- break;
335
+ }
336
+ // Extract unique_id (e.g., "TK-1120") from Notion database properties
337
+ if (propObj.type === 'unique_id' && propObj.unique_id) {
338
+ const uid = propObj.unique_id;
339
+ const prefix = uid.prefix ?? '';
340
+ const number = uid.number;
341
+ if (number !== undefined) {
342
+ ticketId = prefix ? `${prefix}-${number}` : String(number);
343
+ }
348
344
  }
349
345
  }
350
346
  }
@@ -362,7 +358,7 @@ export async function extractNotionPage(notionUrl) {
362
358
  }
363
359
  }
364
360
  const { goal, todos, gherkinFeatures } = parseBlocks(blocks);
365
- return { title, goal, todos, gherkinFeatures };
361
+ return { title, ticketId, goal, todos, gherkinFeatures };
366
362
  }
367
363
  finally {
368
364
  // Ensure the MCP process is terminated
@@ -69,11 +69,13 @@ function scheduleNext() {
69
69
  }, POLL_INTERVAL_MS);
70
70
  timer.unref?.();
71
71
  }
72
+ /** Start polling GitHub for merged/closed PRs to auto-archive workspaces. */
72
73
  export function startPrWatcher() {
73
74
  if (timer)
74
75
  return;
75
76
  scheduleNext();
76
77
  }
78
+ /** Stop the PR watcher polling loop. */
77
79
  export function stopPrWatcher() {
78
80
  if (timer) {
79
81
  clearTimeout(timer);
@@ -89,6 +89,18 @@ const settingsMigrations = [
89
89
  }
90
90
  },
91
91
  },
92
+ {
93
+ version: 4,
94
+ name: 'add-editor-and-notifications',
95
+ migrate({ global }) {
96
+ if (typeof global.editorCommand !== 'string')
97
+ global.editorCommand = '';
98
+ if (typeof global.browserNotifications !== 'boolean')
99
+ global.browserNotifications = true;
100
+ if (typeof global.audioNotifications !== 'boolean')
101
+ global.audioNotifications = true;
102
+ },
103
+ },
92
104
  ];
93
105
  /** Current settings schema version — always equals the highest migration version. */
94
106
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -105,6 +117,9 @@ function defaultSettings() {
105
117
  dangerouslySkipPermissions: true,
106
118
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
107
119
  gitConventions: DEFAULT_GIT_CONVENTIONS,
120
+ editorCommand: '',
121
+ browserNotifications: true,
122
+ audioNotifications: true,
108
123
  },
109
124
  projects: [],
110
125
  };
@@ -128,11 +143,7 @@ function defaultProjectSettings(projectPath) {
128
143
  function pickKnownKeys(data, allowedKeys) {
129
144
  return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
130
145
  }
131
- /**
132
- * Apply migrations sequentially to bring an older settings object up to
133
- * SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
134
- * The returned object carries the bumped schemaVersion; callers persist it.
135
- */
146
+ /** Apply settings migrations sequentially up to SETTINGS_SCHEMA_VERSION. Append-only. */
136
147
  export function runSettingsMigrations(raw) {
137
148
  const current = raw;
138
149
  if (!current.global || typeof current.global !== 'object') {
@@ -190,16 +201,20 @@ function writeSettings(settings) {
190
201
  fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
191
202
  fs.renameSync(tmpPath, settingsFilePath);
192
203
  }
204
+ /** Read and return the full settings object, creating defaults if missing. */
193
205
  export function getSettings() {
194
206
  return readSettings();
195
207
  }
208
+ /** Return only the global settings section. */
196
209
  export function getGlobalSettings() {
197
210
  return readSettings().global;
198
211
  }
212
+ /** Return project-specific settings, or null if the project is not configured. */
199
213
  export function getProjectSettings(projectPath) {
200
214
  const settings = readSettings();
201
215
  return settings.projects.find((p) => p.path === projectPath) ?? null;
202
216
  }
217
+ /** Compute effective settings for a project (project overrides merged with global defaults). */
203
218
  export function getEffectiveSettings(projectPath) {
204
219
  const settings = readSettings();
205
220
  const project = settings.projects.find((p) => p.path === projectPath) ?? null;
@@ -224,14 +239,24 @@ export function getEffectiveSettings(projectPath) {
224
239
  setupScript: project.setupScript || '',
225
240
  };
226
241
  }
242
+ /** Merge partial updates into global settings and persist. */
227
243
  export function updateGlobalSettings(data) {
228
244
  const settings = readSettings();
229
- const allowedGlobalKeys = ['defaultModel', 'dangerouslySkipPermissions', 'prPromptTemplate', 'gitConventions'];
245
+ const allowedGlobalKeys = [
246
+ 'defaultModel',
247
+ 'dangerouslySkipPermissions',
248
+ 'prPromptTemplate',
249
+ 'gitConventions',
250
+ 'editorCommand',
251
+ 'browserNotifications',
252
+ 'audioNotifications',
253
+ ];
230
254
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
231
255
  settings.global = { ...settings.global, ...filtered };
232
256
  writeSettings(settings);
233
257
  return settings.global;
234
258
  }
259
+ /** Create or update project-specific settings. Merges devServer fields on update. */
235
260
  export function upsertProject(projectPath, data) {
236
261
  const allowedProjectKeys = [
237
262
  'displayName',
@@ -276,11 +301,13 @@ export function upsertProject(projectPath, data) {
276
301
  writeSettings(settings);
277
302
  return settings.projects.find((p) => p.path === projectPath);
278
303
  }
304
+ /** Remove a project from the settings file. */
279
305
  export function deleteProject(projectPath) {
280
306
  const settings = readSettings();
281
307
  settings.projects = settings.projects.filter((p) => p.path !== projectPath);
282
308
  writeSettings(settings);
283
309
  }
310
+ /** List all configured projects. */
284
311
  export function listProjects() {
285
312
  return readSettings().projects;
286
313
  }
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import * as wsService from './websocket-service.js';
5
5
  const SETUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
6
+ /** Execute a setup script in a worktree, streaming output via WebSocket. Resolves with the exit code. */
6
7
  export function runSetupScript(workspaceId, worktreePath, script, env, timeoutMs = SETUP_TIMEOUT_MS) {
7
8
  return new Promise((resolve) => {
8
9
  const scriptPath = path.join(worktreePath, '.ai', '.setup-script.tmp');
@@ -3,14 +3,16 @@ import { getDb } from '../db/index.js';
3
3
  // ── State ──────────────────────────────────────────────────────────────────────
4
4
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
5
5
  const clients = new Map();
6
- /** I6: Per-workspace emit counter for periodic cleanup */
6
+ /** Per-workspace emit counter for periodic cleanup. */
7
7
  const emitCounters = new Map();
8
8
  const EMIT_CLEANUP_THRESHOLD = 2000;
9
9
  let messageHandler = null;
10
+ /** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
10
11
  export function setMessageHandler(handler) {
11
12
  messageHandler = handler;
12
13
  }
13
14
  // ── Connection handling ────────────────────────────────────────────────────────
15
+ /** Handle a new WebSocket connection: register, dispatch messages, ping keepalive. */
14
16
  export function handleConnection(ws) {
15
17
  // Register client with empty subscription set
16
18
  clients.set(ws, new Set());
@@ -50,7 +52,6 @@ export function handleConnection(ws) {
50
52
  case 'sync:request': {
51
53
  const p = payload;
52
54
  const lastEventId = p?.lastEventId ?? '';
53
- // I2: Accept optional workspaceIds so the client can sync even before re-subscribing
54
55
  const workspaceIds = p?.workspaceIds;
55
56
  handleSyncRequest(ws, lastEventId, workspaceIds);
56
57
  break;
@@ -97,11 +98,11 @@ export function handleConnection(ws) {
97
98
  export function emit(workspaceId, type, payload, sessionId) {
98
99
  const id = nanoid();
99
100
  const createdAt = new Date().toISOString();
100
- // C3: Persist to DB best-effort only; don't let FK violation (deleted workspace) break the broadcast
101
+ // Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
101
102
  try {
102
103
  const db = getDb();
103
104
  db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
104
- // I6: Periodic cleanup — increment counter and trigger cleanup when threshold is reached
105
+ // Periodic cleanup — trigger when emit threshold is reached
105
106
  const count = (emitCounters.get(workspaceId) ?? 0) + 1;
106
107
  if (count >= EMIT_CLEANUP_THRESHOLD) {
107
108
  cleanupOldEvents(workspaceId);
@@ -142,12 +143,10 @@ export function emitEphemeral(workspaceId, type, payload) {
142
143
  }
143
144
  // ── Sync (replay missed events) ────────────────────────────────────────────────
144
145
  /**
145
- * Sends all events after lastEventId for workspaces the client is subscribed to.
146
- * I2: If workspaceIds is provided, use those instead of the client's current subscriptions
147
- * so the client can sync even before re-subscribing (e.g. after a reconnect).
146
+ * Replay all events after lastEventId for workspaces the client is subscribed to.
147
+ * If workspaceIds is provided, uses those instead of the client's current subscriptions.
148
148
  */
149
149
  export function handleSyncRequest(ws, lastEventId, workspaceIds) {
150
- // I2: Use provided workspaceIds first, fall back to current subscriptions
151
150
  const resolvedIds = workspaceIds && workspaceIds.length > 0
152
151
  ? workspaceIds
153
152
  : (() => {
@@ -171,7 +170,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
171
170
  .all(...resolvedIds, lastRow.rowid);
172
171
  }
173
172
  else {
174
- // I7: lastEventId not found — send events capped to avoid unbounded memory usage
173
+ // lastEventId not found — send events capped to avoid unbounded memory usage
175
174
  rows = db
176
175
  .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
177
176
  .all(...resolvedIds);
@@ -1,6 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
- // Valid status transitions
3
+ /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
4
4
  const VALID_TRANSITIONS = {
5
5
  created: ['extracting', 'brainstorming', 'idle', 'error'],
6
6
  extracting: ['extracting', 'brainstorming', 'idle', 'error'],
@@ -24,6 +24,7 @@ function mapWorkspace(row) {
24
24
  model: row.model,
25
25
  permissionMode: (row.permission_mode ?? 'auto-accept'),
26
26
  devServerStatus: row.dev_server_status,
27
+ hasUnread: row.has_unread === 1,
27
28
  archivedAt: row.archived_at,
28
29
  createdAt: row.created_at,
29
30
  updatedAt: row.updated_at,
@@ -41,6 +42,7 @@ function mapTask(row) {
41
42
  updatedAt: row.updated_at,
42
43
  };
43
44
  }
45
+ /** Insert a new workspace into the database and return it. */
44
46
  export function createWorkspace(data) {
45
47
  const db = getDb();
46
48
  const now = new Date().toISOString();
@@ -51,11 +53,13 @@ export function createWorkspace(data) {
51
53
  `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-6', now, now);
52
54
  return getWorkspace(id);
53
55
  }
56
+ /** Fetch a single workspace by ID, or null if not found. */
54
57
  export function getWorkspace(id) {
55
58
  const db = getDb();
56
59
  const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
57
60
  return row ? mapWorkspace(row) : null;
58
61
  }
62
+ /** List all workspaces, optionally including archived ones. Ordered by most recently updated. */
59
63
  export function listWorkspaces(includeArchived = false) {
60
64
  const db = getDb();
61
65
  const sql = includeArchived
@@ -64,6 +68,7 @@ export function listWorkspaces(includeArchived = false) {
64
68
  const rows = db.prepare(sql).all();
65
69
  return rows.map(mapWorkspace);
66
70
  }
71
+ /** List only archived workspaces, ordered by archive date descending. */
67
72
  export function listArchivedWorkspaces() {
68
73
  const db = getDb();
69
74
  const rows = db
@@ -71,6 +76,7 @@ export function listArchivedWorkspaces() {
71
76
  .all();
72
77
  return rows.map(mapWorkspace);
73
78
  }
79
+ /** Transition a workspace to a new status, validating against VALID_TRANSITIONS. */
74
80
  export function updateWorkspaceStatus(id, status) {
75
81
  const db = getDb();
76
82
  const workspace = getWorkspace(id);
@@ -85,6 +91,7 @@ export function updateWorkspaceStatus(id, status) {
85
91
  db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
86
92
  return getWorkspace(id);
87
93
  }
94
+ /** Update a workspace's display name. */
88
95
  export function updateWorkspaceName(id, name) {
89
96
  const db = getDb();
90
97
  const now = new Date().toISOString();
@@ -94,6 +101,7 @@ export function updateWorkspaceName(id, name) {
94
101
  }
95
102
  return getWorkspace(id);
96
103
  }
104
+ /** Update the Claude model used by a workspace's agent. */
97
105
  export function updateWorkspaceModel(id, model) {
98
106
  const db = getDb();
99
107
  const now = new Date().toISOString();
@@ -103,6 +111,7 @@ export function updateWorkspaceModel(id, model) {
103
111
  }
104
112
  return getWorkspace(id);
105
113
  }
114
+ /** Update the agent's permission mode (auto-accept vs plan/read-only). */
106
115
  export function updateWorkspacePermissionMode(id, permissionMode) {
107
116
  const db = getDb();
108
117
  const now = new Date().toISOString();
@@ -114,17 +123,29 @@ export function updateWorkspacePermissionMode(id, permissionMode) {
114
123
  }
115
124
  return getWorkspace(id);
116
125
  }
126
+ /** Update the dev-server status column for a workspace. */
117
127
  export function updateDevServerStatus(id, status) {
118
128
  const db = getDb();
119
129
  db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
120
130
  }
131
+ /** Mark a workspace as read (has_unread = 0). */
132
+ export function markWorkspaceRead(id) {
133
+ const db = getDb();
134
+ db.prepare('UPDATE workspaces SET has_unread = 0 WHERE id = ?').run(id);
135
+ }
136
+ /** Mark a workspace as unread (has_unread = 1). */
137
+ export function markWorkspaceUnread(id) {
138
+ const db = getDb();
139
+ db.prepare('UPDATE workspaces SET has_unread = 1 WHERE id = ?').run(id);
140
+ }
141
+ /** Delete a workspace and cascade-delete its tasks. */
121
142
  export function deleteWorkspace(id) {
122
143
  const db = getDb();
123
144
  db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
124
145
  }
146
+ /** Create a new task under a workspace. Throws if the workspace does not exist. */
125
147
  export function createTask(workspaceId, data) {
126
148
  const db = getDb();
127
- // I2: explicit workspace existence check before INSERT
128
149
  const exists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId);
129
150
  if (!exists) {
130
151
  throw new Error(`Workspace not found: '${workspaceId}'`);
@@ -138,11 +159,13 @@ export function createTask(workspaceId, data) {
138
159
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
139
160
  return mapTask(row);
140
161
  }
162
+ /** Fetch a single task by ID scoped to a workspace, or null if not found. */
141
163
  export function getTask(taskId, workspaceId) {
142
164
  const db = getDb();
143
165
  const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
144
166
  return row ? mapTask(row) : null;
145
167
  }
168
+ /** List all tasks for a workspace, ordered by sort_order ascending. */
146
169
  export function listTasks(workspaceId) {
147
170
  const db = getDb();
148
171
  const rows = db
@@ -150,6 +173,7 @@ export function listTasks(workspaceId) {
150
173
  .all(workspaceId);
151
174
  return rows.map(mapTask);
152
175
  }
176
+ /** Update a task's status (pending, in_progress, done). */
153
177
  export function updateTaskStatus(taskId, status) {
154
178
  const db = getDb();
155
179
  const now = new Date().toISOString();
@@ -160,6 +184,7 @@ export function updateTaskStatus(taskId, status) {
160
184
  }
161
185
  return mapTask(row);
162
186
  }
187
+ /** Update a task's title. Throws if the title is empty or the task does not exist. */
163
188
  export function updateTaskTitle(taskId, title) {
164
189
  if (!title?.trim()) {
165
190
  throw new Error('Task title cannot be empty');
@@ -173,10 +198,12 @@ export function updateTaskTitle(taskId, title) {
173
198
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
174
199
  return mapTask(row);
175
200
  }
201
+ /** Delete a task by ID. */
176
202
  export function deleteTask(taskId) {
177
203
  const db = getDb();
178
204
  db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
179
205
  }
206
+ /** Fetch a workspace with all its tasks eagerly loaded. */
180
207
  export function getWorkspaceWithTasks(id) {
181
208
  const workspace = getWorkspace(id);
182
209
  if (!workspace)
@@ -184,6 +211,7 @@ export function getWorkspaceWithTasks(id) {
184
211
  const tasks = listTasks(id);
185
212
  return { ...workspace, tasks };
186
213
  }
214
+ /** Archive a workspace (set archived_at). Throws if already archived. */
187
215
  export function archiveWorkspace(id) {
188
216
  const db = getDb();
189
217
  const workspace = getWorkspace(id);
@@ -197,6 +225,7 @@ export function archiveWorkspace(id) {
197
225
  db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
198
226
  return getWorkspace(id);
199
227
  }
228
+ /** Unarchive a workspace (clear archived_at), restoring its previous status. */
200
229
  export function unarchiveWorkspace(id) {
201
230
  const db = getDb();
202
231
  const workspace = getWorkspace(id);
@@ -221,6 +250,7 @@ function mapSession(row) {
221
250
  endedAt: row.ended_at,
222
251
  };
223
252
  }
253
+ /** List all agent sessions for a workspace, most recent first. */
224
254
  export function listSessions(workspaceId) {
225
255
  const db = getDb();
226
256
  const rows = db
@@ -228,6 +258,7 @@ export function listSessions(workspaceId) {
228
258
  .all(workspaceId);
229
259
  return rows.map(mapSession);
230
260
  }
261
+ /** Get the most recent agent session for a workspace, or null if none exist. */
231
262
  export function getLatestSession(workspaceId) {
232
263
  const db = getDb();
233
264
  const row = db
@@ -35,10 +35,10 @@ function removeFromExclude(projectPath, worktreePath) {
35
35
  const entry = `/${relativePath}`;
36
36
  const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
37
37
  const filtered = lines.filter((line) => line !== entry);
38
- // I3: ensure the file ends with exactly one newline and has no trailing empty lines
39
38
  const trimmed = filtered.join('\n').replace(/\n+$/, '');
40
39
  fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
41
40
  }
41
+ /** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
42
42
  export function createWorktree(projectPath, branchName, sourceBranch) {
43
43
  const worktreesDir = path.join(projectPath, '.worktrees');
44
44
  if (!fs.existsSync(worktreesDir)) {
@@ -51,7 +51,6 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
51
51
  }
52
52
  catch (err) {
53
53
  const message = err instanceof Error ? err.message : String(err);
54
- // M3: use shared utility for branch-exists detection
55
54
  // If branch already exists, add worktree without creating the branch
56
55
  if (isGitBranchExistsError(message)) {
57
56
  git(projectPath, ['worktree', 'add', worktreePath, branchName]);
@@ -63,6 +62,7 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
63
62
  addToExclude(projectPath, worktreePath);
64
63
  return worktreePath;
65
64
  }
65
+ /** Remove a git worktree and clean up the .git/info/exclude entry. */
66
66
  export function removeWorktree(projectPath, worktreePath) {
67
67
  try {
68
68
  git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
@@ -73,6 +73,7 @@ export function removeWorktree(projectPath, worktreePath) {
73
73
  }
74
74
  removeFromExclude(projectPath, worktreePath);
75
75
  }
76
+ /** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
76
77
  export function listWorktrees(projectPath) {
77
78
  const output = git(projectPath, ['worktree', 'list', '--porcelain']);
78
79
  const worktrees = [];
@@ -106,6 +107,7 @@ export function listWorktrees(projectPath) {
106
107
  }
107
108
  return worktrees;
108
109
  }
110
+ /** Check whether a worktree for the given branch already exists. */
109
111
  export function worktreeExists(projectPath, branchName) {
110
112
  try {
111
113
  const worktrees = listWorktrees(projectPath);
@@ -6,9 +6,11 @@ const execFileAsync = promisify(execFileCb);
6
6
  function git(repoPath, args) {
7
7
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
8
8
  }
9
+ /** Return the name of the currently checked-out branch. */
9
10
  export function getCurrentBranch(repoPath) {
10
11
  return git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
11
12
  }
13
+ /** List all local branch names in the repository. */
12
14
  export function listBranches(repoPath) {
13
15
  const output = git(repoPath, ['branch', '--format=%(refname:short)']);
14
16
  return output
@@ -16,20 +18,19 @@ export function listBranches(repoPath) {
16
18
  .map((b) => b.trim())
17
19
  .filter(Boolean);
18
20
  }
21
+ /** Thrown when attempting to create a branch that already exists. */
19
22
  export class BranchAlreadyExistsError extends Error {
20
23
  constructor(branchName) {
21
24
  super(`Branch '${branchName}' already exists`);
22
25
  this.name = 'BranchAlreadyExistsError';
23
26
  }
24
27
  }
25
- /**
26
- * M3: Shared utility to detect "branch already exists" git error messages
27
- * across different locales (English, French, Russian).
28
- */
28
+ /** Detect "branch already exists" git error messages across locales (EN, FR, RU). */
29
29
  export function isGitBranchExistsError(message) {
30
30
  const lower = message.toLowerCase();
31
31
  return lower.includes('already exists') || lower.includes('existe') || lower.includes('существует');
32
32
  }
33
+ /** Create a new local branch from the given source branch. */
33
34
  export function createBranch(repoPath, branchName, sourceBranch) {
34
35
  try {
35
36
  git(repoPath, ['branch', branchName, sourceBranch]);
@@ -42,6 +43,7 @@ export function createBranch(repoPath, branchName, sourceBranch) {
42
43
  throw new Error(`Failed to create branch '${branchName}' from '${sourceBranch}': ${message}`);
43
44
  }
44
45
  }
46
+ /** Return shortstat diff stats for staged (cached) changes. */
45
47
  export function getDiffStats(repoPath) {
46
48
  try {
47
49
  const output = git(repoPath, ['diff', '--cached', '--shortstat']);
@@ -64,6 +66,7 @@ function parseDiffShortstat(output) {
64
66
  deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
65
67
  };
66
68
  }
69
+ /** List remote-tracking branch names. Returns empty array on failure. */
67
70
  export function listRemoteBranches(repoPath) {
68
71
  try {
69
72
  const output = git(repoPath, ['branch', '-r', '--format=%(refname:short)']);
@@ -76,6 +79,7 @@ export function listRemoteBranches(repoPath) {
76
79
  return [];
77
80
  }
78
81
  }
82
+ /** Force-delete a local branch (`git branch -D`). */
79
83
  export function deleteLocalBranch(repoPath, branchName) {
80
84
  try {
81
85
  git(repoPath, ['branch', '-D', branchName]);
@@ -85,6 +89,7 @@ export function deleteLocalBranch(repoPath, branchName) {
85
89
  throw new Error(`Failed to delete local branch '${branchName}': ${message}`);
86
90
  }
87
91
  }
92
+ /** Delete a branch on the remote (`git push --delete`). */
88
93
  export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
89
94
  try {
90
95
  git(repoPath, ['push', remote, '--delete', branchName]);
@@ -94,6 +99,7 @@ export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
94
99
  throw new Error(`Failed to delete remote branch '${remote}/${branchName}': ${message}`);
95
100
  }
96
101
  }
102
+ /** Push a branch to the remote with upstream tracking (`git push -u`). */
97
103
  export function pushBranch(repoPath, branchName, remote = 'origin') {
98
104
  try {
99
105
  git(repoPath, ['push', '-u', remote, branchName]);
@@ -119,6 +125,7 @@ function resolveBase(repoPath, base) {
119
125
  }
120
126
  }
121
127
  }
128
+ /** Count commits between base and head (`git rev-list --count`). Returns 0 on failure. */
122
129
  export function getCommitCount(repoPath, base, head) {
123
130
  try {
124
131
  const ref = resolveBase(repoPath, base);
@@ -129,6 +136,7 @@ export function getCommitCount(repoPath, base, head) {
129
136
  return 0;
130
137
  }
131
138
  }
139
+ /** Return structured diff shortstat between two refs (three-dot merge base). */
132
140
  export function getStructuredDiffStatsBetween(repoPath, base, head) {
133
141
  try {
134
142
  const ref = resolveBase(repoPath, base);
@@ -139,6 +147,7 @@ export function getStructuredDiffStatsBetween(repoPath, base, head) {
139
147
  return { filesChanged: 0, insertions: 0, deletions: 0 };
140
148
  }
141
149
  }
150
+ /** Return a formatted list of commit subjects between base and head. */
142
151
  export function getCommitsBetween(repoPath, base, head) {
143
152
  try {
144
153
  const ref = resolveBase(repoPath, base);
@@ -148,6 +157,7 @@ export function getCommitsBetween(repoPath, base, head) {
148
157
  return '';
149
158
  }
150
159
  }
160
+ /** Get the GitHub PR URL for a branch using `gh pr view`. Returns null if no PR exists. */
151
161
  export function getPrUrl(repoPath, branchName) {
152
162
  try {
153
163
  return (execFileSync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
@@ -159,6 +169,7 @@ export function getPrUrl(repoPath, branchName) {
159
169
  return null;
160
170
  }
161
171
  }
172
+ /** Get the state and URL of the PR for a branch. Returns null if no PR exists. */
162
173
  export function getPrStatus(repoPath, branchName) {
163
174
  try {
164
175
  const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
@@ -248,6 +259,7 @@ export function getFileContent(repoPath, filePath) {
248
259
  return null;
249
260
  }
250
261
  }
262
+ /** Parse `git status --porcelain` into counts of staged, modified, and untracked files. */
251
263
  export function getWorkingTreeStatus(repoPath) {
252
264
  try {
253
265
  const output = git(repoPath, ['status', '--porcelain']);
@@ -288,6 +300,7 @@ export function getUnpushedCount(repoPath) {
288
300
  return -1; // no upstream
289
301
  }
290
302
  }
303
+ /** Return raw `git diff --shortstat` output between two refs (three-dot). */
291
304
  export function getDiffStatsBetween(repoPath, base, head) {
292
305
  try {
293
306
  return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
@@ -298,7 +311,7 @@ export function getDiffStatsBetween(repoPath, base, head) {
298
311
  }
299
312
  // ── Async versions ───────────────────────────────────────────────────────────
300
313
  // Non-blocking alternatives for hot paths (pr-watcher, route handlers).
301
- // The sync versions above are kept for callers that haven't migrated yet.
314
+ /** Async version of getPrUrl. Returns null if no PR exists. */
302
315
  export async function getPrUrlAsync(repoPath, branchName) {
303
316
  try {
304
317
  const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
@@ -311,6 +324,7 @@ export async function getPrUrlAsync(repoPath, branchName) {
311
324
  return null;
312
325
  }
313
326
  }
327
+ /** Async version of getPrStatus. Returns null if no PR exists. */
314
328
  export async function getPrStatusAsync(repoPath, branchName) {
315
329
  try {
316
330
  const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {