@bevel-software/platform-core-backend 0.26.0 → 0.28.0

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 (681) hide show
  1. package/agent-guide/access-control.md +17 -0
  2. package/agent-guide/conventions.md +8 -0
  3. package/agent-guide/html-views.md +83 -0
  4. package/agent-guide/new-knowledge-base.md +24 -0
  5. package/agent-guide/tool-manuals.md +10 -0
  6. package/dist/assets.d.ts +19 -0
  7. package/dist/assets.d.ts.map +1 -1
  8. package/dist/assets.js +26 -2
  9. package/dist/assets.js.map +1 -1
  10. package/dist/core/catalog-revision.d.ts +19 -1
  11. package/dist/core/catalog-revision.d.ts.map +1 -1
  12. package/dist/core/catalog-revision.js +14 -3
  13. package/dist/core/catalog-revision.js.map +1 -1
  14. package/dist/core/create-core-server.d.ts +5 -0
  15. package/dist/core/create-core-server.d.ts.map +1 -1
  16. package/dist/core/create-core-server.js +56 -5
  17. package/dist/core/create-core-server.js.map +1 -1
  18. package/dist/core/create-core-services.d.ts +33 -0
  19. package/dist/core/create-core-services.d.ts.map +1 -1
  20. package/dist/core/create-core-services.js +105 -8
  21. package/dist/core/create-core-services.js.map +1 -1
  22. package/dist/core/lifecycle.d.ts +16 -1
  23. package/dist/core/lifecycle.d.ts.map +1 -1
  24. package/dist/core/lifecycle.js +18 -1
  25. package/dist/core/lifecycle.js.map +1 -1
  26. package/dist/core-config.d.ts +21 -0
  27. package/dist/core-config.d.ts.map +1 -1
  28. package/dist/core-config.js +22 -1
  29. package/dist/core-config.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/modules/access/access-control.interface.d.ts +17 -0
  35. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  36. package/dist/modules/access/access-control.service.d.ts +2 -0
  37. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  38. package/dist/modules/access/access-control.service.js +31 -0
  39. package/dist/modules/access/access-control.service.js.map +1 -1
  40. package/dist/modules/access/access.routes.d.ts +4 -1
  41. package/dist/modules/access/access.routes.d.ts.map +1 -1
  42. package/dist/modules/access/access.routes.js +66 -7
  43. package/dist/modules/access/access.routes.js.map +1 -1
  44. package/dist/modules/access/directory-sync-bot.js +1 -1
  45. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  46. package/dist/modules/access/roles-admin.service.d.ts +35 -6
  47. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  48. package/dist/modules/access/roles-admin.service.js +36 -9
  49. package/dist/modules/access/roles-admin.service.js.map +1 -1
  50. package/dist/modules/access/user-access-removal.service.js +1 -1
  51. package/dist/modules/access/user-access-removal.service.js.map +1 -1
  52. package/dist/modules/agent-guide/agent-guide.d.ts +6 -0
  53. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  54. package/dist/modules/agent-guide/agent-guide.js +8 -0
  55. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  56. package/dist/modules/agent-guide/index.d.ts +1 -1
  57. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  58. package/dist/modules/agent-guide/index.js +1 -1
  59. package/dist/modules/agent-guide/index.js.map +1 -1
  60. package/dist/modules/agent-instructions/compose.d.ts +5 -0
  61. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  62. package/dist/modules/agent-instructions/compose.js +19 -2
  63. package/dist/modules/agent-instructions/compose.js.map +1 -1
  64. package/dist/modules/agent-instructions/shared-file-rules.js +19 -19
  65. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  66. package/dist/modules/audit/agent-audit.service.d.ts +21 -2
  67. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  68. package/dist/modules/audit/agent-audit.service.js +43 -2
  69. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  70. package/dist/modules/audit/agent-connection.routes.d.ts +34 -0
  71. package/dist/modules/audit/agent-connection.routes.d.ts.map +1 -0
  72. package/dist/modules/audit/agent-connection.routes.js +38 -0
  73. package/dist/modules/audit/agent-connection.routes.js.map +1 -0
  74. package/dist/modules/audit/audit.contract.d.ts +24 -0
  75. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  76. package/dist/modules/audit/audit.contract.js.map +1 -1
  77. package/dist/modules/audit/index.d.ts +1 -0
  78. package/dist/modules/audit/index.d.ts.map +1 -1
  79. package/dist/modules/audit/index.js +1 -0
  80. package/dist/modules/audit/index.js.map +1 -1
  81. package/dist/modules/auth/account-hooks.d.ts +46 -0
  82. package/dist/modules/auth/account-hooks.d.ts.map +1 -0
  83. package/dist/modules/auth/account-hooks.js +25 -0
  84. package/dist/modules/auth/account-hooks.js.map +1 -0
  85. package/dist/modules/auth/account-switch-guard.d.ts +15 -0
  86. package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
  87. package/dist/modules/auth/account-switch-guard.js +2 -0
  88. package/dist/modules/auth/account-switch-guard.js.map +1 -0
  89. package/dist/modules/auth/account-switch-lock.d.ts +34 -0
  90. package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
  91. package/dist/modules/auth/account-switch-lock.js +48 -0
  92. package/dist/modules/auth/account-switch-lock.js.map +1 -0
  93. package/dist/modules/auth/account.routes.d.ts +4 -1
  94. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  95. package/dist/modules/auth/account.routes.js +110 -13
  96. package/dist/modules/auth/account.routes.js.map +1 -1
  97. package/dist/modules/auth/auth.middleware.d.ts +7 -0
  98. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  99. package/dist/modules/auth/auth.middleware.js +1 -1
  100. package/dist/modules/auth/auth.middleware.js.map +1 -1
  101. package/dist/modules/auth/auth.service.d.ts +101 -6
  102. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  103. package/dist/modules/auth/auth.service.js +246 -51
  104. package/dist/modules/auth/auth.service.js.map +1 -1
  105. package/dist/modules/auth/password-hash.d.ts +5 -2
  106. package/dist/modules/auth/password-hash.d.ts.map +1 -1
  107. package/dist/modules/auth/password-hash.js +5 -2
  108. package/dist/modules/auth/password-hash.js.map +1 -1
  109. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  110. package/dist/modules/code-mode/code-mode.tool.js +11 -3
  111. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  112. package/dist/modules/database/core-schema.d.ts +96 -0
  113. package/dist/modules/database/core-schema.d.ts.map +1 -1
  114. package/dist/modules/database/core-schema.js +32 -3
  115. package/dist/modules/database/core-schema.js.map +1 -1
  116. package/dist/modules/database/pg-error.d.ts +9 -0
  117. package/dist/modules/database/pg-error.d.ts.map +1 -0
  118. package/dist/modules/database/pg-error.js +17 -0
  119. package/dist/modules/database/pg-error.js.map +1 -0
  120. package/dist/modules/embed/account-link.service.d.ts +42 -0
  121. package/dist/modules/embed/account-link.service.d.ts.map +1 -0
  122. package/dist/modules/embed/account-link.service.js +50 -0
  123. package/dist/modules/embed/account-link.service.js.map +1 -0
  124. package/dist/modules/embed/embed-link.d.ts +58 -0
  125. package/dist/modules/embed/embed-link.d.ts.map +1 -0
  126. package/dist/modules/embed/embed-link.js +160 -0
  127. package/dist/modules/embed/embed-link.js.map +1 -0
  128. package/dist/modules/embed/embed.errors.d.ts +30 -0
  129. package/dist/modules/embed/embed.errors.d.ts.map +1 -0
  130. package/dist/modules/embed/embed.errors.js +42 -0
  131. package/dist/modules/embed/embed.errors.js.map +1 -0
  132. package/dist/modules/embed/embed.interface.d.ts +204 -0
  133. package/dist/modules/embed/embed.interface.d.ts.map +1 -0
  134. package/dist/modules/embed/embed.interface.js +2 -0
  135. package/dist/modules/embed/embed.interface.js.map +1 -0
  136. package/dist/modules/embed/embed.routes.d.ts +59 -0
  137. package/dist/modules/embed/embed.routes.d.ts.map +1 -0
  138. package/dist/modules/embed/embed.routes.js +343 -0
  139. package/dist/modules/embed/embed.routes.js.map +1 -0
  140. package/dist/modules/embed/embed.service.d.ts +169 -0
  141. package/dist/modules/embed/embed.service.d.ts.map +1 -0
  142. package/dist/modules/embed/embed.service.js +627 -0
  143. package/dist/modules/embed/embed.service.js.map +1 -0
  144. package/dist/modules/embed/embed.tools.d.ts +51 -0
  145. package/dist/modules/embed/embed.tools.d.ts.map +1 -0
  146. package/dist/modules/embed/embed.tools.js +170 -0
  147. package/dist/modules/embed/embed.tools.js.map +1 -0
  148. package/dist/modules/embed/mcp-app.d.ts +64 -0
  149. package/dist/modules/embed/mcp-app.d.ts.map +1 -0
  150. package/dist/modules/embed/mcp-app.js +147 -0
  151. package/dist/modules/embed/mcp-app.js.map +1 -0
  152. package/dist/modules/embed/mcp-app.routes.d.ts +21 -0
  153. package/dist/modules/embed/mcp-app.routes.d.ts.map +1 -0
  154. package/dist/modules/embed/mcp-app.routes.js +36 -0
  155. package/dist/modules/embed/mcp-app.routes.js.map +1 -0
  156. package/dist/modules/github-app/github-app.connection.d.ts +15 -0
  157. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -1
  158. package/dist/modules/github-app/github-app.connection.js +34 -3
  159. package/dist/modules/github-app/github-app.connection.js.map +1 -1
  160. package/dist/modules/kb-fs/locking-filesystem.d.ts +65 -0
  161. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  162. package/dist/modules/kb-fs/locking-filesystem.js +300 -0
  163. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  164. package/dist/modules/kb-sync/kb-sync.interface.d.ts +5 -0
  165. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -1
  166. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -1
  167. package/dist/modules/kb-sync/kb-sync.service.js +6 -0
  168. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -1
  169. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  170. package/dist/modules/mcp/mcp-auth.middleware.js +2 -1
  171. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  172. package/dist/modules/mcp/mcp.service.d.ts +28 -1
  173. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  174. package/dist/modules/mcp/mcp.service.js +64 -8
  175. package/dist/modules/mcp/mcp.service.js.map +1 -1
  176. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +24 -0
  177. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  178. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +32 -4
  179. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  180. package/dist/modules/onboarding/onboarding.contract.d.ts +80 -0
  181. package/dist/modules/onboarding/onboarding.contract.d.ts.map +1 -0
  182. package/dist/modules/onboarding/onboarding.contract.js +7 -0
  183. package/dist/modules/onboarding/onboarding.contract.js.map +1 -0
  184. package/dist/modules/onboarding/onboarding.routes.d.ts +18 -0
  185. package/dist/modules/onboarding/onboarding.routes.d.ts.map +1 -0
  186. package/dist/modules/onboarding/onboarding.routes.js +81 -0
  187. package/dist/modules/onboarding/onboarding.routes.js.map +1 -0
  188. package/dist/modules/onboarding/starter-pack.service.d.ts +213 -0
  189. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -0
  190. package/dist/modules/onboarding/starter-pack.service.js +492 -0
  191. package/dist/modules/onboarding/starter-pack.service.js.map +1 -0
  192. package/dist/modules/onboarding/starter-packs.d.ts +78 -0
  193. package/dist/modules/onboarding/starter-packs.d.ts.map +1 -0
  194. package/dist/modules/onboarding/starter-packs.js +156 -0
  195. package/dist/modules/onboarding/starter-packs.js.map +1 -0
  196. package/dist/modules/plugins/index.d.ts +1 -1
  197. package/dist/modules/plugins/index.d.ts.map +1 -1
  198. package/dist/modules/plugins/index.js +1 -1
  199. package/dist/modules/plugins/index.js.map +1 -1
  200. package/dist/modules/plugins/plugin-provision.service.d.ts +19 -0
  201. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  202. package/dist/modules/plugins/plugin-provision.service.js +18 -1
  203. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  204. package/dist/modules/plugins/plugins.routes.d.ts +0 -36
  205. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  206. package/dist/modules/plugins/plugins.routes.js +31 -0
  207. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  208. package/dist/modules/settings/deployment-settings.service.d.ts +34 -0
  209. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  210. package/dist/modules/settings/deployment-settings.service.js +134 -1
  211. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  212. package/dist/modules/settings/repository-source.d.ts +1 -0
  213. package/dist/modules/settings/repository-source.d.ts.map +1 -1
  214. package/dist/modules/settings/repository-source.js +3 -3
  215. package/dist/modules/settings/repository-source.js.map +1 -1
  216. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  217. package/dist/modules/skills/skills.tools.js +50 -32
  218. package/dist/modules/skills/skills.tools.js.map +1 -1
  219. package/dist/modules/tool-auth/connection-key-rejection.d.ts +10 -1
  220. package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -1
  221. package/dist/modules/tool-auth/connection-key-rejection.js +11 -3
  222. package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -1
  223. package/dist/modules/tool-auth/external-api-key.interface.d.ts +8 -0
  224. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  225. package/dist/modules/tool-auth/external-api-key.service.d.ts +24 -2
  226. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  227. package/dist/modules/tool-auth/external-api-key.service.js +59 -2
  228. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  229. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  230. package/dist/modules/tool-auth/tool-auth.middleware.js +9 -4
  231. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  232. package/dist/modules/tool-helpers/index.d.ts +28 -1
  233. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  234. package/dist/modules/tool-helpers/index.js +28 -1
  235. package/dist/modules/tool-helpers/index.js.map +1 -1
  236. package/dist/modules/tool-helpers/route-argument-check.d.ts +10 -0
  237. package/dist/modules/tool-helpers/route-argument-check.d.ts.map +1 -0
  238. package/dist/modules/tool-helpers/route-argument-check.js +132 -0
  239. package/dist/modules/tool-helpers/route-argument-check.js.map +1 -0
  240. package/dist/modules/tool-helpers/route-tool-schemas.d.ts +89 -0
  241. package/dist/modules/tool-helpers/route-tool-schemas.d.ts.map +1 -0
  242. package/dist/modules/tool-helpers/route-tool-schemas.js +113 -0
  243. package/dist/modules/tool-helpers/route-tool-schemas.js.map +1 -0
  244. package/dist/modules/tool-helpers/tool-def.d.ts +71 -0
  245. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -1
  246. package/dist/modules/tool-helpers/tool-def.js +70 -6
  247. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  248. package/dist/modules/tool-helpers/tool-handler.d.ts +46 -6
  249. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  250. package/dist/modules/tool-helpers/tool-handler.js +157 -8
  251. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  252. package/dist/modules/tool-helpers/tool.contract.d.ts +9 -0
  253. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  254. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  255. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  256. package/dist/modules/tool-manuals/tool-manuals.tools.js +14 -5
  257. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  258. package/dist/modules/tool-registry/description-length.d.ts +6 -2
  259. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  260. package/dist/modules/tool-registry/description-length.js +27 -5
  261. package/dist/modules/tool-registry/description-length.js.map +1 -1
  262. package/dist/modules/tool-registry/guide-first.d.ts +4 -0
  263. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -1
  264. package/dist/modules/tool-registry/guide-first.js +9 -1
  265. package/dist/modules/tool-registry/guide-first.js.map +1 -1
  266. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +53 -0
  267. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  268. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +97 -68
  269. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  270. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +8 -1
  271. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  272. package/dist/modules/workflow/agent-tools/workflow.tools.js +124 -37
  273. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  274. package/dist/modules/workflow/file-lock.service.d.ts +29 -5
  275. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  276. package/dist/modules/workflow/file-lock.service.js +62 -5
  277. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  278. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  279. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  280. package/dist/modules/workflow/git/git.service.js +172 -36
  281. package/dist/modules/workflow/git/git.service.js.map +1 -1
  282. package/dist/modules/workflow/git/node-git-runner.d.ts +15 -0
  283. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  284. package/dist/modules/workflow/git/node-git-runner.js +78 -6
  285. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  286. package/dist/modules/workflow/pending-commits.service.d.ts +15 -0
  287. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  288. package/dist/modules/workflow/pending-commits.service.js +25 -1
  289. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  290. package/dist/modules/workflow/pending-commits.worker.d.ts +6 -1
  291. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  292. package/dist/modules/workflow/pending-commits.worker.js +23 -6
  293. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  294. package/dist/modules/workflow/recovery-bot.js +1 -1
  295. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  296. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  297. package/dist/modules/workflow/review-workflow/review-workflow.service.js +2 -1
  298. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  299. package/dist/modules/workflow/sanitize-error.d.ts +16 -0
  300. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
  301. package/dist/modules/workflow/sanitize-error.js +45 -0
  302. package/dist/modules/workflow/sanitize-error.js.map +1 -1
  303. package/dist/modules/workflow/workflow.service.d.ts +183 -15
  304. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  305. package/dist/modules/workflow/workflow.service.js +807 -107
  306. package/dist/modules/workflow/workflow.service.js.map +1 -1
  307. package/dist/modules/workspace/agent-download.builder.d.ts +105 -0
  308. package/dist/modules/workspace/agent-download.builder.d.ts.map +1 -0
  309. package/dist/modules/workspace/agent-download.builder.js +238 -0
  310. package/dist/modules/workspace/agent-download.builder.js.map +1 -0
  311. package/dist/modules/workspace/agent-download.routes.d.ts +77 -0
  312. package/dist/modules/workspace/agent-download.routes.d.ts.map +1 -0
  313. package/dist/modules/workspace/agent-download.routes.js +156 -0
  314. package/dist/modules/workspace/agent-download.routes.js.map +1 -0
  315. package/dist/modules/workspace/agent-download.store.d.ts +225 -0
  316. package/dist/modules/workspace/agent-download.store.d.ts.map +1 -0
  317. package/dist/modules/workspace/agent-download.store.js +423 -0
  318. package/dist/modules/workspace/agent-download.store.js.map +1 -0
  319. package/dist/modules/workspace/agent-upload.store.d.ts +10 -1
  320. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -1
  321. package/dist/modules/workspace/agent-upload.store.js +16 -6
  322. package/dist/modules/workspace/agent-upload.store.js.map +1 -1
  323. package/dist/modules/workspace/file-readers/raw-mime.d.ts +6 -0
  324. package/dist/modules/workspace/file-readers/raw-mime.d.ts.map +1 -0
  325. package/dist/modules/workspace/file-readers/raw-mime.js +38 -0
  326. package/dist/modules/workspace/file-readers/raw-mime.js.map +1 -0
  327. package/dist/modules/workspace/file-readers/text-reader.d.ts +5 -5
  328. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
  329. package/dist/modules/workspace/file-readers/text-reader.js +5 -8
  330. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
  331. package/dist/modules/workspace/first-run.d.ts +111 -0
  332. package/dist/modules/workspace/first-run.d.ts.map +1 -0
  333. package/dist/modules/workspace/first-run.js +181 -0
  334. package/dist/modules/workspace/first-run.js.map +1 -0
  335. package/dist/modules/workspace/move-links.d.ts +92 -0
  336. package/dist/modules/workspace/move-links.d.ts.map +1 -0
  337. package/dist/modules/workspace/move-links.js +173 -0
  338. package/dist/modules/workspace/move-links.js.map +1 -0
  339. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  340. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -13
  341. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  342. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  343. package/dist/modules/workspace/workspace.routes.js +63 -22
  344. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  345. package/dist/modules/workspace/workspace.service.d.ts +75 -2
  346. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  347. package/dist/modules/workspace/workspace.service.js +197 -24
  348. package/dist/modules/workspace/workspace.service.js.map +1 -1
  349. package/dist/modules/workspace/workspace.tools.d.ts +59 -1
  350. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  351. package/dist/modules/workspace/workspace.tools.js +443 -65
  352. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  353. package/dist/modules/write-access/write-access.d.ts.map +1 -1
  354. package/dist/modules/write-access/write-access.js +9 -0
  355. package/dist/modules/write-access/write-access.js.map +1 -1
  356. package/dist/shared/domain-errors.d.ts +73 -1
  357. package/dist/shared/domain-errors.d.ts.map +1 -1
  358. package/dist/shared/domain-errors.js +86 -12
  359. package/dist/shared/domain-errors.js.map +1 -1
  360. package/dist/shared/git.contract.d.ts +10 -1
  361. package/dist/shared/git.contract.d.ts.map +1 -1
  362. package/dist/shared/git.contract.js.map +1 -1
  363. package/dist/shared/utf8-text.d.ts +15 -0
  364. package/dist/shared/utf8-text.d.ts.map +1 -0
  365. package/dist/shared/utf8-text.js +20 -0
  366. package/dist/shared/utf8-text.js.map +1 -0
  367. package/dist/tenancy/static-tenant-source.d.ts +2 -1
  368. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  369. package/dist/tenancy/static-tenant-source.js +9 -2
  370. package/dist/tenancy/static-tenant-source.js.map +1 -1
  371. package/mcp-app/page.html +349 -0
  372. package/migrations/0017_atlassian_account_links.sql +37 -0
  373. package/migrations/meta/0017_snapshot.json +2396 -0
  374. package/migrations/meta/_journal.json +8 -1
  375. package/package.json +5 -3
  376. package/src/__tests__/packaged-assets-ship.test.ts +16 -4
  377. package/src/__tests__/pg-scratch.ts +48 -0
  378. package/src/assets.ts +28 -2
  379. package/src/core/__tests__/catalog-revision.test.ts +40 -0
  380. package/src/core/__tests__/gated-boot.test.ts +1 -0
  381. package/src/core/__tests__/lifecycle.test.ts +14 -0
  382. package/src/core/catalog-revision.ts +15 -2
  383. package/src/core/create-core-server.ts +69 -3
  384. package/src/core/create-core-services.ts +151 -6
  385. package/src/core/lifecycle.ts +28 -2
  386. package/src/core-config.ts +24 -1
  387. package/src/index.ts +2 -2
  388. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +1 -0
  389. package/src/modules/access/__tests__/access-control.service.test.ts +19 -0
  390. package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
  391. package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
  392. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
  393. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
  394. package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
  395. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  396. package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
  397. package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
  398. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
  399. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  400. package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
  401. package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
  402. package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
  403. package/src/modules/access/__tests__/users-db-double.ts +12 -3
  404. package/src/modules/access/access-control.interface.ts +23 -0
  405. package/src/modules/access/access-control.service.ts +35 -0
  406. package/src/modules/access/access.routes.ts +67 -6
  407. package/src/modules/access/directory-sync-bot.ts +1 -1
  408. package/src/modules/access/roles-admin.service.ts +51 -11
  409. package/src/modules/access/user-access-removal.service.ts +1 -1
  410. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +58 -0
  411. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +19 -1
  412. package/src/modules/agent-guide/agent-guide.ts +9 -0
  413. package/src/modules/agent-guide/index.ts +1 -0
  414. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +14 -0
  415. package/src/modules/agent-instructions/compose.ts +17 -2
  416. package/src/modules/agent-instructions/shared-file-rules.ts +19 -19
  417. package/src/modules/audit/__tests__/agent-audit.service.test.ts +64 -4
  418. package/src/modules/audit/__tests__/agent-connection.routes.test.ts +102 -0
  419. package/src/modules/audit/agent-audit.service.ts +58 -3
  420. package/src/modules/audit/agent-connection.routes.ts +56 -0
  421. package/src/modules/audit/audit.contract.ts +26 -0
  422. package/src/modules/audit/index.ts +1 -0
  423. package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
  424. package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
  425. package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
  426. package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
  427. package/src/modules/auth/__tests__/account.routes.test.ts +387 -9
  428. package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
  429. package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
  430. package/src/modules/auth/account-hooks.ts +69 -0
  431. package/src/modules/auth/account-switch-guard.ts +14 -0
  432. package/src/modules/auth/account-switch-lock.ts +58 -0
  433. package/src/modules/auth/account.routes.ts +122 -13
  434. package/src/modules/auth/auth.middleware.ts +1 -1
  435. package/src/modules/auth/auth.service.ts +268 -52
  436. package/src/modules/auth/password-hash.ts +5 -2
  437. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +18 -2
  438. package/src/modules/code-mode/code-mode.tool.ts +13 -2
  439. package/src/modules/database/__tests__/pg-error.test.ts +21 -0
  440. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
  441. package/src/modules/database/core-schema.ts +33 -3
  442. package/src/modules/database/pg-error.ts +15 -0
  443. package/src/modules/embed/__tests__/account-links.migration.test.ts +98 -0
  444. package/src/modules/embed/__tests__/embed-link.test.ts +122 -0
  445. package/src/modules/embed/__tests__/embed.routes.test.ts +362 -0
  446. package/src/modules/embed/__tests__/embed.service.lock.test.ts +193 -0
  447. package/src/modules/embed/__tests__/embed.service.test.ts +805 -0
  448. package/src/modules/embed/__tests__/embed.tools.test.ts +304 -0
  449. package/src/modules/embed/__tests__/mcp-app.test.ts +207 -0
  450. package/src/modules/embed/account-link.service.ts +76 -0
  451. package/src/modules/embed/embed-link.ts +180 -0
  452. package/src/modules/embed/embed.errors.ts +43 -0
  453. package/src/modules/embed/embed.interface.ts +203 -0
  454. package/src/modules/embed/embed.routes.ts +363 -0
  455. package/src/modules/embed/embed.service.ts +705 -0
  456. package/src/modules/embed/embed.tools.ts +206 -0
  457. package/src/modules/embed/mcp-app.routes.ts +41 -0
  458. package/src/modules/embed/mcp-app.ts +173 -0
  459. package/src/modules/github-app/__tests__/github-app.test.ts +76 -0
  460. package/src/modules/github-app/github-app.connection.ts +38 -4
  461. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +304 -0
  462. package/src/modules/kb-fs/locking-filesystem.ts +287 -0
  463. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +35 -0
  464. package/src/modules/kb-sync/kb-sync.interface.ts +2 -0
  465. package/src/modules/kb-sync/kb-sync.service.ts +6 -0
  466. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +68 -11
  467. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +37 -2
  468. package/src/modules/mcp/__tests__/mcp.app.e2e.test.ts +200 -0
  469. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  470. package/src/modules/mcp/__tests__/mcp.service.test.ts +23 -13
  471. package/src/modules/mcp/__tests__/tool-call-interface.e2e.test.ts +499 -0
  472. package/src/modules/mcp/mcp-auth.middleware.ts +2 -1
  473. package/src/modules/mcp/mcp.service.ts +81 -12
  474. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +42 -4
  475. package/src/modules/onboarding/__tests__/onboarding.routes.test.ts +163 -0
  476. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +635 -0
  477. package/src/modules/onboarding/__tests__/starter-packs.test.ts +169 -0
  478. package/src/modules/onboarding/onboarding.contract.ts +85 -0
  479. package/src/modules/onboarding/onboarding.routes.ts +85 -0
  480. package/src/modules/onboarding/starter-pack.service.ts +580 -0
  481. package/src/modules/onboarding/starter-packs.ts +182 -0
  482. package/src/modules/plugins/__tests__/plugins.tools.test.ts +25 -2
  483. package/src/modules/plugins/index.ts +1 -0
  484. package/src/modules/plugins/plugin-provision.service.ts +22 -1
  485. package/src/modules/plugins/plugins.routes.ts +29 -0
  486. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +150 -1
  487. package/src/modules/settings/__tests__/repository-source.test.ts +9 -0
  488. package/src/modules/settings/deployment-settings.service.ts +131 -1
  489. package/src/modules/settings/repository-source.ts +4 -4
  490. package/src/modules/skills/__tests__/skills.tools.description.test.ts +5 -1
  491. package/src/modules/skills/skills.tools.ts +55 -35
  492. package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +32 -0
  493. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +65 -0
  494. package/src/modules/tool-auth/connection-key-rejection.ts +13 -3
  495. package/src/modules/tool-auth/external-api-key.interface.ts +9 -0
  496. package/src/modules/tool-auth/external-api-key.service.ts +61 -4
  497. package/src/modules/tool-auth/tool-auth.middleware.ts +13 -4
  498. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +502 -0
  499. package/src/modules/tool-helpers/__tests__/route-argument-check.test.ts +337 -0
  500. package/src/modules/tool-helpers/index.ts +34 -1
  501. package/src/modules/tool-helpers/route-argument-check.ts +149 -0
  502. package/src/modules/tool-helpers/route-tool-schemas.ts +171 -0
  503. package/src/modules/tool-helpers/tool-def.ts +132 -7
  504. package/src/modules/tool-helpers/tool-handler.ts +196 -8
  505. package/src/modules/tool-helpers/tool.contract.ts +9 -0
  506. package/src/modules/tool-manuals/tool-manuals.tools.ts +15 -5
  507. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +14 -1
  508. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +23 -10
  509. package/src/modules/tool-registry/description-length.ts +34 -7
  510. package/src/modules/tool-registry/guide-first.ts +8 -1
  511. package/src/modules/workflow/__tests__/file-lock.service.deletion-gate.test.ts +104 -0
  512. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +66 -0
  513. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +31 -2
  514. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +110 -6
  515. package/src/modules/workflow/__tests__/sanitize-error.test.ts +89 -1
  516. package/src/modules/workflow/__tests__/workflow.routes.push-refused.test.ts +202 -0
  517. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +6 -6
  518. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +1026 -0
  519. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +468 -10
  520. package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +4 -0
  521. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +5 -4
  522. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +54 -2
  523. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +41 -5
  524. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +127 -2
  525. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +157 -122
  526. package/src/modules/workflow/agent-tools/workflow.tools.ts +129 -35
  527. package/src/modules/workflow/file-lock.service.ts +67 -5
  528. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +164 -0
  529. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +48 -1
  530. package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +231 -2
  531. package/src/modules/workflow/git/git.service.ts +176 -38
  532. package/src/modules/workflow/git/node-git-runner.ts +82 -7
  533. package/src/modules/workflow/pending-commits.service.ts +29 -1
  534. package/src/modules/workflow/pending-commits.worker.ts +24 -6
  535. package/src/modules/workflow/recovery-bot.ts +1 -1
  536. package/src/modules/workflow/review-workflow/review-workflow.service.ts +2 -1
  537. package/src/modules/workflow/sanitize-error.ts +50 -0
  538. package/src/modules/workflow/workflow.service.ts +881 -113
  539. package/src/modules/workspace/__tests__/agent-downloads.test.ts +823 -0
  540. package/src/modules/workspace/__tests__/first-run.test.ts +234 -0
  541. package/src/modules/workspace/__tests__/git-internals.security.test.ts +5 -2
  542. package/src/modules/workspace/__tests__/md-links.test.ts +353 -0
  543. package/src/modules/workspace/__tests__/move-links.test.ts +261 -0
  544. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +219 -0
  545. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +3 -0
  546. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +102 -0
  547. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +52 -5
  548. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +190 -0
  549. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +8 -4
  550. package/src/modules/workspace/__tests__/workspace.service.test.ts +50 -8
  551. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +44 -1
  552. package/src/modules/workspace/__tests__/workspace.tools.test.ts +409 -12
  553. package/src/modules/workspace/agent-download.builder.ts +308 -0
  554. package/src/modules/workspace/agent-download.routes.ts +183 -0
  555. package/src/modules/workspace/agent-download.store.ts +533 -0
  556. package/src/modules/workspace/agent-upload.store.ts +23 -7
  557. package/src/modules/workspace/file-readers/raw-mime.ts +40 -0
  558. package/src/modules/workspace/file-readers/text-reader.ts +5 -8
  559. package/src/modules/workspace/first-run.ts +222 -0
  560. package/src/modules/workspace/move-links.ts +237 -0
  561. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -12
  562. package/src/modules/workspace/workspace.routes.ts +64 -22
  563. package/src/modules/workspace/workspace.service.ts +202 -25
  564. package/src/modules/workspace/workspace.tools.ts +492 -63
  565. package/src/modules/write-access/__tests__/write-access.test.ts +13 -0
  566. package/src/modules/write-access/write-access.ts +9 -0
  567. package/src/shared/domain-errors.ts +108 -11
  568. package/src/shared/git.contract.ts +8 -1
  569. package/src/shared/utf8-text.ts +21 -0
  570. package/src/tenancy/__tests__/static-tenant-source.test.ts +1 -0
  571. package/src/tenancy/static-tenant-source.ts +10 -2
  572. package/starter-packs/README.md +103 -0
  573. package/starter-packs/engineering/KnowledgeBase/About us.md +9 -0
  574. package/starter-packs/engineering/KnowledgeBase/Architecture.md +9 -0
  575. package/starter-packs/engineering/KnowledgeBase/Glossary.md +9 -0
  576. package/starter-packs/engineering/KnowledgeBase/How we ship.md +9 -0
  577. package/starter-packs/engineering/Plugins/engineering-starter/LICENSE +21 -0
  578. package/starter-packs/engineering/Plugins/engineering-starter/SOURCE.md +20 -0
  579. package/starter-packs/engineering/Plugins/engineering-starter/access.md +19 -0
  580. package/starter-packs/engineering/Plugins/engineering-starter/plugin.json +10 -0
  581. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/SKILL.md +171 -0
  582. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/sections/phase-2a-startup-diagnostic.md +157 -0
  583. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/sections/phase-2b-builder-brainstorm.md +39 -0
  584. package/starter-packs/engineering/Plugins/engineering-starter/skills/plan-ceo-review/SKILL.md +144 -0
  585. package/starter-packs/engineering/Plugins/engineering-starter/skills/plan-eng-review/SKILL.md +144 -0
  586. package/starter-packs/engineering/Plugins/engineering-starter/skills/review/SKILL.md +80 -0
  587. package/starter-packs/engineering/Plugins/engineering-starter/skills/review/checklist.md +184 -0
  588. package/starter-packs/engineering/pack.yaml +11 -0
  589. package/starter-packs/general/KnowledgeBase/About us.md +9 -0
  590. package/starter-packs/general/KnowledgeBase/Glossary.md +9 -0
  591. package/starter-packs/general/KnowledgeBase/How we work.md +9 -0
  592. package/starter-packs/general/Plugins/general-starter/LICENSE +202 -0
  593. package/starter-packs/general/Plugins/general-starter/SOURCE.md +10 -0
  594. package/starter-packs/general/Plugins/general-starter/access.md +19 -0
  595. package/starter-packs/general/Plugins/general-starter/plugin.json +10 -0
  596. package/starter-packs/general/Plugins/general-starter/skills/memory-management/SKILL.md +332 -0
  597. package/starter-packs/general/Plugins/general-starter/skills/productivity-start/SKILL.md +163 -0
  598. package/starter-packs/general/Plugins/general-starter/skills/productivity-update/SKILL.md +167 -0
  599. package/starter-packs/general/Plugins/general-starter/skills/task-management/SKILL.md +83 -0
  600. package/starter-packs/general/pack.yaml +10 -0
  601. package/starter-packs/operations/KnowledgeBase/Glossary.md +9 -0
  602. package/starter-packs/operations/KnowledgeBase/How we work.md +9 -0
  603. package/starter-packs/operations/KnowledgeBase/Processes.md +9 -0
  604. package/starter-packs/operations/KnowledgeBase/Tools we use.md +9 -0
  605. package/starter-packs/operations/KnowledgeBase/Vendors.md +9 -0
  606. package/starter-packs/operations/Plugins/operations-starter/LICENSE +202 -0
  607. package/starter-packs/operations/Plugins/operations-starter/SOURCE.md +10 -0
  608. package/starter-packs/operations/Plugins/operations-starter/access.md +19 -0
  609. package/starter-packs/operations/Plugins/operations-starter/plugin.json +10 -0
  610. package/starter-packs/operations/Plugins/operations-starter/skills/capacity-plan/SKILL.md +111 -0
  611. package/starter-packs/operations/Plugins/operations-starter/skills/change-request/SKILL.md +119 -0
  612. package/starter-packs/operations/Plugins/operations-starter/skills/compliance-tracking/SKILL.md +44 -0
  613. package/starter-packs/operations/Plugins/operations-starter/skills/process-doc/SKILL.md +80 -0
  614. package/starter-packs/operations/Plugins/operations-starter/skills/process-optimization/SKILL.md +39 -0
  615. package/starter-packs/operations/Plugins/operations-starter/skills/risk-assessment/SKILL.md +40 -0
  616. package/starter-packs/operations/Plugins/operations-starter/skills/runbook/SKILL.md +85 -0
  617. package/starter-packs/operations/Plugins/operations-starter/skills/status-report/SKILL.md +75 -0
  618. package/starter-packs/operations/Plugins/operations-starter/skills/vendor-review/SKILL.md +102 -0
  619. package/starter-packs/operations/pack.yaml +12 -0
  620. package/starter-packs/sales/KnowledgeBase/About us.md +9 -0
  621. package/starter-packs/sales/KnowledgeBase/Customers.md +9 -0
  622. package/starter-packs/sales/KnowledgeBase/Glossary.md +9 -0
  623. package/starter-packs/sales/KnowledgeBase/How we sell.md +9 -0
  624. package/starter-packs/sales/KnowledgeBase/Objections.md +9 -0
  625. package/starter-packs/sales/KnowledgeBase/Pricing.md +9 -0
  626. package/starter-packs/sales/Plugins/sales-starter/LICENSE +202 -0
  627. package/starter-packs/sales/Plugins/sales-starter/SOURCE.md +10 -0
  628. package/starter-packs/sales/Plugins/sales-starter/access.md +19 -0
  629. package/starter-packs/sales/Plugins/sales-starter/plugin.json +10 -0
  630. package/starter-packs/sales/Plugins/sales-starter/skills/account-context/SKILL.md +94 -0
  631. package/starter-packs/sales/Plugins/sales-starter/skills/account-plan/SKILL.md +95 -0
  632. package/starter-packs/sales/Plugins/sales-starter/skills/account-research/SKILL.md +99 -0
  633. package/starter-packs/sales/Plugins/sales-starter/skills/account-tiering/SKILL.md +101 -0
  634. package/starter-packs/sales/Plugins/sales-starter/skills/call-prep/SKILL.md +111 -0
  635. package/starter-packs/sales/Plugins/sales-starter/skills/call-summary/SKILL.md +111 -0
  636. package/starter-packs/sales/Plugins/sales-starter/skills/close-plan/SKILL.md +106 -0
  637. package/starter-packs/sales/Plugins/sales-starter/skills/competitive-intelligence/SKILL.md +102 -0
  638. package/starter-packs/sales/Plugins/sales-starter/skills/create-an-asset/SKILL.md +105 -0
  639. package/starter-packs/sales/Plugins/sales-starter/skills/crm-hygiene-check/SKILL.md +103 -0
  640. package/starter-packs/sales/Plugins/sales-starter/skills/customer-health/SKILL.md +108 -0
  641. package/starter-packs/sales/Plugins/sales-starter/skills/customer-voice/SKILL.md +94 -0
  642. package/starter-packs/sales/Plugins/sales-starter/skills/daily-briefing/SKILL.md +61 -0
  643. package/starter-packs/sales/Plugins/sales-starter/skills/deal-advance-gap/SKILL.md +102 -0
  644. package/starter-packs/sales/Plugins/sales-starter/skills/deal-review/SKILL.md +105 -0
  645. package/starter-packs/sales/Plugins/sales-starter/skills/deal-signals/SKILL.md +96 -0
  646. package/starter-packs/sales/Plugins/sales-starter/skills/deal-slip-scenario/SKILL.md +104 -0
  647. package/starter-packs/sales/Plugins/sales-starter/skills/draft-outreach/SKILL.md +116 -0
  648. package/starter-packs/sales/Plugins/sales-starter/skills/end-of-day/SKILL.md +114 -0
  649. package/starter-packs/sales/Plugins/sales-starter/skills/expansion-whitespace/SKILL.md +97 -0
  650. package/starter-packs/sales/Plugins/sales-starter/skills/forecast/SKILL.md +100 -0
  651. package/starter-packs/sales/Plugins/sales-starter/skills/handle-objection/SKILL.md +93 -0
  652. package/starter-packs/sales/Plugins/sales-starter/skills/inbox-sweep/SKILL.md +114 -0
  653. package/starter-packs/sales/Plugins/sales-starter/skills/lead-triage/SKILL.md +106 -0
  654. package/starter-packs/sales/Plugins/sales-starter/skills/log-activity/SKILL.md +111 -0
  655. package/starter-packs/sales/Plugins/sales-starter/skills/pipeline-review/SKILL.md +97 -0
  656. package/starter-packs/sales/Plugins/sales-starter/skills/renewal-radar/SKILL.md +95 -0
  657. package/starter-packs/sales/Plugins/sales-starter/skills/rep-context/SKILL.md +90 -0
  658. package/starter-packs/sales/Plugins/sales-starter/skills/route-lead/SKILL.md +111 -0
  659. package/starter-packs/sales/Plugins/sales-starter/skills/sales-setup/SKILL.md +200 -0
  660. package/starter-packs/sales/Plugins/sales-starter/skills/schedule-meeting/SKILL.md +99 -0
  661. package/starter-packs/sales/Plugins/sales-starter/skills/stakeholder-map/SKILL.md +107 -0
  662. package/starter-packs/sales/Plugins/sales-starter/skills/team-pipeline/SKILL.md +105 -0
  663. package/starter-packs/sales/Plugins/sales-starter/skills/update-opportunity/SKILL.md +112 -0
  664. package/starter-packs/sales/Plugins/sales-starter/skills/weekly-wrap/SKILL.md +90 -0
  665. package/starter-packs/sales/Plugins/sales-starter/skills/win-loss-review/SKILL.md +88 -0
  666. package/starter-packs/sales/pack.yaml +13 -0
  667. package/starter-packs/support/KnowledgeBase/Escalations.md +9 -0
  668. package/starter-packs/support/KnowledgeBase/Glossary.md +9 -0
  669. package/starter-packs/support/KnowledgeBase/Known issues.md +9 -0
  670. package/starter-packs/support/KnowledgeBase/Products.md +9 -0
  671. package/starter-packs/support/KnowledgeBase/Tone of voice.md +9 -0
  672. package/starter-packs/support/Plugins/support-starter/LICENSE +202 -0
  673. package/starter-packs/support/Plugins/support-starter/SOURCE.md +10 -0
  674. package/starter-packs/support/Plugins/support-starter/access.md +19 -0
  675. package/starter-packs/support/Plugins/support-starter/plugin.json +10 -0
  676. package/starter-packs/support/Plugins/support-starter/skills/customer-escalation/SKILL.md +246 -0
  677. package/starter-packs/support/Plugins/support-starter/skills/customer-research/SKILL.md +250 -0
  678. package/starter-packs/support/Plugins/support-starter/skills/draft-response/SKILL.md +416 -0
  679. package/starter-packs/support/Plugins/support-starter/skills/kb-article/SKILL.md +350 -0
  680. package/starter-packs/support/Plugins/support-starter/skills/ticket-triage/SKILL.md +273 -0
  681. package/starter-packs/support/pack.yaml +12 -0
@@ -27,6 +27,9 @@ const syncLog = logger('sync');
27
27
  const lockLog = logger('lock');
28
28
  const crLog = logger('cr');
29
29
  const mergeLog = logger('merge');
30
+
31
+ /** Who the leftover cleanup deletes as. Its deletions skip the authorship check, and the log names them `system`. */
32
+ const SYSTEM_ACTOR: AuthUser = { id: 'system', email: 'system', name: 'system' };
30
33
  import { promises as fs } from 'node:fs';
31
34
  import { execFile } from 'node:child_process';
32
35
  import { promisify } from 'node:util';
@@ -46,6 +49,8 @@ import type {
46
49
  ChangeRequestUpdateResult,
47
50
  ChangeRequestState,
48
51
  ChangedFile,
52
+ DeleteBranchPreview,
53
+ DeleteBranchResult,
49
54
  FileApproval,
50
55
  FileLock,
51
56
  FolderChangeRequest,
@@ -66,6 +71,7 @@ import type { KbContext } from '../../shared/kb-context.js';
66
71
  import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
67
72
  import type { Database } from '../database/connection.js';
68
73
  import { changeRequests } from '../database/schema.js';
74
+ import { pgErrorCode } from '../database/pg-error.js';
69
75
  import type { GitService } from './git/git.service.js';
70
76
  import { redactSecret } from '../../shared/redact-secret.js';
71
77
  import type { PullRequestService } from './git/pull-request.service.js';
@@ -77,15 +83,20 @@ import { FileLockService } from './file-lock.service.js';
77
83
  import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
78
84
  import { PendingCommitsService } from './pending-commits.service.js';
79
85
  import type { WorkflowEventBus } from './event-bus.js';
80
- import { sanitizeError } from './sanitize-error.js';
86
+ import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
81
87
  import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
82
88
  import { WorkflowHooks } from './workflow-hooks.js';
83
89
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
90
+ import { assertValidBranchName } from '../kb-fs/branch-name.js';
84
91
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
85
92
  import {
93
+ BranchAuthorshipError,
94
+ BranchDeleteBlockedError,
95
+ BranchNotFoundError,
86
96
  ChangeRequestConflictsError,
87
97
  DuplicateChangeRequestError,
88
98
  OpenChangeRequestBlocksMergeError,
99
+ ProtectedBranchError,
89
100
  RolesYamlPreservationError,
90
101
  PullRebaseConflictError,
91
102
  PushNeedsAgentResolutionError,
@@ -119,7 +130,7 @@ function isOpenPairViolation(err: unknown): boolean {
119
130
  const fieldOf = (e: unknown, key: 'code' | 'constraint' | 'message'): unknown =>
120
131
  typeof e === 'object' && e !== null ? (e as Record<string, unknown>)[key] : undefined;
121
132
  const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
122
- const is23505 = fieldOf(err, 'code') === '23505' || fieldOf(cause, 'code') === '23505';
133
+ const is23505 = pgErrorCode(err) === '23505';
123
134
  if (!is23505) return false;
124
135
  // Prefer the structured `constraint` name; some pg drivers omit it, so fall
125
136
  // back to sniffing the named index out of the message (same approach as
@@ -484,9 +495,19 @@ export class WorkflowService implements IWorkflowService {
484
495
  // request has to be withdrawn or declined first. `systemCleanup` does
485
496
  // its own open-request check under this same lock, and the legacy
486
497
  // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
498
+ // A request that proposes nothing any more is closed first, and then
499
+ // blocks nothing. Only for a caller the deletion would go ahead for: a
500
+ // refused deletion changes nothing, a closed request included — so one
501
+ // that fails after the close reopens what it closed.
502
+ let closed: number[] = [];
487
503
  if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
488
- const open = await this.openChangeRequestOn(name);
504
+ let open = await this.openChangeRequestOn(name);
505
+ if (open !== null && !this.kb.isProtectedBranch(name) && (await this.mayDelete(workspaceId, name, user))) {
506
+ closed = await this.closeEmptyRequestsOn(name, user.id, { workspaceId, fresh: false });
507
+ open = await this.openChangeRequestOn(name);
508
+ }
489
509
  if (open !== null) {
510
+ await this.reopenAfterFailedDelete(closed, name, user.id);
490
511
  // Two different situations, two different actors. A request FROM
491
512
  // this branch is the deleter's own to withdraw; a request INTO it
492
513
  // belongs to someone else, and telling the branch owner to
@@ -501,7 +522,12 @@ export class WorkflowService implements IWorkflowService {
501
522
  );
502
523
  }
503
524
  }
504
- await this.deleteBranchUnlocked(workspaceId, name, user, opts);
525
+ try {
526
+ await this.deleteBranchUnlocked(workspaceId, name, user, opts);
527
+ } catch (err) {
528
+ await this.reopenAfterFailedDelete(closed, name, user.id);
529
+ throw err;
530
+ }
505
531
  });
506
532
  }
507
533
 
@@ -510,9 +536,15 @@ export class WorkflowService implements IWorkflowService {
510
536
  workspaceId: string,
511
537
  name: string,
512
538
  user: AuthUser,
513
- opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean },
514
- ): Promise<void> {
515
- await this.git.deleteBranch(workspaceId, name, user, opts);
539
+ opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean; expectTip?: string },
540
+ ): Promise<{ lastCommit: string | null }> {
541
+ const { lastCommit } = await this.git.deleteBranch(workspaceId, name, user, opts);
542
+ // One line per deletion, whoever asked: the commit is what a restore
543
+ // starts from, and for a deletion made in the app or by the cleanup this
544
+ // line is the only trace there is.
545
+ // By id, never by address: log lines carry no email (the release's own promise).
546
+ const actor = opts?.systemCleanup ? 'system' : user.id;
547
+ log.info(`branch deleted by ${actor}: "${name}" at ${lastCommit ?? '(no commit)'}`);
516
548
  // Retire the deleted branch's own workspace clone, best-effort. A stale
517
549
  // clone left on disk would be silently REUSED if the branch name is ever
518
550
  // recreated (the workspace bootstrap short-circuits on an existing
@@ -525,6 +557,360 @@ export class WorkflowService implements IWorkflowService {
525
557
  } catch (err) {
526
558
  log.warn(`could not retire workspace clone of deleted branch "${name}":`, { err });
527
559
  }
560
+ return { lastCommit };
561
+ }
562
+
563
+ /**
564
+ * Every open change request naming `branch`, at either end — the preview's
565
+ * list. `openChangeRequestOn` answers the first one, which is all a refusal
566
+ * needs.
567
+ */
568
+ private async openChangeRequestsOn(
569
+ branch: string,
570
+ ): Promise<{ number: number; end: 'source' | 'target' }[]> {
571
+ const rows = await this.db
572
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
573
+ .from(changeRequests)
574
+ .where(
575
+ and(
576
+ or(
577
+ eq(changeRequests.sourceBranch, branch),
578
+ eq(changeRequests.targetBranch, branch),
579
+ ),
580
+ eq(changeRequests.state, 'open'),
581
+ ),
582
+ );
583
+ return rows.map((row) => ({
584
+ number: row.number,
585
+ end: row.sourceBranch === branch ? ('source' as const) : ('target' as const),
586
+ }));
587
+ }
588
+
589
+ /**
590
+ * Whether open change request `summary` proposes nothing at this moment:
591
+ * its changes, recomputed now, are empty, and no save to its source branch
592
+ * is still landing (a save under a held lock or queued for the commit
593
+ * worker is on disk but not yet in the diff). Anything that cannot be
594
+ * determined is a no — a request is never closed on a guess.
595
+ *
596
+ * `workspaceId` is the clone to compute in (any clone will do: the diff is
597
+ * between origin's refs); absent, the source branch's own, as the app's
598
+ * lazy close always did. `fresh` says that clone was just fetched, so the
599
+ * refs are read as they are instead of fetched again per request.
600
+ */
601
+ private async proposesNothing(
602
+ summary: { number: number; branch: string; base: string },
603
+ opts: { workspaceId?: string; fresh?: boolean } = {},
604
+ ): Promise<boolean> {
605
+ try {
606
+ const wsId = opts.workspaceId ?? (await this.workspaceService.getOrCreateForBranch(summary.branch)).id;
607
+ const paths = await this.git.changedPathsForPr(
608
+ wsId,
609
+ summary.base,
610
+ summary.branch,
611
+ opts.fresh ? { fetch: false } : {},
612
+ );
613
+ if (paths.length > 0) return false;
614
+ } catch (err) {
615
+ crLog.warn(`empty-check for change request #${summary.number} failed — leaving it open:`, { err });
616
+ return false;
617
+ }
618
+ return !(await this.savesInFlight(workspaceIdForBranch(summary.branch)));
619
+ }
620
+
621
+ /**
622
+ * Close open change request `number` if it proposes nothing right now
623
+ * ({@link proposesNothing}), recorded as today's empty-close: closed state
624
+ * and closing time, guarded on `state = 'open'` so a concurrent merge or
625
+ * withdrawal wins. Writes the log line every such close owes. Does NOT
626
+ * retire the source branch — the caller decides that, and a caller holding
627
+ * that branch's lifecycle lock must not. Answers the request's target when
628
+ * this call closed it, else null.
629
+ */
630
+ private async closeIfEmpty(
631
+ number: number,
632
+ opts: { reason: string; actor: string; workspaceId?: string; fresh?: boolean },
633
+ ): Promise<{ base: string } | null> {
634
+ const summary = await this.prs.getPr(number);
635
+ if (!summary || summary.state !== 'open') return null;
636
+ if (!(await this.proposesNothing(summary, opts))) return null;
637
+ const now = new Date();
638
+ const updated = await this.db
639
+ .update(changeRequests)
640
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
641
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
642
+ .returning({ id: changeRequests.id });
643
+ if (updated.length === 0) return null;
644
+ this.prs.invalidateDetailCache(number);
645
+ this.events?.emit({ kind: 'change-request-rejected', number });
646
+ crLog.info(
647
+ `closed change request #${number} ("${summary.branch}" into "${summary.base}") by ${opts.actor}: ` +
648
+ `it proposes nothing (${opts.reason})`,
649
+ );
650
+ return { base: summary.base };
651
+ }
652
+
653
+ /**
654
+ * The deletion's close-empty move: every open request from or into
655
+ * `branch` that proposes nothing is closed. Best effort per request — one
656
+ * that cannot be judged or closed stays open, and the open-request check
657
+ * after this refuses on it. Never retires a branch: the caller holds
658
+ * `branch`'s lifecycle lock, and the deletion itself removes it. Answers
659
+ * the numbers it closed, so a deletion that then fails can reopen them
660
+ * ({@link reopenAfterFailedDelete}).
661
+ */
662
+ private async closeEmptyRequestsOn(
663
+ branch: string,
664
+ actor: string,
665
+ opts: { workspaceId?: string; fresh?: boolean },
666
+ ): Promise<number[]> {
667
+ const closed: number[] = [];
668
+ for (const cr of await this.openChangeRequestsOn(branch)) {
669
+ try {
670
+ if (await this.closeIfEmpty(cr.number, { ...opts, actor, reason: `empty when "${branch}" was deleted` })) {
671
+ closed.push(cr.number);
672
+ }
673
+ } catch (err) {
674
+ crLog.warn(`could not close empty change request #${cr.number} before deleting "${branch}":`, { err });
675
+ }
676
+ }
677
+ return closed;
678
+ }
679
+
680
+ /**
681
+ * Undo {@link closeEmptyRequestsOn} for a deletion that failed after it: a
682
+ * refused deletion changes nothing, and the requests it closed are open
683
+ * again. Still under the branch's lifecycle lock, so nothing can have
684
+ * merged, withdrawn or declined them in between; guarded on `closed` all
685
+ * the same. Best effort: a request that cannot be reopened is logged.
686
+ */
687
+ private async reopenAfterFailedDelete(numbers: number[], branch: string, actor: string): Promise<void> {
688
+ if (numbers.length === 0) return;
689
+ try {
690
+ const reopened = await this.db
691
+ .update(changeRequests)
692
+ .set({ state: 'open', closedAt: null, updatedAt: new Date() })
693
+ .where(and(inArray(changeRequests.number, numbers), eq(changeRequests.state, 'closed')))
694
+ .returning({ number: changeRequests.number });
695
+ for (const { number } of reopened) {
696
+ this.prs.invalidateDetailCache(number);
697
+ crLog.info(`reopened change request #${number} for ${actor}: deleting "${branch}" failed after it was closed`);
698
+ // The close told every tab the request was gone; announce it back,
699
+ // as an open does, so none keeps leaving it out.
700
+ const summary = await this.prs.getPr(number).catch(() => null);
701
+ if (summary) {
702
+ this.events?.emit({
703
+ kind: 'change-request-opened',
704
+ number,
705
+ source: summary.branch,
706
+ target: summary.base,
707
+ authorIdHash: summary.authorId ?? null,
708
+ title: summary.title,
709
+ });
710
+ }
711
+ }
712
+ } catch (err) {
713
+ crLog.warn(`could not reopen change requests ${numbers.map((n) => `#${n}`).join(', ')} after deleting "${branch}" failed:`, { err });
714
+ }
715
+ }
716
+
717
+ /** The author-or-Admin rule, as a yes/no; a rule that cannot be read is a no. */
718
+ private async mayDelete(workspaceId: string, name: string, user: AuthUser): Promise<boolean> {
719
+ try {
720
+ return await this.git.mayDeleteBranch(workspaceId, name, user);
721
+ } catch {
722
+ return false;
723
+ }
724
+ }
725
+
726
+ /**
727
+ * The refusal for an open request that still proposes something: names it,
728
+ * links to it, and says who can act — its author withdraws it, an Admin can
729
+ * decline it.
730
+ */
731
+ private openRequestRefusal(
732
+ branch: string,
733
+ cr: { number: number; end: 'source' | 'target'; url: string },
734
+ visible = true,
735
+ ): BranchDeleteBlockedError {
736
+ // One the caller may not see is not named, linked or placed at an end:
737
+ // `get_change_request` answers it as not found, and so does this.
738
+ if (!visible) {
739
+ return new BranchDeleteBlockedError(
740
+ 'open-change-request',
741
+ branch,
742
+ `An open change request you cannot see still proposes changes from or into "${branch}". ` +
743
+ `An Admin can decline it in the app; then the branch can be deleted.`,
744
+ );
745
+ }
746
+ return new BranchDeleteBlockedError(
747
+ 'open-change-request',
748
+ branch,
749
+ `Change request #${cr.number} (${cr.url}) is open ${cr.end === 'source' ? 'from' : 'into'} "${branch}" and still ` +
750
+ `proposes changes. Its author can withdraw it, or an Admin can decline it, in the app; then the branch can be deleted.`,
751
+ { number: cr.number, url: cr.url },
752
+ );
753
+ }
754
+
755
+ /**
756
+ * The default branch's workspace, freshly fetched — where an agent's
757
+ * deletion and the leftover cleanup both decide and act. Never the
758
+ * branch's own checkout (which git refuses to delete from), and always
759
+ * after a strict, forced fetch: the refs are about to authorize a
760
+ * deletion, and a fetch that failed proves nothing about them.
761
+ */
762
+ private async freshDefaultWorkspace(): Promise<string> {
763
+ const ws = await this.workspaceService.getOrCreateForBranch(this.kb.defaultBranch);
764
+ await this.workspaceService.ensureRemotesFetched(ws.id, { strict: true, force: true });
765
+ return ws.id;
766
+ }
767
+
768
+ async deleteBranchChecked(
769
+ user: AuthUser,
770
+ name: string,
771
+ opts: { dryRun?: boolean; discardUnmerged?: boolean; maySee?: (number: number) => Promise<boolean> } = {},
772
+ ): Promise<DeleteBranchPreview | DeleteBranchResult> {
773
+ assertValidBranchName(name);
774
+ // The caller's view of a request, as `get_change_request` gives it: one
775
+ // they may not see is never named, linked or judged in an answer. Absent,
776
+ // every request is visible. A verdict that fails is a no.
777
+ const maySee = async (number: number): Promise<boolean> =>
778
+ opts.maySee ? opts.maySee(number).catch(() => false) : true;
779
+ // Under the lock `deleteBranch`, `createBranch` and `openChangeRequest`
780
+ // take, so nothing this answer depends on — a request being opened, the
781
+ // branch being recreated — can change between the checks and the delete.
782
+ // A preview takes it too: it is short, and it then answers a settled state.
783
+ return this.branchLifecycle.run(`branch:${name}`, async () => {
784
+ const isProtected = this.kb.isProtectedBranch(name);
785
+ if (isProtected && !opts.dryRun) throw new ProtectedBranchError(name, 'deleting a protected branch');
786
+
787
+ let wsId: string;
788
+ try {
789
+ wsId = await this.freshDefaultWorkspace();
790
+ } catch (err) {
791
+ log.warn(`delete_branch "${name}": could not fetch the shared repository — deleting nothing:`, { err });
792
+ throw new BranchDeleteBlockedError(
793
+ 'state-unconfirmed',
794
+ name,
795
+ `Could not reach the shared repository to confirm the current state of "${name}", so nothing was deleted. Try again shortly.`,
796
+ );
797
+ }
798
+
799
+ const state = await this.git.branchState(wsId, name, this.kb.defaultBranch);
800
+ if (!state.exists) {
801
+ if (!opts.dryRun) throw new BranchNotFoundError(name);
802
+ return {
803
+ kind: 'preview',
804
+ branch: name,
805
+ exists: false,
806
+ canDelete: false,
807
+ refusals: [new BranchNotFoundError(name).message],
808
+ unmergedCommits: 0,
809
+ openChangeRequests: [],
810
+ lastCommit: null,
811
+ };
812
+ }
813
+
814
+ // Every refusal, in the order a deletion meets them. The preview lists
815
+ // them all; a deletion throws the first.
816
+ const refusals: Error[] = [];
817
+ if (isProtected) refusals.push(new ProtectedBranchError(name, 'deleting a protected branch'));
818
+ if (!(await this.git.mayDeleteBranch(wsId, name, user))) refusals.push(new BranchAuthorshipError(name));
819
+ // Every open request at either end, each judged now: one that proposes
820
+ // nothing is closed by the deletion and blocks nothing; one that still
821
+ // proposes something — or whose changes cannot be determined — blocks.
822
+ // A request the caller may not see is not part of a preview at all —
823
+ // not listed, not judged, not weighed in `canDelete` — so the preview
824
+ // reads the same whether one exists, and whether it is empty. A
825
+ // deletion still closes it or is refused on it like any other, without
826
+ // naming it: the rules above are the deletion's, not the caller's view.
827
+ const open: (DeleteBranchPreview['openChangeRequests'][number] & { visible: boolean })[] = [];
828
+ for (const cr of await this.openChangeRequestsOn(name)) {
829
+ const visible = await maySee(cr.number);
830
+ if (!visible && opts.dryRun) continue;
831
+ const summary = await this.prs.getPr(cr.number).catch(() => null);
832
+ const empty = summary ? await this.proposesNothing(summary, { workspaceId: wsId, fresh: true }) : false;
833
+ open.push({
834
+ ...cr,
835
+ url: summary?.url ?? `/change-requests/${cr.number}`,
836
+ proposesNothing: empty,
837
+ visible,
838
+ });
839
+ }
840
+ const blocking = open.find((cr) => !cr.proposesNothing);
841
+ if (blocking) refusals.push(this.openRequestRefusal(name, blocking, blocking.visible));
842
+ // Not coverable by `discardUnmerged`: these are a person's saves on
843
+ // their way into the branch, not work already on it.
844
+ if (await this.savesInFlight(workspaceIdForBranch(name))) {
845
+ refusals.push(this.savesLandingRefusal(name));
846
+ }
847
+ if (state.unmergedCommits > 0 && !opts.discardUnmerged) {
848
+ const n = state.unmergedCommits;
849
+ refusals.push(
850
+ new BranchDeleteBlockedError(
851
+ 'unmerged-commits',
852
+ name,
853
+ `"${name}" holds ${n} commit${n === 1 ? '' : 's'} that ${n === 1 ? 'is' : 'are'} not on ${this.kb.defaultBranch}; ` +
854
+ `deleting it would lose ${n === 1 ? 'it' : 'them'}. Pass discardUnmerged: true to delete it anyway.`,
855
+ { unmergedCommits: n },
856
+ ),
857
+ );
858
+ }
859
+
860
+ if (opts.dryRun) {
861
+ return {
862
+ kind: 'preview',
863
+ branch: name,
864
+ exists: true,
865
+ canDelete: refusals.length === 0,
866
+ refusals: refusals.map((r) => r.message),
867
+ unmergedCommits: state.unmergedCommits,
868
+ openChangeRequests: open.map(({ number, end, url, proposesNothing }) => ({ number, end, url, proposesNothing })),
869
+ lastCommit: state.lastCommit,
870
+ };
871
+ }
872
+ // A refused deletion changes nothing: no request is closed either.
873
+ if (refusals[0]) throw refusals[0];
874
+
875
+ // The saves check above is a snapshot; a save could take its file lock
876
+ // since. With no lock grantable on the branch, it is made again and
877
+ // nothing can start landing between it and the deletion.
878
+ const { lastCommit } = await this.fileLocks.whileNoneAcquired(name, async () => {
879
+ if (await this.savesInFlight(workspaceIdForBranch(name))) throw this.savesLandingRefusal(name);
880
+ // Every open request proposes nothing: close them, each re-judged at
881
+ // the moment of closing — the last step before the deletion, so no
882
+ // later check can refuse after it. Nothing here takes this branch's
883
+ // lifecycle lock, so a request whose source IS this branch cannot make
884
+ // the deletion wait on itself. Should the deletion still fail (the
885
+ // leased push below), the requests closed here are reopened.
886
+ const closed = await this.closeEmptyRequestsOn(name, user.id, { workspaceId: wsId, fresh: true });
887
+ try {
888
+ const still = (await this.openChangeRequestsOn(name))[0];
889
+ if (still) {
890
+ const summary = await this.prs.getPr(still.number).catch(() => null);
891
+ throw this.openRequestRefusal(
892
+ name,
893
+ { ...still, url: summary?.url ?? `/change-requests/${still.number}` },
894
+ await maySee(still.number),
895
+ );
896
+ }
897
+ // Leased on the tip the checks above were made against, so a commit
898
+ // pushed since is refused by the host instead of deleted unseen.
899
+ return await this.deleteBranchUnlocked(wsId, name, user, {
900
+ ...(state.lastCommit ? { expectTip: state.lastCommit } : {}),
901
+ });
902
+ } catch (err) {
903
+ await this.reopenAfterFailedDelete(closed, name, user.id);
904
+ throw err;
905
+ }
906
+ });
907
+ return {
908
+ kind: 'deleted',
909
+ branch: name,
910
+ lastCommit: lastCommit ?? state.lastCommit ?? '',
911
+ discardedCommits: state.unmergedCommits,
912
+ };
913
+ });
528
914
  }
529
915
 
530
916
  // No `switchBranch` here on purpose. Under the per-branch workspace model,
@@ -545,7 +931,7 @@ export class WorkflowService implements IWorkflowService {
545
931
  // to discard.
546
932
 
547
933
  shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
548
- return this.trackedPush(workspaceId, user);
934
+ return this.pushWithRecovery(workspaceId, branchForWorkspaceId(workspaceId), '(share)', user);
549
935
  }
550
936
 
551
937
  refreshRemotes(workspaceId: string): Promise<void> {
@@ -695,7 +1081,7 @@ export class WorkflowService implements IWorkflowService {
695
1081
  }
696
1082
  const message = sanitizeError(err);
697
1083
  syncLog.warn(`pull failed for branch "${branch}": ${message}`);
698
- this.noteGitSyncFailed(gitId, branch, err);
1084
+ this.noteGitSyncFailed(gitId, branch, err, undefined, 'pull');
699
1085
  return { branch, outcome: 'error', error: message };
700
1086
  }
701
1087
  // A clean pull is proof origin is reachable and this clone rebases onto
@@ -1097,7 +1483,8 @@ export class WorkflowService implements IWorkflowService {
1097
1483
  });
1098
1484
  } else {
1099
1485
  lockLog.info(
1100
- `ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`,
1486
+ // The holder by id only: log lines carry no names.
1487
+ `ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderUserId}`,
1101
1488
  );
1102
1489
  }
1103
1490
  return result;
@@ -1183,7 +1570,8 @@ export class WorkflowService implements IWorkflowService {
1183
1570
  let recoveryError: unknown = null;
1184
1571
  if (looksLikeNonFastForward) {
1185
1572
  try {
1186
- await this.pullWorkspace(workspaceId);
1573
+ // Same as `pushWithRecovery`: keep a stranded merge a merge.
1574
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
1187
1575
  await this.git.push(workspaceId, user);
1188
1576
  recovered = true;
1189
1577
  this.noteGitSyncOk(workspaceId, branch);
@@ -1272,7 +1660,7 @@ export class WorkflowService implements IWorkflowService {
1272
1660
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
1273
1661
  if (!lock || lock.holderUserId !== user.id) {
1274
1662
  lockLog.warn(
1275
- `RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`,
1663
+ `RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderUserId}` : 'no lock row'}`,
1276
1664
  );
1277
1665
  throw new WorkflowValidationError(
1278
1666
  `Cannot release lock on "${targetPath}": not held by you (or no longer exists).`,
@@ -1444,6 +1832,12 @@ export class WorkflowService implements IWorkflowService {
1444
1832
  * hold the sentence pass it; otherwise it is derived below.
1445
1833
  */
1446
1834
  explicitConflict?: { paths: string[]; message: string },
1835
+ /**
1836
+ * Which side of the sync failed. A 403 or "Repository not found" on a
1837
+ * pull means the credentials cannot READ the repository, and telling the
1838
+ * operator to grant push access would send them to the wrong setting.
1839
+ */
1840
+ operation: 'push' | 'pull' = 'push',
1447
1841
  ): void {
1448
1842
  // A rebase conflict is a conflict whichever path ran into it. The remote
1449
1843
  // sync names its files on purpose; the push-recovery paths (autosave, the
@@ -1466,49 +1860,28 @@ export class WorkflowService implements IWorkflowService {
1466
1860
  kind: 'git-sync-failed',
1467
1861
  workspaceId: id,
1468
1862
  branch,
1469
- // Git stderr can quote a credentialed URL; the banner is user-facing and
1470
- // the string also lands in client logs, so sanitize before it leaves.
1471
- reason: conflict ? conflict.message : sanitizeError(err),
1863
+ // Never the git output itself — not even sanitised: stderr quotes the
1864
+ // host's own error text and URLs, and the banner is user-facing. A
1865
+ // fixed sentence says what kind of failure it was; the raw text is in
1866
+ // the server log, where the banner points whoever can act on it.
1867
+ reason: conflict ? conflict.message : describeSyncFailure(err, operation),
1472
1868
  ...(conflict ? { conflictedPaths: conflict.paths } : {}),
1473
1869
  });
1474
1870
  }
1475
1871
 
1476
- /**
1477
- * A bare push wrapped in the sync tracker: a failure raises the banner, a
1478
- * success clears it. For the workflow paths that push directly — sharing a
1479
- * branch, publishing a CR's source, update-from-base, the decline revert,
1480
- * the roles.yaml restore — rather than through `pushWithRecovery`'s
1481
- * cooperative ladder. Behaviour is otherwise unchanged: the error still
1482
- * propagates to the caller exactly as the bare push's did. Without this,
1483
- * those pushes could fail invisibly (no banner) and, worse, a successful
1484
- * share could not CLEAR a banner an earlier save had raised.
1485
- *
1486
- * The branch is derived from the workspace id (they are the same string,
1487
- * URL-encoding aside) so call sites cannot pass a mismatched pair.
1488
- */
1489
- private async trackedPush(
1490
- workspaceId: string,
1491
- user: AuthUser,
1492
- opts?: { systemAuthorized?: boolean },
1493
- ): Promise<void> {
1494
- const branch = branchForWorkspaceId(workspaceId);
1495
- try {
1496
- // Preserve the exact call shape of the bare pushes this replaces — an
1497
- // explicit `undefined` third argument is a different call signature to
1498
- // every spy that asserts on it.
1499
- await (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user));
1500
- this.noteGitSyncOk(workspaceId, branch);
1501
- } catch (err) {
1502
- this.noteGitSyncFailed(workspaceId, branch, err);
1503
- throw err;
1504
- }
1505
- }
1506
-
1507
1872
  /**
1508
1873
  * Shared implementation of "push, and on non-fast-forward try a
1509
1874
  * cooperative pull-rebase + retry, then hand off to the agent if that
1510
1875
  * still fails." The full rationale lives at the call site in
1511
1876
  * `releaseLock`; this method is the de-duplicated body.
1877
+ *
1878
+ * EVERY workflow push goes through here — saves, sharing a branch, opening
1879
+ * a request, update-from-target, the decline revert, the roles.yaml
1880
+ * restore — so a push the host refuses answers one way everywhere: the
1881
+ * commit stays local, the sync banner goes up, and the caller gets the
1882
+ * 409 saved-locally sentence instead of a raw git error (which a route
1883
+ * would turn into a 500). A later successful push of the branch carries
1884
+ * the commits and clears the banner.
1512
1885
  */
1513
1886
  private async pushWithRecovery(
1514
1887
  workspaceId: string,
@@ -1517,8 +1890,12 @@ export class WorkflowService implements IWorkflowService {
1517
1890
  user: AuthUser,
1518
1891
  opts?: { systemAuthorized?: boolean },
1519
1892
  ): Promise<void> {
1893
+ // No explicit `undefined` third argument when there are no options: the
1894
+ // call shape stays the bare `push(workspaceId, user)` every caller made.
1895
+ const push = () =>
1896
+ opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user);
1520
1897
  try {
1521
- await this.git.push(workspaceId, user, opts);
1898
+ await push();
1522
1899
  this.noteGitSyncOk(workspaceId, branch);
1523
1900
  } catch (firstPushErr) {
1524
1901
  const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
@@ -1526,10 +1903,24 @@ export class WorkflowService implements IWorkflowService {
1526
1903
  let recovered = false;
1527
1904
  let recoveryDetail = '(cooperative path not attempted)';
1528
1905
  let recoveryError: unknown = null;
1906
+ // The retry push's own rejection, when the pull got that far: the
1907
+ // divergence is cured by then, so it — not the first rejection — says
1908
+ // whether the host is refusing.
1909
+ let retryDetail: string | null = null;
1529
1910
  if (looksLikeNonFastForward) {
1530
1911
  try {
1531
- await this.pullWorkspace(workspaceId);
1532
- await this.git.push(workspaceId, user, opts);
1912
+ // `preserveMerges` on every push, not only the ones that make a
1913
+ // merge: a merge stranded by a refused open or update rides along
1914
+ // with whatever push of the branch comes next — a save, a share —
1915
+ // and a plain rebase would flatten it into cherry-picks. With no
1916
+ // merge among the local commits the two spellings replay the same.
1917
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
1918
+ try {
1919
+ await push();
1920
+ } catch (retryErr) {
1921
+ retryDetail = retryErr instanceof Error ? retryErr.message : String(retryErr);
1922
+ throw retryErr;
1923
+ }
1533
1924
  recovered = true;
1534
1925
  this.noteGitSyncOk(workspaceId, branch);
1535
1926
  log.info(
@@ -1552,12 +1943,31 @@ export class WorkflowService implements IWorkflowService {
1552
1943
  // When recovery RAN, its error is the current state of the world (the
1553
1944
  // first rejection may be a stale non-fast-forward the pull already
1554
1945
  // cured); when it was skipped, the first error is all there is.
1555
- this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
1946
+ // A recovery that failed before its retry push failed in the PULL.
1947
+ const failedInPull = recoveryError !== null && retryDetail === null;
1948
+ this.noteGitSyncFailed(
1949
+ workspaceId,
1950
+ branch,
1951
+ recoveryError ?? firstPushErr,
1952
+ undefined,
1953
+ failedInPull ? 'pull' : 'push',
1954
+ );
1556
1955
  log.warn(
1557
1956
  `push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
1558
1957
  { detail: firstDetail },
1559
1958
  );
1560
- throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail);
1959
+ throw new PushNeedsAgentResolutionError(
1960
+ branch,
1961
+ targetPath,
1962
+ firstDetail,
1963
+ recoveryDetail,
1964
+ // `rejected` above also matches the host's own `[remote rejected]`
1965
+ // (an outage, a hook): worth the cooperative try, but only a real
1966
+ // divergence is described as one.
1967
+ /non-fast-forward|fetch first|updates were rejected|! \[rejected\]/i.test(retryDetail ?? firstDetail)
1968
+ ? 'diverged'
1969
+ : 'refused',
1970
+ );
1561
1971
  }
1562
1972
  }
1563
1973
  }
@@ -1877,10 +2287,24 @@ export class WorkflowService implements IWorkflowService {
1877
2287
  );
1878
2288
  }
1879
2289
 
1880
- // Push the source — `gh pr create` against an unpushed branch returns
1881
- // "head branch does not exist on remote". Push *after* the auto-merge
1882
- // so the remote sees the merge commit too.
1883
- await this.trackedPush(workspaceId, user);
2290
+ // Push the source *after* the auto-merge so the remote sees the merge
2291
+ // commit too. A push the host refuses does not undo the open: the merge
2292
+ // is committed locally, the request is a DB row, and the next push of
2293
+ // the branch carries the commits — so the row is still created below and
2294
+ // the refusal (409, the saved-locally sentence, the sync banner) is
2295
+ // answered once it exists.
2296
+ let pushRefused: PushNeedsAgentResolutionError | null = null;
2297
+ try {
2298
+ await this.pushWithRecovery(
2299
+ workspaceId,
2300
+ branchForWorkspaceId(workspaceId),
2301
+ '(opening a change request)',
2302
+ user,
2303
+ );
2304
+ } catch (err) {
2305
+ if (!(err instanceof PushNeedsAgentResolutionError)) throw err;
2306
+ pushRefused = err;
2307
+ }
1884
2308
 
1885
2309
  const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
1886
2310
  const cwd = path.join(workspacePath, this.kbDirName);
@@ -1969,6 +2393,21 @@ export class WorkflowService implements IWorkflowService {
1969
2393
  );
1970
2394
  }
1971
2395
  this.prs.invalidateDetailCache(prNumber);
2396
+ const opened = {
2397
+ kind: 'change-request-opened',
2398
+ number: prNumber,
2399
+ source: input.sourceBranch,
2400
+ target: input.targetBranch,
2401
+ authorIdHash: hashEmail(user.email),
2402
+ title: input.title,
2403
+ } as const;
2404
+ if (pushRefused) {
2405
+ // The request exists; its detail reads the published refs, which the
2406
+ // refused push never moved — answering the refusal is what the caller
2407
+ // needs, not a detail read that may fail on a branch the host lacks.
2408
+ this.events?.emit(opened);
2409
+ throw pushRefused;
2410
+ }
1972
2411
 
1973
2412
  const detail = await this.prs.getPrDetail(prNumber, {
1974
2413
  fresh: true,
@@ -1980,14 +2419,7 @@ export class WorkflowService implements IWorkflowService {
1980
2419
  `Created change request #${prNumber} but could not fetch its detail`,
1981
2420
  );
1982
2421
  }
1983
- this.events?.emit({
1984
- kind: 'change-request-opened',
1985
- number: prNumber,
1986
- source: input.sourceBranch,
1987
- target: input.targetBranch,
1988
- authorIdHash: hashEmail(user.email),
1989
- title: input.title,
1990
- });
2422
+ this.events?.emit(opened);
1991
2423
  return detail;
1992
2424
  });
1993
2425
  }
@@ -2119,7 +2551,7 @@ export class WorkflowService implements IWorkflowService {
2119
2551
  // do. Asking git what is unpushed retries it instead.
2120
2552
  //
2121
2553
  // A failed probe falls back to the old gate rather than to "push
2122
- // anyway": `trackedPush` on a clone with nothing to push is a wasted
2554
+ // anyway": a push on a clone with nothing to push is a wasted
2123
2555
  // round trip on every single update.
2124
2556
  const hasUnpushed = await this.git
2125
2557
  .hasUnpushedCommits(workspaceId)
@@ -2131,7 +2563,15 @@ export class WorkflowService implements IWorkflowService {
2131
2563
  return !outcome.alreadyUpToDate;
2132
2564
  });
2133
2565
  if (hasUnpushed) {
2134
- await this.trackedPush(workspaceId, user);
2566
+ // A refusal answers 409 with the saved-locally sentence and leaves the
2567
+ // merge applied in the clone; the probe above makes the next update
2568
+ // (or any save on the branch) push it.
2569
+ await this.pushWithRecovery(
2570
+ workspaceId,
2571
+ branchForWorkspaceId(workspaceId),
2572
+ `(update from ${detail.base})`,
2573
+ user,
2574
+ );
2135
2575
  }
2136
2576
  // Where the branch ended up, as PUBLISHED — two rev-parses, not a second
2137
2577
  // detail read. Everything below needs only the sha, and the detail that
@@ -2382,8 +2822,10 @@ export class WorkflowService implements IWorkflowService {
2382
2822
  // Best-effort freshen of the source checkout: the diff below reads origin
2383
2823
  // refs, but the restore commits from the working tree — a stale tree
2384
2824
  // would push non-fast-forward and fail loudly anyway; this just makes
2385
- // that rare.
2386
- await this.pullWorkspace(ws.id).catch(() => undefined);
2825
+ // that rare. `preserveMerges`: the branch may carry a merge a refused open
2826
+ // or update stranded locally, and a plain rebase would flatten it before
2827
+ // the revert's push publishes the branch.
2828
+ await this.pullWorkspace(ws.id, { preserveMerges: true }).catch(() => undefined);
2387
2829
 
2388
2830
  // The verb acts on the request as it is NOW — never a cached file list.
2389
2831
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
@@ -2416,6 +2858,10 @@ export class WorkflowService implements IWorkflowService {
2416
2858
  // save must not race the restore between write and commit.
2417
2859
  const held: string[] = [];
2418
2860
  let revertError: { reason: unknown } | null = null;
2861
+ // A push the host refuses leaves the revert committed on the branch
2862
+ // locally; it is answered (409, the saved-locally sentence) only after
2863
+ // the locks are released and the request's caches reflect the new head.
2864
+ let pushRefused: PushNeedsAgentResolutionError | null = null;
2419
2865
  try {
2420
2866
  for (const p of reverted) {
2421
2867
  const lockPath = `${this.kbDirName}/${p}`;
@@ -2440,7 +2886,12 @@ export class WorkflowService implements IWorkflowService {
2440
2886
  true, // skipValidator — this restores an already-validated base version
2441
2887
  );
2442
2888
  }
2443
- await this.trackedPush(ws.id, user);
2889
+ try {
2890
+ await this.pushWithRecovery(ws.id, headBranch, repoRelPath, user);
2891
+ } catch (err) {
2892
+ if (!(err instanceof PushNeedsAgentResolutionError)) throw err;
2893
+ pushRefused = err;
2894
+ }
2444
2895
  } catch (err) {
2445
2896
  revertError = { reason: err };
2446
2897
  }
@@ -2455,9 +2906,14 @@ export class WorkflowService implements IWorkflowService {
2455
2906
  if (revertError) throw revertError.reason;
2456
2907
  const failedRelease = released.find((r): r is PromiseRejectedResult => r.status === 'rejected');
2457
2908
  if (failedRelease) throw failedRelease.reason;
2909
+ this.prs.invalidateDetailCache(number);
2910
+ // A refused push moved nothing the request is judged on. A recorded
2911
+ // apply failure describes the published head, which is still where it
2912
+ // was, so it stays; and the file list reads the published refs, so
2913
+ // closing an "empty" request is decided once the push lands.
2914
+ if (pushRefused) throw pushRefused;
2458
2915
  // The source head moved: the refusal described a revision that is gone.
2459
2916
  await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
2460
- this.prs.invalidateDetailCache(number);
2461
2917
 
2462
2918
  const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
2463
2919
  if (remaining.length > 0) {
@@ -2598,7 +3054,9 @@ export class WorkflowService implements IWorkflowService {
2598
3054
  if (!summary || summary.state !== 'open') continue;
2599
3055
  const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2600
3056
  try {
2601
- await this.pullWorkspace(ws.id);
3057
+ // `preserveMerges`: a merge a refused open or update stranded on the
3058
+ // request's branch must survive until the branch's next push.
3059
+ await this.pullWorkspace(ws.id, { preserveMerges: true });
2602
3060
  } catch (err) {
2603
3061
  log.warn(
2604
3062
  `pull failed for #${summary.number} before removing folder ${printable(folder)}: ${printable(sanitizeError(err))}`,
@@ -2902,33 +3360,9 @@ export class WorkflowService implements IWorkflowService {
2902
3360
  * Returns true when THIS call closed it.
2903
3361
  */
2904
3362
  async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
2905
- const summary = await this.prs.getPr(number);
2906
- if (!summary || summary.state !== 'open') return false;
2907
- let paths: string[];
2908
- let wsId: string;
2909
- try {
2910
- const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2911
- wsId = ws.id;
2912
- paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
2913
- } catch (err) {
2914
- crLog.warn(`empty-check for change request #${number} failed — leaving it open:`, { err });
2915
- return false;
2916
- }
2917
- if (paths.length > 0) return false;
2918
- if (await this.savesInFlight(wsId)) return false;
2919
-
2920
- // Guard on `state = 'open'` so a concurrent merge or withdraw wins the
2921
- // race and this becomes a no-op.
2922
- const now = new Date();
2923
- const updated = await this.db
2924
- .update(changeRequests)
2925
- .set({ state: 'closed', closedAt: now, updatedAt: now })
2926
- .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
2927
- .returning({ id: changeRequests.id });
2928
- if (updated.length === 0) return false;
2929
- this.prs.invalidateDetailCache(number);
2930
- this.events?.emit({ kind: 'change-request-rejected', number });
2931
- await this.retireMergedSourceBranch(number, summary.base, user);
3363
+ const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.email });
3364
+ if (!closed) return false;
3365
+ await this.retireMergedSourceBranch(number, closed.base, user);
2932
3366
  return true;
2933
3367
  }
2934
3368
 
@@ -2938,6 +3372,14 @@ export class WorkflowService implements IWorkflowService {
2938
3372
  * (their file is still only on disk). An answer that cannot be read is a
2939
3373
  * yes — the caller is about to delete the checkout.
2940
3374
  */
3375
+ private savesLandingRefusal(name: string): BranchDeleteBlockedError {
3376
+ return new BranchDeleteBlockedError(
3377
+ 'saves-landing',
3378
+ name,
3379
+ `"${name}" still has saves waiting to be committed or a file held for editing. Try again once they have landed.`,
3380
+ );
3381
+ }
3382
+
2941
3383
  private async savesInFlight(wsId: string): Promise<boolean> {
2942
3384
  try {
2943
3385
  return (await this.fileLocks.hasAnyActive(wsId)) || (await this.pendingCommits.hasAnyForWorkspace(wsId));
@@ -3173,11 +3615,233 @@ export class WorkflowService implements IWorkflowService {
3173
3615
  }
3174
3616
  })
3175
3617
  .catch((err) => crLog.warn('on-demand deleted-branch sweep failed:', { err }))
3618
+ // Every sweep is followed by the tidy-up, which never throws.
3619
+ .then(() => this.tidyAfterSweep())
3620
+ .then(() => undefined)
3176
3621
  .finally(() => {
3177
3622
  this.sweepKick = null;
3178
3623
  });
3179
3624
  }
3180
3625
 
3626
+ private tidyRound: Promise<{ closedEmpty: number; removedLeftovers: number }> | null = null;
3627
+ private tidyStopped = false;
3628
+
3629
+ /**
3630
+ * Stop the tidy-up when the graph stops: no round starts after this, and a
3631
+ * round under way stops at its next request or branch. Then
3632
+ * {@link drainTidy} waits for it, so it cannot fetch or delete once the
3633
+ * pool is ended or a replacement graph owns the same checkout.
3634
+ */
3635
+ stopTidying(): void {
3636
+ this.tidyStopped = true;
3637
+ }
3638
+
3639
+ /** Settles once no tidy-up (nor the sweep that leads into one) is running. */
3640
+ async drainTidy(): Promise<void> {
3641
+ await Promise.allSettled([this.sweepKick, this.tidyRound]);
3642
+ }
3643
+
3644
+ /**
3645
+ * What follows every sweep for deleted branches — at startup, on a remote
3646
+ * sync, after a merge and on demand: first the open requests that propose
3647
+ * nothing are closed and their source branch removed
3648
+ * ({@link closeEmptyOpenChangeRequests}), then the branches merged requests
3649
+ * left behind ({@link retireLeftoverMergedBranches}, behind its own
3650
+ * setting). In that order, because closing an empty request is what can
3651
+ * free a branch for the second. Never throws; coalesced.
3652
+ */
3653
+ tidyAfterSweep(): Promise<{ closedEmpty: number; removedLeftovers: number }> {
3654
+ if (this.tidyRound) return this.tidyRound;
3655
+ if (this.tidyStopped) return Promise.resolve({ closedEmpty: 0, removedLeftovers: 0 });
3656
+ this.tidyRound = (async () => {
3657
+ const closedEmpty = await this.closeEmptyOpenChangeRequests().catch((err: unknown) => {
3658
+ crLog.warn('background close of empty change requests failed:', { err });
3659
+ return 0;
3660
+ });
3661
+ const removedLeftovers = await this.retireLeftoverMergedBranches();
3662
+ return { closedEmpty, removedLeftovers };
3663
+ })().finally(() => {
3664
+ this.tidyRound = null;
3665
+ });
3666
+ return this.tidyRound;
3667
+ }
3668
+
3669
+ /**
3670
+ * Close every open change request that proposes nothing, and remove its
3671
+ * source branch — what opening such a request's page in the app does
3672
+ * (`closeEmptyChangeRequest`), done without anyone opening it. Each is
3673
+ * judged from the default branch's workspace against origin's refs, after
3674
+ * ONE strict fetch, so no request's branch is cloned for the question; a
3675
+ * round whose fetch fails closes nothing. A request whose changes cannot be
3676
+ * determined, or with a save still landing on its branch, stays open.
3677
+ * Returns how many it closed.
3678
+ *
3679
+ * The source branch is removed under the same guards as any other
3680
+ * deletion: under its lifecycle lock, only with no request open from or
3681
+ * into it, with no save landing and no lock grantable on it at that moment,
3682
+ * and leased on the tip the request was judged empty at — a commit pushed
3683
+ * since is refused by the host instead of deleted unseen.
3684
+ */
3685
+ async closeEmptyOpenChangeRequests(): Promise<number> {
3686
+ const open = await this.db
3687
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
3688
+ .from(changeRequests)
3689
+ .where(eq(changeRequests.state, 'open'));
3690
+ if (open.length === 0) return 0;
3691
+ let wsId: string;
3692
+ try {
3693
+ wsId = await this.freshDefaultWorkspace();
3694
+ } catch (err) {
3695
+ crLog.warn('background close of empty change requests skipped — could not fetch the shared repository:', { err });
3696
+ return 0;
3697
+ }
3698
+ let closed = 0;
3699
+ for (const { number, sourceBranch } of open) {
3700
+ if (this.tidyStopped) break;
3701
+ try {
3702
+ // Read before the request is judged, so the lease below covers
3703
+ // anything that reached the branch after the judgement.
3704
+ const tip = this.kb.isProtectedBranch(sourceBranch)
3705
+ ? null
3706
+ : (await this.git.branchState(wsId, sourceBranch, this.kb.defaultBranch)).lastCommit;
3707
+ const done = await this.closeIfEmpty(number, {
3708
+ workspaceId: wsId,
3709
+ fresh: true,
3710
+ actor: 'system',
3711
+ reason: 'empty in the background sweep',
3712
+ });
3713
+ if (!done) continue;
3714
+ closed++;
3715
+ if (tip) await this.retireEmptySourceBranch(wsId, sourceBranch, tip);
3716
+ } catch (err) {
3717
+ crLog.warn(`background close of change request #${number} failed — leaving it open:`, { err });
3718
+ }
3719
+ }
3720
+ return closed;
3721
+ }
3722
+
3723
+ /**
3724
+ * Remove the source branch of a request the background sweep just closed
3725
+ * as empty, from the default branch's freshly fetched workspace `wsId`.
3726
+ * Skips quietly when another request is open from or into it or a save is
3727
+ * landing on it; the delete is leased on `tip`. Never throws.
3728
+ */
3729
+ private async retireEmptySourceBranch(wsId: string, branch: string, tip: string): Promise<void> {
3730
+ try {
3731
+ await this.branchLifecycle.run(`branch:${branch}`, async () => {
3732
+ if ((await this.openChangeRequestOn(branch)) !== null) return;
3733
+ await this.fileLocks.whileNoneAcquired(branch, async () => {
3734
+ if (await this.savesInFlight(workspaceIdForBranch(branch))) {
3735
+ crLog.info(`kept "${branch}" after closing its empty change request: a save is still landing on it`);
3736
+ return;
3737
+ }
3738
+ await this.deleteBranchUnlocked(wsId, branch, SYSTEM_ACTOR, { systemCleanup: true, expectTip: tip });
3739
+ });
3740
+ });
3741
+ } catch (err) {
3742
+ crLog.warn(`could not remove "${branch}" after closing its empty change request (non-fatal):`, { err });
3743
+ }
3744
+ }
3745
+
3746
+ /**
3747
+ * Whether the leftover cleanup below runs — the `retireMergedBranches`
3748
+ * deployment setting, read at every round so the Deployment page applies
3749
+ * without a restart. Set by the composition root; on by default.
3750
+ */
3751
+ leftoverCleanupEnabled: () => boolean = () => true;
3752
+
3753
+ private leftoverRound: Promise<number> | null = null;
3754
+
3755
+ /**
3756
+ * Remove the branches merged change requests left behind: sources of a
3757
+ * merge from before the post-merge retirement existed (core-backend 0.5.0),
3758
+ * and ones whose retirement failed. What `retireMergedSourceBranch` does
3759
+ * after a merge, applied to every merge on record.
3760
+ *
3761
+ * A branch goes only when ALL hold: it is not protected; it was the source
3762
+ * of a merged request; it has no commit that is not on the default branch;
3763
+ * no request is open from or into it; its tip is contained in the merge
3764
+ * commit recorded for one of those requests — a branch recreated under the
3765
+ * same name after the merge is somebody's new draft, and a request with no
3766
+ * recorded merge commit proves nothing; and its checkout has no saves
3767
+ * landing and no file held. Each is decided and deleted under the branch's
3768
+ * lifecycle lock, like the post-merge retirement.
3769
+ *
3770
+ * Fails SAFE, like the deleted-branch sweep it runs beside: a round whose
3771
+ * fetch fails removes nothing, and a branch whose state cannot be read is
3772
+ * left. The switch covers this round only; the post-merge retirement is not
3773
+ * asked. Never throws. Coalesced, because boot, the remote sync and the
3774
+ * on-demand sweep can all start one. Returns how many it removed.
3775
+ */
3776
+ retireLeftoverMergedBranches(): Promise<number> {
3777
+ if (this.leftoverRound) return this.leftoverRound;
3778
+ this.leftoverRound = this.retireLeftoverMergedBranchesOnce()
3779
+ .catch((err) => {
3780
+ log.warn('leftover branch cleanup failed:', { err });
3781
+ return 0;
3782
+ })
3783
+ .finally(() => {
3784
+ this.leftoverRound = null;
3785
+ });
3786
+ return this.leftoverRound;
3787
+ }
3788
+
3789
+ private async retireLeftoverMergedBranchesOnce(): Promise<number> {
3790
+ if (!this.leftoverCleanupEnabled()) return 0;
3791
+ const merged = await this.db
3792
+ .select({ sourceBranch: changeRequests.sourceBranch, mergedSha: changeRequests.mergedSha })
3793
+ .from(changeRequests)
3794
+ .where(eq(changeRequests.state, 'merged'));
3795
+ const mergeCommits = new Map<string, string[]>();
3796
+ for (const row of merged) {
3797
+ if (this.kb.isProtectedBranch(row.sourceBranch)) continue;
3798
+ const shas = mergeCommits.get(row.sourceBranch) ?? [];
3799
+ if (row.mergedSha) shas.push(row.mergedSha);
3800
+ mergeCommits.set(row.sourceBranch, shas);
3801
+ }
3802
+ if (mergeCommits.size === 0) return 0;
3803
+
3804
+ let wsId: string;
3805
+ try {
3806
+ wsId = await this.freshDefaultWorkspace();
3807
+ } catch (err) {
3808
+ log.warn('leftover branch cleanup skipped — could not fetch the shared repository:', { err });
3809
+ return 0;
3810
+ }
3811
+
3812
+ let removed = 0;
3813
+ for (const [branch, shas] of mergeCommits) {
3814
+ if (this.tidyStopped) break;
3815
+ if (shas.length === 0) continue;
3816
+ try {
3817
+ const done = await this.branchLifecycle.run(`branch:${branch}`, async () => {
3818
+ const state = await this.git.branchState(wsId, branch, this.kb.defaultBranch);
3819
+ if (!state.exists || state.lastCommit === null || state.unmergedCommits > 0) return false;
3820
+ if ((await this.openChangeRequestOn(branch)) !== null) return false;
3821
+ let contained = false;
3822
+ for (const sha of shas) {
3823
+ if (await this.git.isAncestor(wsId, state.lastCommit, sha).catch(() => false)) {
3824
+ contained = true;
3825
+ break;
3826
+ }
3827
+ }
3828
+ if (!contained) return false;
3829
+ const tip = state.lastCommit;
3830
+ return this.fileLocks.whileNoneAcquired(branch, async () => {
3831
+ if (await this.savesInFlight(workspaceIdForBranch(branch))) return false;
3832
+ await this.deleteBranchUnlocked(wsId, branch, SYSTEM_ACTOR, { systemCleanup: true, expectTip: tip });
3833
+ return true;
3834
+ });
3835
+ });
3836
+ if (done) removed++;
3837
+ } catch (err) {
3838
+ log.warn(`leftover branch cleanup could not remove "${branch}":`, { err });
3839
+ }
3840
+ }
3841
+ if (removed > 0) log.info(`removed ${removed} branch${removed === 1 ? '' : 'es'} left over from merged change requests`);
3842
+ return removed;
3843
+ }
3844
+
3181
3845
 
3182
3846
  /**
3183
3847
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
@@ -3353,6 +4017,9 @@ export class WorkflowService implements IWorkflowService {
3353
4017
  // AFTER the event: the applying UI is waiting on `change-request-merged`,
3354
4018
  // and branch retirement is git IO it must never wait behind.
3355
4019
  await this.retireMergedSourceBranch(number, baseBranch, user);
4020
+ // A merge can empty OTHER open requests into the same target; the sweep's
4021
+ // tidy-up closes those. Detached and coalesced, like every kick.
4022
+ this.kickDeletedBranchSweep();
3356
4023
  return { kind: 'merged', result };
3357
4024
  }
3358
4025
 
@@ -3421,11 +4088,92 @@ export class WorkflowService implements IWorkflowService {
3421
4088
  }
3422
4089
  }
3423
4090
 
4091
+ /**
4092
+ * How long `mergeBranch` waits for the source's queued commits, and how
4093
+ * often it asks. The worker polls every half second and a commit takes a few
4094
+ * seconds; twenty seconds of waiting is an outage, not latency.
4095
+ *
4096
+ * Twenty, not thirty, because of who is waiting on THIS call: an agent over
4097
+ * `/api/mcp` reaches the tool through `@utcp/http`, whose request timeout is
4098
+ * a fixed 30 seconds (its call-template schema drops any `timeout` field).
4099
+ * The wait plus the merge after it (fetch, merge, push) must fit inside
4100
+ * that, or the agent hears "timeout of 30000ms exceeded" instead of the
4101
+ * `pending-commits` answer this wait exists to give. Mutable for tests only.
4102
+ */
4103
+ mergeWaitForPendingCommits = { timeoutMs: 20_000, pollMs: 250 };
4104
+
4105
+ /**
4106
+ * Wait until no commit is queued on `branch`, or the bound runs out. A
4107
+ * commit escalated to a person ends the wait at once: nothing will land it.
4108
+ *
4109
+ * Polls the queue rather than draining it: the worker's own per-workspace
4110
+ * serialization stays the only writer to the clone, and the row is deleted
4111
+ * only after its push, so "no row" means the remote holds the commit.
4112
+ */
4113
+ private async awaitPendingCommits(
4114
+ branch: string,
4115
+ ): Promise<{ ok: true } | { ok: false; pending: number; needsAttention?: string }> {
4116
+ const { timeoutMs, pollMs } = this.mergeWaitForPendingCommits;
4117
+ const deadline = Date.now() + timeoutMs;
4118
+ for (;;) {
4119
+ const { queued, stuck, needsAttention } = await this.pendingCommits.queuedOnBranch(branch);
4120
+ if (needsAttention !== null) return { ok: false, pending: queued + stuck, needsAttention };
4121
+ if (queued === 0) return { ok: true };
4122
+ if (Date.now() >= deadline) return { ok: false, pending: queued };
4123
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
4124
+ }
4125
+ }
4126
+
4127
+ /** The one question `mergeBranch` refuses on: is a request from `source` into `target` open? */
4128
+ private async openRequestBlockingMerge(sourceBranch: string, targetBranch: string): Promise<number | null> {
4129
+ const [open] = await this.db
4130
+ .select({ number: changeRequests.number })
4131
+ .from(changeRequests)
4132
+ .where(
4133
+ and(
4134
+ eq(changeRequests.sourceBranch, sourceBranch),
4135
+ eq(changeRequests.targetBranch, targetBranch),
4136
+ eq(changeRequests.state, 'open'),
4137
+ ),
4138
+ )
4139
+ .limit(1);
4140
+ return open ? open.number : null;
4141
+ }
4142
+
3424
4143
  async mergeBranch(user: AuthUser, sourceBranch: string, targetBranch: string): Promise<MergeBranchOutcome> {
3425
4144
  if (sourceBranch === targetBranch) {
3426
4145
  throw new WorkflowValidationError(`\`source\` and \`target\` are both "${sourceBranch}" — name two different branches.`);
3427
4146
  }
3428
4147
 
4148
+ // Asked once BEFORE waiting, so a merge the open request forbids is refused
4149
+ // at once rather than after the source's commits have landed. Not the
4150
+ // answer that counts — that one is asked again under the locks below.
4151
+ const openBefore = await this.openRequestBlockingMerge(sourceBranch, targetBranch);
4152
+ if (openBefore !== null) throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, openBefore);
4153
+
4154
+ // Writes are committed asynchronously: a write tool answers once the bytes
4155
+ // are on disk and the pending-commits worker commits and pushes them a few
4156
+ // seconds later. The merge below merges what the REMOTE holds of the
4157
+ // source, so issued in those seconds it would find nothing new and land
4158
+ // nothing. Wait for them first — OUTSIDE the lifecycle locks, which guard
4159
+ // creating, deleting and opening requests on these branches, and must not
4160
+ // be held for seconds of waiting on a queue.
4161
+ const settled = await this.awaitPendingCommits(sourceBranch);
4162
+ if (!settled.ok) {
4163
+ const message = settled.needsAttention
4164
+ ? `A commit queued on "${sourceBranch}" failed and needs a person: ${settled.needsAttention} ` +
4165
+ `Nothing was merged. Tell the user; merge again once it has been resolved.`
4166
+ : `${settled.pending} write(s) on "${sourceBranch}" are still being committed. ` +
4167
+ `Nothing was merged. Retry the merge shortly.`;
4168
+ return {
4169
+ kind: 'pending-commits',
4170
+ branch: sourceBranch,
4171
+ pending: settled.pending,
4172
+ ...(settled.needsAttention ? { needsAttention: settled.needsAttention } : {}),
4173
+ message,
4174
+ };
4175
+ }
4176
+
3429
4177
  // Under the lifecycle lock of BOTH branches, the same protocol
3430
4178
  // `openChangeRequest` follows — and for the same window. That method holds
3431
4179
  // these keys across its whole check-then-insert (an auto-merge and a push,
@@ -3439,18 +4187,8 @@ export class WorkflowService implements IWorkflowService {
3439
4187
  // A person merges a change request. Only the request's OWN direction is
3440
4188
  // blocked: the target merged into the source is how a draft under review
3441
4189
  // stays current, and that must keep working while the request is open.
3442
- const [open] = await this.db
3443
- .select({ number: changeRequests.number })
3444
- .from(changeRequests)
3445
- .where(
3446
- and(
3447
- eq(changeRequests.sourceBranch, sourceBranch),
3448
- eq(changeRequests.targetBranch, targetBranch),
3449
- eq(changeRequests.state, 'open'),
3450
- ),
3451
- )
3452
- .limit(1);
3453
- if (open) throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, open.number);
4190
+ const open = await this.openRequestBlockingMerge(sourceBranch, targetBranch);
4191
+ if (open !== null) throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, open);
3454
4192
 
3455
4193
  // The merge runs in the target's own workspace, like a change request's.
3456
4194
  const targetWorkspaceId = (await this.workspaceService.getOrCreateForBranch(targetBranch)).id;
@@ -3530,6 +4268,12 @@ export class WorkflowService implements IWorkflowService {
3530
4268
  return { kind: 'conflicts-need-resolution', conflictedPaths: result.paths };
3531
4269
  }
3532
4270
  await this.pullMergeTarget(targetBranch, user);
4271
+ // No merge commit: the target already held everything on the source, and
4272
+ // `sha` is its unchanged tip. Reported as such — a `merged` that applied
4273
+ // nothing is what sent agents on as if their write had landed.
4274
+ if (result.mergeCommit === null) return { kind: 'nothing-to-merge', sha: result.sha };
4275
+ // What landed on the target may have emptied open requests into it.
4276
+ this.kickDeletedBranchSweep();
3533
4277
  return { kind: 'merged', sha: result.sha };
3534
4278
  });
3535
4279
  }
@@ -3812,8 +4556,9 @@ export class WorkflowService implements IWorkflowService {
3812
4556
  // sweep those unrelated edits into our "preserve roles.yaml" commit under the
3813
4557
  // wrong author/message. Feature branch → the protected-branch gate doesn't
3814
4558
  // fire, so this commits cleanly. Then publish so the merge sees the
3815
- // neutralised source. A non-fast-forward push (the source advanced under us)
3816
- // throws → RolesYamlPreservationError → the merge aborts, fail-closed.
4559
+ // neutralised source. A push that still fails after the cooperative
4560
+ // pull-rebase throws PushNeedsAgentResolutionError → the merge aborts,
4561
+ // fail-closed, with the restore kept locally for the next push.
3817
4562
  const committed = await this.git.commitFile(
3818
4563
  ws.id,
3819
4564
  user,
@@ -3829,12 +4574,29 @@ export class WorkflowService implements IWorkflowService {
3829
4574
  // with origin/<head>), then origin/<head> STILL carries the divergent
3830
4575
  // roles.yaml and the merge would land it. Treat that as a hard failure —
3831
4576
  // returning false here would fail OPEN. Fail closed instead.
3832
- if (!committed) {
4577
+ //
4578
+ // One shape of "nothing to commit" is not out of sync: a restore an
4579
+ // earlier merge attempt committed, whose push the host refused. The
4580
+ // clone's HEAD already carries the base version — that is why there
4581
+ // was nothing new to commit — and only the push is owed. Without this,
4582
+ // the retry the refusal promises could never succeed: every attempt
4583
+ // would find the file already restored and stop here, and the merge
4584
+ // would stay refused until some unrelated save pushed the branch.
4585
+ //
4586
+ // The question is NOT "does the clone hold unpushed commits" — any
4587
+ // queued save answers yes. It is whether HEAD already holds all of
4588
+ // origin/<head>, so that the push fast-forwards origin to a head whose
4589
+ // roles.yaml is the base version. A clone BEHIND origin would have
4590
+ // origin's divergent copy rebased into HEAD by the cooperative pull on
4591
+ // the way to the push, and the merge would land it: that clone is the
4592
+ // out-of-sync one this guard refuses. A probe that cannot answer
4593
+ // throws, and the guard fails closed with it.
4594
+ if (!committed && !(await this.git.headContainsOrigin(ws.id))) {
3833
4595
  throw new Error(
3834
4596
  'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
3835
4597
  );
3836
4598
  }
3837
- await this.trackedPush(ws.id, user);
4599
+ await this.pushWithRecovery(ws.id, headBranch, ROLES_YAML, user);
3838
4600
  this.accessControl.invalidate(ws.id);
3839
4601
  return true;
3840
4602
  } finally {
@@ -3843,6 +4605,12 @@ export class WorkflowService implements IWorkflowService {
3843
4605
  await this.fileLocks.release(ws.id, headBranch, rolesLockPath, user);
3844
4606
  }
3845
4607
  } catch (err) {
4608
+ // A refused push: the restore is committed on the source locally and the
4609
+ // merge does not run (origin still carries the divergent roles.yaml).
4610
+ // Answer it like every other refused push — 409, the saved-locally
4611
+ // sentence, the banner already raised — so the merge can be retried
4612
+ // once the host accepts the push.
4613
+ if (err instanceof PushNeedsAgentResolutionError) throw err;
3846
4614
  const detail = err instanceof Error ? err.message : String(err);
3847
4615
  // Log the raw git/push detail server-side; the thrown error keeps it OFF
3848
4616
  // the client-facing 502 message (see RolesYamlPreservationError).