@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
@@ -25,20 +25,24 @@ const syncLog = logger('sync');
25
25
  const lockLog = logger('lock');
26
26
  const crLog = logger('cr');
27
27
  const mergeLog = logger('merge');
28
+ /** Who the leftover cleanup deletes as. Its deletions skip the authorship check, and the log names them `system`. */
29
+ const SYSTEM_ACTOR = { id: 'system', email: 'system', name: 'system' };
28
30
  import { promises as fs } from 'node:fs';
29
31
  import { execFile } from 'node:child_process';
30
32
  import { promisify } from 'node:util';
31
33
  import { isFolderPlaceholder, folderPlaceholderPath, isPlatformRestoreShape, } from '@bevel-software/platform-shared';
32
34
  import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
33
35
  import { changeRequests } from '../database/schema.js';
36
+ import { pgErrorCode } from '../database/pg-error.js';
34
37
  import { redactSecret } from '../../shared/redact-secret.js';
35
38
  import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
36
39
  import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
37
- import { sanitizeError } from './sanitize-error.js';
40
+ import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
38
41
  import { WorkflowHooks } from './workflow-hooks.js';
39
42
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
43
+ import { assertValidBranchName } from '../kb-fs/branch-name.js';
40
44
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
41
- import { ChangeRequestConflictsError, DuplicateChangeRequestError, OpenChangeRequestBlocksMergeError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, RemoteBranchGoneError, WorkflowDomainError, WorkflowValidationError, } from '../../shared/domain-errors.js';
45
+ import { BranchAuthorshipError, BranchDeleteBlockedError, BranchNotFoundError, ChangeRequestConflictsError, DuplicateChangeRequestError, OpenChangeRequestBlocksMergeError, ProtectedBranchError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, RemoteBranchGoneError, WorkflowDomainError, WorkflowValidationError, } from '../../shared/domain-errors.js';
42
46
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
43
47
  import { AccessDeniedError } from '../access-model/access-errors.js';
44
48
  import { printable } from '../../shared/printable.js';
@@ -59,7 +63,7 @@ function isOpenPairViolation(err) {
59
63
  // sometimes wraps that error in `.cause`, so unwrap one level and check both.
60
64
  const fieldOf = (e, key) => typeof e === 'object' && e !== null ? e[key] : undefined;
61
65
  const cause = typeof err === 'object' && err !== null ? err.cause : undefined;
62
- const is23505 = fieldOf(err, 'code') === '23505' || fieldOf(cause, 'code') === '23505';
66
+ const is23505 = pgErrorCode(err) === '23505';
63
67
  if (!is23505)
64
68
  return false;
65
69
  // Prefer the structured `constraint` name; some pg drivers omit it, so fall
@@ -391,9 +395,19 @@ export class WorkflowService {
391
395
  // request has to be withdrawn or declined first. `systemCleanup` does
392
396
  // its own open-request check under this same lock, and the legacy
393
397
  // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
398
+ // A request that proposes nothing any more is closed first, and then
399
+ // blocks nothing. Only for a caller the deletion would go ahead for: a
400
+ // refused deletion changes nothing, a closed request included — so one
401
+ // that fails after the close reopens what it closed.
402
+ let closed = [];
394
403
  if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
395
- const open = await this.openChangeRequestOn(name);
404
+ let open = await this.openChangeRequestOn(name);
405
+ if (open !== null && !this.kb.isProtectedBranch(name) && (await this.mayDelete(workspaceId, name, user))) {
406
+ closed = await this.closeEmptyRequestsOn(name, user.id, { workspaceId, fresh: false });
407
+ open = await this.openChangeRequestOn(name);
408
+ }
396
409
  if (open !== null) {
410
+ await this.reopenAfterFailedDelete(closed, name, user.id);
397
411
  // Two different situations, two different actors. A request FROM
398
412
  // this branch is the deleter's own to withdraw; a request INTO it
399
413
  // belongs to someone else, and telling the branch owner to
@@ -406,12 +420,24 @@ export class WorkflowService {
406
420
  `after that the branch can be deleted.`);
407
421
  }
408
422
  }
409
- await this.deleteBranchUnlocked(workspaceId, name, user, opts);
423
+ try {
424
+ await this.deleteBranchUnlocked(workspaceId, name, user, opts);
425
+ }
426
+ catch (err) {
427
+ await this.reopenAfterFailedDelete(closed, name, user.id);
428
+ throw err;
429
+ }
410
430
  });
411
431
  }
412
432
  /** The deletion itself — callers must hold the branch-lifecycle lock. */
413
433
  async deleteBranchUnlocked(workspaceId, name, user, opts) {
414
- await this.git.deleteBranch(workspaceId, name, user, opts);
434
+ const { lastCommit } = await this.git.deleteBranch(workspaceId, name, user, opts);
435
+ // One line per deletion, whoever asked: the commit is what a restore
436
+ // starts from, and for a deletion made in the app or by the cleanup this
437
+ // line is the only trace there is.
438
+ // By id, never by address: log lines carry no email (the release's own promise).
439
+ const actor = opts?.systemCleanup ? 'system' : user.id;
440
+ log.info(`branch deleted by ${actor}: "${name}" at ${lastCommit ?? '(no commit)'}`);
415
441
  // Retire the deleted branch's own workspace clone, best-effort. A stale
416
442
  // clone left on disk would be silently REUSED if the branch name is ever
417
443
  // recreated (the workspace bootstrap short-circuits on an existing
@@ -425,6 +451,305 @@ export class WorkflowService {
425
451
  catch (err) {
426
452
  log.warn(`could not retire workspace clone of deleted branch "${name}":`, { err });
427
453
  }
454
+ return { lastCommit };
455
+ }
456
+ /**
457
+ * Every open change request naming `branch`, at either end — the preview's
458
+ * list. `openChangeRequestOn` answers the first one, which is all a refusal
459
+ * needs.
460
+ */
461
+ async openChangeRequestsOn(branch) {
462
+ const rows = await this.db
463
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
464
+ .from(changeRequests)
465
+ .where(and(or(eq(changeRequests.sourceBranch, branch), eq(changeRequests.targetBranch, branch)), eq(changeRequests.state, 'open')));
466
+ return rows.map((row) => ({
467
+ number: row.number,
468
+ end: row.sourceBranch === branch ? 'source' : 'target',
469
+ }));
470
+ }
471
+ /**
472
+ * Whether open change request `summary` proposes nothing at this moment:
473
+ * its changes, recomputed now, are empty, and no save to its source branch
474
+ * is still landing (a save under a held lock or queued for the commit
475
+ * worker is on disk but not yet in the diff). Anything that cannot be
476
+ * determined is a no — a request is never closed on a guess.
477
+ *
478
+ * `workspaceId` is the clone to compute in (any clone will do: the diff is
479
+ * between origin's refs); absent, the source branch's own, as the app's
480
+ * lazy close always did. `fresh` says that clone was just fetched, so the
481
+ * refs are read as they are instead of fetched again per request.
482
+ */
483
+ async proposesNothing(summary, opts = {}) {
484
+ try {
485
+ const wsId = opts.workspaceId ?? (await this.workspaceService.getOrCreateForBranch(summary.branch)).id;
486
+ const paths = await this.git.changedPathsForPr(wsId, summary.base, summary.branch, opts.fresh ? { fetch: false } : {});
487
+ if (paths.length > 0)
488
+ return false;
489
+ }
490
+ catch (err) {
491
+ crLog.warn(`empty-check for change request #${summary.number} failed — leaving it open:`, { err });
492
+ return false;
493
+ }
494
+ return !(await this.savesInFlight(workspaceIdForBranch(summary.branch)));
495
+ }
496
+ /**
497
+ * Close open change request `number` if it proposes nothing right now
498
+ * ({@link proposesNothing}), recorded as today's empty-close: closed state
499
+ * and closing time, guarded on `state = 'open'` so a concurrent merge or
500
+ * withdrawal wins. Writes the log line every such close owes. Does NOT
501
+ * retire the source branch — the caller decides that, and a caller holding
502
+ * that branch's lifecycle lock must not. Answers the request's target when
503
+ * this call closed it, else null.
504
+ */
505
+ async closeIfEmpty(number, opts) {
506
+ const summary = await this.prs.getPr(number);
507
+ if (!summary || summary.state !== 'open')
508
+ return null;
509
+ if (!(await this.proposesNothing(summary, opts)))
510
+ return null;
511
+ const now = new Date();
512
+ const updated = await this.db
513
+ .update(changeRequests)
514
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
515
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
516
+ .returning({ id: changeRequests.id });
517
+ if (updated.length === 0)
518
+ return null;
519
+ this.prs.invalidateDetailCache(number);
520
+ this.events?.emit({ kind: 'change-request-rejected', number });
521
+ crLog.info(`closed change request #${number} ("${summary.branch}" into "${summary.base}") by ${opts.actor}: ` +
522
+ `it proposes nothing (${opts.reason})`);
523
+ return { base: summary.base };
524
+ }
525
+ /**
526
+ * The deletion's close-empty move: every open request from or into
527
+ * `branch` that proposes nothing is closed. Best effort per request — one
528
+ * that cannot be judged or closed stays open, and the open-request check
529
+ * after this refuses on it. Never retires a branch: the caller holds
530
+ * `branch`'s lifecycle lock, and the deletion itself removes it. Answers
531
+ * the numbers it closed, so a deletion that then fails can reopen them
532
+ * ({@link reopenAfterFailedDelete}).
533
+ */
534
+ async closeEmptyRequestsOn(branch, actor, opts) {
535
+ const closed = [];
536
+ for (const cr of await this.openChangeRequestsOn(branch)) {
537
+ try {
538
+ if (await this.closeIfEmpty(cr.number, { ...opts, actor, reason: `empty when "${branch}" was deleted` })) {
539
+ closed.push(cr.number);
540
+ }
541
+ }
542
+ catch (err) {
543
+ crLog.warn(`could not close empty change request #${cr.number} before deleting "${branch}":`, { err });
544
+ }
545
+ }
546
+ return closed;
547
+ }
548
+ /**
549
+ * Undo {@link closeEmptyRequestsOn} for a deletion that failed after it: a
550
+ * refused deletion changes nothing, and the requests it closed are open
551
+ * again. Still under the branch's lifecycle lock, so nothing can have
552
+ * merged, withdrawn or declined them in between; guarded on `closed` all
553
+ * the same. Best effort: a request that cannot be reopened is logged.
554
+ */
555
+ async reopenAfterFailedDelete(numbers, branch, actor) {
556
+ if (numbers.length === 0)
557
+ return;
558
+ try {
559
+ const reopened = await this.db
560
+ .update(changeRequests)
561
+ .set({ state: 'open', closedAt: null, updatedAt: new Date() })
562
+ .where(and(inArray(changeRequests.number, numbers), eq(changeRequests.state, 'closed')))
563
+ .returning({ number: changeRequests.number });
564
+ for (const { number } of reopened) {
565
+ this.prs.invalidateDetailCache(number);
566
+ crLog.info(`reopened change request #${number} for ${actor}: deleting "${branch}" failed after it was closed`);
567
+ // The close told every tab the request was gone; announce it back,
568
+ // as an open does, so none keeps leaving it out.
569
+ const summary = await this.prs.getPr(number).catch(() => null);
570
+ if (summary) {
571
+ this.events?.emit({
572
+ kind: 'change-request-opened',
573
+ number,
574
+ source: summary.branch,
575
+ target: summary.base,
576
+ authorIdHash: summary.authorId ?? null,
577
+ title: summary.title,
578
+ });
579
+ }
580
+ }
581
+ }
582
+ catch (err) {
583
+ crLog.warn(`could not reopen change requests ${numbers.map((n) => `#${n}`).join(', ')} after deleting "${branch}" failed:`, { err });
584
+ }
585
+ }
586
+ /** The author-or-Admin rule, as a yes/no; a rule that cannot be read is a no. */
587
+ async mayDelete(workspaceId, name, user) {
588
+ try {
589
+ return await this.git.mayDeleteBranch(workspaceId, name, user);
590
+ }
591
+ catch {
592
+ return false;
593
+ }
594
+ }
595
+ /**
596
+ * The refusal for an open request that still proposes something: names it,
597
+ * links to it, and says who can act — its author withdraws it, an Admin can
598
+ * decline it.
599
+ */
600
+ openRequestRefusal(branch, cr, visible = true) {
601
+ // One the caller may not see is not named, linked or placed at an end:
602
+ // `get_change_request` answers it as not found, and so does this.
603
+ if (!visible) {
604
+ return new BranchDeleteBlockedError('open-change-request', branch, `An open change request you cannot see still proposes changes from or into "${branch}". ` +
605
+ `An Admin can decline it in the app; then the branch can be deleted.`);
606
+ }
607
+ return new BranchDeleteBlockedError('open-change-request', branch, `Change request #${cr.number} (${cr.url}) is open ${cr.end === 'source' ? 'from' : 'into'} "${branch}" and still ` +
608
+ `proposes changes. Its author can withdraw it, or an Admin can decline it, in the app; then the branch can be deleted.`, { number: cr.number, url: cr.url });
609
+ }
610
+ /**
611
+ * The default branch's workspace, freshly fetched — where an agent's
612
+ * deletion and the leftover cleanup both decide and act. Never the
613
+ * branch's own checkout (which git refuses to delete from), and always
614
+ * after a strict, forced fetch: the refs are about to authorize a
615
+ * deletion, and a fetch that failed proves nothing about them.
616
+ */
617
+ async freshDefaultWorkspace() {
618
+ const ws = await this.workspaceService.getOrCreateForBranch(this.kb.defaultBranch);
619
+ await this.workspaceService.ensureRemotesFetched(ws.id, { strict: true, force: true });
620
+ return ws.id;
621
+ }
622
+ async deleteBranchChecked(user, name, opts = {}) {
623
+ assertValidBranchName(name);
624
+ // The caller's view of a request, as `get_change_request` gives it: one
625
+ // they may not see is never named, linked or judged in an answer. Absent,
626
+ // every request is visible. A verdict that fails is a no.
627
+ const maySee = async (number) => opts.maySee ? opts.maySee(number).catch(() => false) : true;
628
+ // Under the lock `deleteBranch`, `createBranch` and `openChangeRequest`
629
+ // take, so nothing this answer depends on — a request being opened, the
630
+ // branch being recreated — can change between the checks and the delete.
631
+ // A preview takes it too: it is short, and it then answers a settled state.
632
+ return this.branchLifecycle.run(`branch:${name}`, async () => {
633
+ const isProtected = this.kb.isProtectedBranch(name);
634
+ if (isProtected && !opts.dryRun)
635
+ throw new ProtectedBranchError(name, 'deleting a protected branch');
636
+ let wsId;
637
+ try {
638
+ wsId = await this.freshDefaultWorkspace();
639
+ }
640
+ catch (err) {
641
+ log.warn(`delete_branch "${name}": could not fetch the shared repository — deleting nothing:`, { err });
642
+ throw new BranchDeleteBlockedError('state-unconfirmed', name, `Could not reach the shared repository to confirm the current state of "${name}", so nothing was deleted. Try again shortly.`);
643
+ }
644
+ const state = await this.git.branchState(wsId, name, this.kb.defaultBranch);
645
+ if (!state.exists) {
646
+ if (!opts.dryRun)
647
+ throw new BranchNotFoundError(name);
648
+ return {
649
+ kind: 'preview',
650
+ branch: name,
651
+ exists: false,
652
+ canDelete: false,
653
+ refusals: [new BranchNotFoundError(name).message],
654
+ unmergedCommits: 0,
655
+ openChangeRequests: [],
656
+ lastCommit: null,
657
+ };
658
+ }
659
+ // Every refusal, in the order a deletion meets them. The preview lists
660
+ // them all; a deletion throws the first.
661
+ const refusals = [];
662
+ if (isProtected)
663
+ refusals.push(new ProtectedBranchError(name, 'deleting a protected branch'));
664
+ if (!(await this.git.mayDeleteBranch(wsId, name, user)))
665
+ refusals.push(new BranchAuthorshipError(name));
666
+ // Every open request at either end, each judged now: one that proposes
667
+ // nothing is closed by the deletion and blocks nothing; one that still
668
+ // proposes something — or whose changes cannot be determined — blocks.
669
+ // A request the caller may not see is not part of a preview at all —
670
+ // not listed, not judged, not weighed in `canDelete` — so the preview
671
+ // reads the same whether one exists, and whether it is empty. A
672
+ // deletion still closes it or is refused on it like any other, without
673
+ // naming it: the rules above are the deletion's, not the caller's view.
674
+ const open = [];
675
+ for (const cr of await this.openChangeRequestsOn(name)) {
676
+ const visible = await maySee(cr.number);
677
+ if (!visible && opts.dryRun)
678
+ continue;
679
+ const summary = await this.prs.getPr(cr.number).catch(() => null);
680
+ const empty = summary ? await this.proposesNothing(summary, { workspaceId: wsId, fresh: true }) : false;
681
+ open.push({
682
+ ...cr,
683
+ url: summary?.url ?? `/change-requests/${cr.number}`,
684
+ proposesNothing: empty,
685
+ visible,
686
+ });
687
+ }
688
+ const blocking = open.find((cr) => !cr.proposesNothing);
689
+ if (blocking)
690
+ refusals.push(this.openRequestRefusal(name, blocking, blocking.visible));
691
+ // Not coverable by `discardUnmerged`: these are a person's saves on
692
+ // their way into the branch, not work already on it.
693
+ if (await this.savesInFlight(workspaceIdForBranch(name))) {
694
+ refusals.push(this.savesLandingRefusal(name));
695
+ }
696
+ if (state.unmergedCommits > 0 && !opts.discardUnmerged) {
697
+ const n = state.unmergedCommits;
698
+ refusals.push(new BranchDeleteBlockedError('unmerged-commits', name, `"${name}" holds ${n} commit${n === 1 ? '' : 's'} that ${n === 1 ? 'is' : 'are'} not on ${this.kb.defaultBranch}; ` +
699
+ `deleting it would lose ${n === 1 ? 'it' : 'them'}. Pass discardUnmerged: true to delete it anyway.`, { unmergedCommits: n }));
700
+ }
701
+ if (opts.dryRun) {
702
+ return {
703
+ kind: 'preview',
704
+ branch: name,
705
+ exists: true,
706
+ canDelete: refusals.length === 0,
707
+ refusals: refusals.map((r) => r.message),
708
+ unmergedCommits: state.unmergedCommits,
709
+ openChangeRequests: open.map(({ number, end, url, proposesNothing }) => ({ number, end, url, proposesNothing })),
710
+ lastCommit: state.lastCommit,
711
+ };
712
+ }
713
+ // A refused deletion changes nothing: no request is closed either.
714
+ if (refusals[0])
715
+ throw refusals[0];
716
+ // The saves check above is a snapshot; a save could take its file lock
717
+ // since. With no lock grantable on the branch, it is made again and
718
+ // nothing can start landing between it and the deletion.
719
+ const { lastCommit } = await this.fileLocks.whileNoneAcquired(name, async () => {
720
+ if (await this.savesInFlight(workspaceIdForBranch(name)))
721
+ throw this.savesLandingRefusal(name);
722
+ // Every open request proposes nothing: close them, each re-judged at
723
+ // the moment of closing — the last step before the deletion, so no
724
+ // later check can refuse after it. Nothing here takes this branch's
725
+ // lifecycle lock, so a request whose source IS this branch cannot make
726
+ // the deletion wait on itself. Should the deletion still fail (the
727
+ // leased push below), the requests closed here are reopened.
728
+ const closed = await this.closeEmptyRequestsOn(name, user.id, { workspaceId: wsId, fresh: true });
729
+ try {
730
+ const still = (await this.openChangeRequestsOn(name))[0];
731
+ if (still) {
732
+ const summary = await this.prs.getPr(still.number).catch(() => null);
733
+ throw this.openRequestRefusal(name, { ...still, url: summary?.url ?? `/change-requests/${still.number}` }, await maySee(still.number));
734
+ }
735
+ // Leased on the tip the checks above were made against, so a commit
736
+ // pushed since is refused by the host instead of deleted unseen.
737
+ return await this.deleteBranchUnlocked(wsId, name, user, {
738
+ ...(state.lastCommit ? { expectTip: state.lastCommit } : {}),
739
+ });
740
+ }
741
+ catch (err) {
742
+ await this.reopenAfterFailedDelete(closed, name, user.id);
743
+ throw err;
744
+ }
745
+ });
746
+ return {
747
+ kind: 'deleted',
748
+ branch: name,
749
+ lastCommit: lastCommit ?? state.lastCommit ?? '',
750
+ discardedCommits: state.unmergedCommits,
751
+ };
752
+ });
428
753
  }
429
754
  // No `switchBranch` here on purpose. Under the per-branch workspace model,
430
755
  // switching branches is a frontend navigation that triggers a workspace
@@ -440,7 +765,7 @@ export class WorkflowService {
440
765
  // `discardWorkingChanges` removed: under save=share there's nothing dirty
441
766
  // to discard.
442
767
  shareCurrentBranch(workspaceId, user) {
443
- return this.trackedPush(workspaceId, user);
768
+ return this.pushWithRecovery(workspaceId, branchForWorkspaceId(workspaceId), '(share)', user);
444
769
  }
445
770
  refreshRemotes(workspaceId) {
446
771
  return this.git.fetch(workspaceId);
@@ -584,7 +909,7 @@ export class WorkflowService {
584
909
  }
585
910
  const message = sanitizeError(err);
586
911
  syncLog.warn(`pull failed for branch "${branch}": ${message}`);
587
- this.noteGitSyncFailed(gitId, branch, err);
912
+ this.noteGitSyncFailed(gitId, branch, err, undefined, 'pull');
588
913
  return { branch, outcome: 'error', error: message };
589
914
  }
590
915
  // A clean pull is proof origin is reachable and this clone rebases onto
@@ -905,7 +1230,9 @@ export class WorkflowService {
905
1230
  });
906
1231
  }
907
1232
  else {
908
- lockLog.info(`ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`);
1233
+ lockLog.info(
1234
+ // The holder by id only: log lines carry no names.
1235
+ `ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderUserId}`);
909
1236
  }
910
1237
  return result;
911
1238
  }
@@ -969,7 +1296,8 @@ export class WorkflowService {
969
1296
  let recoveryError = null;
970
1297
  if (looksLikeNonFastForward) {
971
1298
  try {
972
- await this.pullWorkspace(workspaceId);
1299
+ // Same as `pushWithRecovery`: keep a stranded merge a merge.
1300
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
973
1301
  await this.git.push(workspaceId, user);
974
1302
  recovered = true;
975
1303
  this.noteGitSyncOk(workspaceId, branch);
@@ -1049,7 +1377,7 @@ export class WorkflowService {
1049
1377
  lockLog.info(`RELEASE start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`);
1050
1378
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
1051
1379
  if (!lock || lock.holderUserId !== user.id) {
1052
- lockLog.warn(`RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`);
1380
+ lockLog.warn(`RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderUserId}` : 'no lock row'}`);
1053
1381
  throw new WorkflowValidationError(`Cannot release lock on "${targetPath}": not held by you (or no longer exists).`, { kind: 'lock-not-held', branch, path: targetPath });
1054
1382
  }
1055
1383
  // Releasing a coordination hold must NEVER enqueue a commit: the hold was
@@ -1192,7 +1520,13 @@ export class WorkflowService {
1192
1520
  * clip would make the banner disagree with them. Callers that already
1193
1521
  * hold the sentence pass it; otherwise it is derived below.
1194
1522
  */
1195
- explicitConflict) {
1523
+ explicitConflict,
1524
+ /**
1525
+ * Which side of the sync failed. A 403 or "Repository not found" on a
1526
+ * pull means the credentials cannot READ the repository, and telling the
1527
+ * operator to grant push access would send them to the wrong setting.
1528
+ */
1529
+ operation = 'push') {
1196
1530
  // A rebase conflict is a conflict whichever path ran into it. The remote
1197
1531
  // sync names its files on purpose; the push-recovery paths (autosave, the
1198
1532
  // lock release, the queued retries) raise the same banner with the raw
@@ -1213,48 +1547,34 @@ export class WorkflowService {
1213
1547
  kind: 'git-sync-failed',
1214
1548
  workspaceId: id,
1215
1549
  branch,
1216
- // Git stderr can quote a credentialed URL; the banner is user-facing and
1217
- // the string also lands in client logs, so sanitize before it leaves.
1218
- reason: conflict ? conflict.message : sanitizeError(err),
1550
+ // Never the git output itself — not even sanitised: stderr quotes the
1551
+ // host's own error text and URLs, and the banner is user-facing. A
1552
+ // fixed sentence says what kind of failure it was; the raw text is in
1553
+ // the server log, where the banner points whoever can act on it.
1554
+ reason: conflict ? conflict.message : describeSyncFailure(err, operation),
1219
1555
  ...(conflict ? { conflictedPaths: conflict.paths } : {}),
1220
1556
  });
1221
1557
  }
1222
- /**
1223
- * A bare push wrapped in the sync tracker: a failure raises the banner, a
1224
- * success clears it. For the workflow paths that push directly — sharing a
1225
- * branch, publishing a CR's source, update-from-base, the decline revert,
1226
- * the roles.yaml restore — rather than through `pushWithRecovery`'s
1227
- * cooperative ladder. Behaviour is otherwise unchanged: the error still
1228
- * propagates to the caller exactly as the bare push's did. Without this,
1229
- * those pushes could fail invisibly (no banner) and, worse, a successful
1230
- * share could not CLEAR a banner an earlier save had raised.
1231
- *
1232
- * The branch is derived from the workspace id (they are the same string,
1233
- * URL-encoding aside) so call sites cannot pass a mismatched pair.
1234
- */
1235
- async trackedPush(workspaceId, user, opts) {
1236
- const branch = branchForWorkspaceId(workspaceId);
1237
- try {
1238
- // Preserve the exact call shape of the bare pushes this replaces — an
1239
- // explicit `undefined` third argument is a different call signature to
1240
- // every spy that asserts on it.
1241
- await (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user));
1242
- this.noteGitSyncOk(workspaceId, branch);
1243
- }
1244
- catch (err) {
1245
- this.noteGitSyncFailed(workspaceId, branch, err);
1246
- throw err;
1247
- }
1248
- }
1249
1558
  /**
1250
1559
  * Shared implementation of "push, and on non-fast-forward try a
1251
1560
  * cooperative pull-rebase + retry, then hand off to the agent if that
1252
1561
  * still fails." The full rationale lives at the call site in
1253
1562
  * `releaseLock`; this method is the de-duplicated body.
1563
+ *
1564
+ * EVERY workflow push goes through here — saves, sharing a branch, opening
1565
+ * a request, update-from-target, the decline revert, the roles.yaml
1566
+ * restore — so a push the host refuses answers one way everywhere: the
1567
+ * commit stays local, the sync banner goes up, and the caller gets the
1568
+ * 409 saved-locally sentence instead of a raw git error (which a route
1569
+ * would turn into a 500). A later successful push of the branch carries
1570
+ * the commits and clears the banner.
1254
1571
  */
1255
1572
  async pushWithRecovery(workspaceId, branch, targetPath, user, opts) {
1573
+ // No explicit `undefined` third argument when there are no options: the
1574
+ // call shape stays the bare `push(workspaceId, user)` every caller made.
1575
+ const push = () => opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user);
1256
1576
  try {
1257
- await this.git.push(workspaceId, user, opts);
1577
+ await push();
1258
1578
  this.noteGitSyncOk(workspaceId, branch);
1259
1579
  }
1260
1580
  catch (firstPushErr) {
@@ -1263,10 +1583,25 @@ export class WorkflowService {
1263
1583
  let recovered = false;
1264
1584
  let recoveryDetail = '(cooperative path not attempted)';
1265
1585
  let recoveryError = null;
1586
+ // The retry push's own rejection, when the pull got that far: the
1587
+ // divergence is cured by then, so it — not the first rejection — says
1588
+ // whether the host is refusing.
1589
+ let retryDetail = null;
1266
1590
  if (looksLikeNonFastForward) {
1267
1591
  try {
1268
- await this.pullWorkspace(workspaceId);
1269
- await this.git.push(workspaceId, user, opts);
1592
+ // `preserveMerges` on every push, not only the ones that make a
1593
+ // merge: a merge stranded by a refused open or update rides along
1594
+ // with whatever push of the branch comes next — a save, a share —
1595
+ // and a plain rebase would flatten it into cherry-picks. With no
1596
+ // merge among the local commits the two spellings replay the same.
1597
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
1598
+ try {
1599
+ await push();
1600
+ }
1601
+ catch (retryErr) {
1602
+ retryDetail = retryErr instanceof Error ? retryErr.message : String(retryErr);
1603
+ throw retryErr;
1604
+ }
1270
1605
  recovered = true;
1271
1606
  this.noteGitSyncOk(workspaceId, branch);
1272
1607
  log.info(`non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`);
@@ -1285,9 +1620,17 @@ export class WorkflowService {
1285
1620
  // When recovery RAN, its error is the current state of the world (the
1286
1621
  // first rejection may be a stale non-fast-forward the pull already
1287
1622
  // cured); when it was skipped, the first error is all there is.
1288
- this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
1623
+ // A recovery that failed before its retry push failed in the PULL.
1624
+ const failedInPull = recoveryError !== null && retryDetail === null;
1625
+ this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr, undefined, failedInPull ? 'pull' : 'push');
1289
1626
  log.warn(`push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`, { detail: firstDetail });
1290
- throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail);
1627
+ throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail,
1628
+ // `rejected` above also matches the host's own `[remote rejected]`
1629
+ // (an outage, a hook): worth the cooperative try, but only a real
1630
+ // divergence is described as one.
1631
+ /non-fast-forward|fetch first|updates were rejected|! \[rejected\]/i.test(retryDetail ?? firstDetail)
1632
+ ? 'diverged'
1633
+ : 'refused');
1291
1634
  }
1292
1635
  }
1293
1636
  }
@@ -1527,10 +1870,21 @@ export class WorkflowService {
1527
1870
  if (mergeOutcome.kind === 'conflicts') {
1528
1871
  throw new ChangeRequestConflictsError(input.sourceBranch, input.targetBranch, mergeOutcome.paths);
1529
1872
  }
1530
- // Push the source — `gh pr create` against an unpushed branch returns
1531
- // "head branch does not exist on remote". Push *after* the auto-merge
1532
- // so the remote sees the merge commit too.
1533
- await this.trackedPush(workspaceId, user);
1873
+ // Push the source *after* the auto-merge so the remote sees the merge
1874
+ // commit too. A push the host refuses does not undo the open: the merge
1875
+ // is committed locally, the request is a DB row, and the next push of
1876
+ // the branch carries the commits — so the row is still created below and
1877
+ // the refusal (409, the saved-locally sentence, the sync banner) is
1878
+ // answered once it exists.
1879
+ let pushRefused = null;
1880
+ try {
1881
+ await this.pushWithRecovery(workspaceId, branchForWorkspaceId(workspaceId), '(opening a change request)', user);
1882
+ }
1883
+ catch (err) {
1884
+ if (!(err instanceof PushNeedsAgentResolutionError))
1885
+ throw err;
1886
+ pushRefused = err;
1887
+ }
1534
1888
  const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
1535
1889
  const cwd = path.join(workspacePath, this.kbDirName);
1536
1890
  // Touched paths between target and source — basis for the
@@ -1592,6 +1946,21 @@ export class WorkflowService {
1592
1946
  throw new WorkflowValidationError(`Failed to open change request: ${redactTokens(rawMsg, this.git.credentials?.token())}`, { kind: 'open-change-request-failed' });
1593
1947
  }
1594
1948
  this.prs.invalidateDetailCache(prNumber);
1949
+ const opened = {
1950
+ kind: 'change-request-opened',
1951
+ number: prNumber,
1952
+ source: input.sourceBranch,
1953
+ target: input.targetBranch,
1954
+ authorIdHash: hashEmail(user.email),
1955
+ title: input.title,
1956
+ };
1957
+ if (pushRefused) {
1958
+ // The request exists; its detail reads the published refs, which the
1959
+ // refused push never moved — answering the refusal is what the caller
1960
+ // needs, not a detail read that may fail on a branch the host lacks.
1961
+ this.events?.emit(opened);
1962
+ throw pushRefused;
1963
+ }
1595
1964
  const detail = await this.prs.getPrDetail(prNumber, {
1596
1965
  fresh: true,
1597
1966
  workspaceId,
@@ -1600,14 +1969,7 @@ export class WorkflowService {
1600
1969
  if (!detail) {
1601
1970
  throw new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`);
1602
1971
  }
1603
- this.events?.emit({
1604
- kind: 'change-request-opened',
1605
- number: prNumber,
1606
- source: input.sourceBranch,
1607
- target: input.targetBranch,
1608
- authorIdHash: hashEmail(user.email),
1609
- title: input.title,
1610
- });
1972
+ this.events?.emit(opened);
1611
1973
  return detail;
1612
1974
  });
1613
1975
  }
@@ -1719,7 +2081,7 @@ export class WorkflowService {
1719
2081
  // do. Asking git what is unpushed retries it instead.
1720
2082
  //
1721
2083
  // A failed probe falls back to the old gate rather than to "push
1722
- // anyway": `trackedPush` on a clone with nothing to push is a wasted
2084
+ // anyway": a push on a clone with nothing to push is a wasted
1723
2085
  // round trip on every single update.
1724
2086
  const hasUnpushed = await this.git
1725
2087
  .hasUnpushedCommits(workspaceId)
@@ -1728,7 +2090,10 @@ export class WorkflowService {
1728
2090
  return !outcome.alreadyUpToDate;
1729
2091
  });
1730
2092
  if (hasUnpushed) {
1731
- await this.trackedPush(workspaceId, user);
2093
+ // A refusal answers 409 with the saved-locally sentence and leaves the
2094
+ // merge applied in the clone; the probe above makes the next update
2095
+ // (or any save on the branch) push it.
2096
+ await this.pushWithRecovery(workspaceId, branchForWorkspaceId(workspaceId), `(update from ${detail.base})`, user);
1732
2097
  }
1733
2098
  // Where the branch ended up, as PUBLISHED — two rev-parses, not a second
1734
2099
  // detail read. Everything below needs only the sha, and the detail that
@@ -1904,8 +2269,10 @@ export class WorkflowService {
1904
2269
  // Best-effort freshen of the source checkout: the diff below reads origin
1905
2270
  // refs, but the restore commits from the working tree — a stale tree
1906
2271
  // would push non-fast-forward and fail loudly anyway; this just makes
1907
- // that rare.
1908
- await this.pullWorkspace(ws.id).catch(() => undefined);
2272
+ // that rare. `preserveMerges`: the branch may carry a merge a refused open
2273
+ // or update stranded locally, and a plain rebase would flatten it before
2274
+ // the revert's push publishes the branch.
2275
+ await this.pullWorkspace(ws.id, { preserveMerges: true }).catch(() => undefined);
1909
2276
  // The verb acts on the request as it is NOW — never a cached file list.
1910
2277
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1911
2278
  if (!paths.includes(repoRelPath)) {
@@ -1925,6 +2292,10 @@ export class WorkflowService {
1925
2292
  // save must not race the restore between write and commit.
1926
2293
  const held = [];
1927
2294
  let revertError = null;
2295
+ // A push the host refuses leaves the revert committed on the branch
2296
+ // locally; it is answered (409, the saved-locally sentence) only after
2297
+ // the locks are released and the request's caches reflect the new head.
2298
+ let pushRefused = null;
1928
2299
  try {
1929
2300
  for (const p of reverted) {
1930
2301
  const lockPath = `${this.kbDirName}/${p}`;
@@ -1940,7 +2311,14 @@ export class WorkflowService {
1940
2311
  await this.git.restorePathFromRef(ws.id, mergeBase, p);
1941
2312
  await this.git.commitFile(ws.id, user, p, `Revert ${p} (declined in change request #${number})`, true);
1942
2313
  }
1943
- await this.trackedPush(ws.id, user);
2314
+ try {
2315
+ await this.pushWithRecovery(ws.id, headBranch, repoRelPath, user);
2316
+ }
2317
+ catch (err) {
2318
+ if (!(err instanceof PushNeedsAgentResolutionError))
2319
+ throw err;
2320
+ pushRefused = err;
2321
+ }
1944
2322
  }
1945
2323
  catch (err) {
1946
2324
  revertError = { reason: err };
@@ -1956,9 +2334,15 @@ export class WorkflowService {
1956
2334
  const failedRelease = released.find((r) => r.status === 'rejected');
1957
2335
  if (failedRelease)
1958
2336
  throw failedRelease.reason;
2337
+ this.prs.invalidateDetailCache(number);
2338
+ // A refused push moved nothing the request is judged on. A recorded
2339
+ // apply failure describes the published head, which is still where it
2340
+ // was, so it stays; and the file list reads the published refs, so
2341
+ // closing an "empty" request is decided once the push lands.
2342
+ if (pushRefused)
2343
+ throw pushRefused;
1959
2344
  // The source head moved: the refusal described a revision that is gone.
1960
2345
  await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
1961
- this.prs.invalidateDetailCache(number);
1962
2346
  const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1963
2347
  if (remaining.length > 0) {
1964
2348
  // Answered like the file list: the placeholder is not a remaining file.
@@ -2076,7 +2460,9 @@ export class WorkflowService {
2076
2460
  continue;
2077
2461
  const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2078
2462
  try {
2079
- await this.pullWorkspace(ws.id);
2463
+ // `preserveMerges`: a merge a refused open or update stranded on the
2464
+ // request's branch must survive until the branch's next push.
2465
+ await this.pullWorkspace(ws.id, { preserveMerges: true });
2080
2466
  }
2081
2467
  catch (err) {
2082
2468
  log.warn(`pull failed for #${summary.number} before removing folder ${printable(folder)}: ${printable(sanitizeError(err))}`);
@@ -2339,37 +2725,10 @@ export class WorkflowService {
2339
2725
  * Returns true when THIS call closed it.
2340
2726
  */
2341
2727
  async closeEmptyChangeRequest(number, user) {
2342
- const summary = await this.prs.getPr(number);
2343
- if (!summary || summary.state !== 'open')
2344
- return false;
2345
- let paths;
2346
- let wsId;
2347
- try {
2348
- const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2349
- wsId = ws.id;
2350
- paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
2351
- }
2352
- catch (err) {
2353
- crLog.warn(`empty-check for change request #${number} failed — leaving it open:`, { err });
2354
- return false;
2355
- }
2356
- if (paths.length > 0)
2728
+ const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.email });
2729
+ if (!closed)
2357
2730
  return false;
2358
- if (await this.savesInFlight(wsId))
2359
- return false;
2360
- // Guard on `state = 'open'` so a concurrent merge or withdraw wins the
2361
- // race and this becomes a no-op.
2362
- const now = new Date();
2363
- const updated = await this.db
2364
- .update(changeRequests)
2365
- .set({ state: 'closed', closedAt: now, updatedAt: now })
2366
- .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
2367
- .returning({ id: changeRequests.id });
2368
- if (updated.length === 0)
2369
- return false;
2370
- this.prs.invalidateDetailCache(number);
2371
- this.events?.emit({ kind: 'change-request-rejected', number });
2372
- await this.retireMergedSourceBranch(number, summary.base, user);
2731
+ await this.retireMergedSourceBranch(number, closed.base, user);
2373
2732
  return true;
2374
2733
  }
2375
2734
  /**
@@ -2378,6 +2737,9 @@ export class WorkflowService {
2378
2737
  * (their file is still only on disk). An answer that cannot be read is a
2379
2738
  * yes — the caller is about to delete the checkout.
2380
2739
  */
2740
+ savesLandingRefusal(name) {
2741
+ return new BranchDeleteBlockedError('saves-landing', name, `"${name}" still has saves waiting to be committed or a file held for editing. Try again once they have landed.`);
2742
+ }
2381
2743
  async savesInFlight(wsId) {
2382
2744
  try {
2383
2745
  return (await this.fileLocks.hasAnyActive(wsId)) || (await this.pendingCommits.hasAnyForWorkspace(wsId));
@@ -2611,10 +2973,245 @@ export class WorkflowService {
2611
2973
  }
2612
2974
  })
2613
2975
  .catch((err) => crLog.warn('on-demand deleted-branch sweep failed:', { err }))
2976
+ // Every sweep is followed by the tidy-up, which never throws.
2977
+ .then(() => this.tidyAfterSweep())
2978
+ .then(() => undefined)
2614
2979
  .finally(() => {
2615
2980
  this.sweepKick = null;
2616
2981
  });
2617
2982
  }
2983
+ tidyRound = null;
2984
+ tidyStopped = false;
2985
+ /**
2986
+ * Stop the tidy-up when the graph stops: no round starts after this, and a
2987
+ * round under way stops at its next request or branch. Then
2988
+ * {@link drainTidy} waits for it, so it cannot fetch or delete once the
2989
+ * pool is ended or a replacement graph owns the same checkout.
2990
+ */
2991
+ stopTidying() {
2992
+ this.tidyStopped = true;
2993
+ }
2994
+ /** Settles once no tidy-up (nor the sweep that leads into one) is running. */
2995
+ async drainTidy() {
2996
+ await Promise.allSettled([this.sweepKick, this.tidyRound]);
2997
+ }
2998
+ /**
2999
+ * What follows every sweep for deleted branches — at startup, on a remote
3000
+ * sync, after a merge and on demand: first the open requests that propose
3001
+ * nothing are closed and their source branch removed
3002
+ * ({@link closeEmptyOpenChangeRequests}), then the branches merged requests
3003
+ * left behind ({@link retireLeftoverMergedBranches}, behind its own
3004
+ * setting). In that order, because closing an empty request is what can
3005
+ * free a branch for the second. Never throws; coalesced.
3006
+ */
3007
+ tidyAfterSweep() {
3008
+ if (this.tidyRound)
3009
+ return this.tidyRound;
3010
+ if (this.tidyStopped)
3011
+ return Promise.resolve({ closedEmpty: 0, removedLeftovers: 0 });
3012
+ this.tidyRound = (async () => {
3013
+ const closedEmpty = await this.closeEmptyOpenChangeRequests().catch((err) => {
3014
+ crLog.warn('background close of empty change requests failed:', { err });
3015
+ return 0;
3016
+ });
3017
+ const removedLeftovers = await this.retireLeftoverMergedBranches();
3018
+ return { closedEmpty, removedLeftovers };
3019
+ })().finally(() => {
3020
+ this.tidyRound = null;
3021
+ });
3022
+ return this.tidyRound;
3023
+ }
3024
+ /**
3025
+ * Close every open change request that proposes nothing, and remove its
3026
+ * source branch — what opening such a request's page in the app does
3027
+ * (`closeEmptyChangeRequest`), done without anyone opening it. Each is
3028
+ * judged from the default branch's workspace against origin's refs, after
3029
+ * ONE strict fetch, so no request's branch is cloned for the question; a
3030
+ * round whose fetch fails closes nothing. A request whose changes cannot be
3031
+ * determined, or with a save still landing on its branch, stays open.
3032
+ * Returns how many it closed.
3033
+ *
3034
+ * The source branch is removed under the same guards as any other
3035
+ * deletion: under its lifecycle lock, only with no request open from or
3036
+ * into it, with no save landing and no lock grantable on it at that moment,
3037
+ * and leased on the tip the request was judged empty at — a commit pushed
3038
+ * since is refused by the host instead of deleted unseen.
3039
+ */
3040
+ async closeEmptyOpenChangeRequests() {
3041
+ const open = await this.db
3042
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
3043
+ .from(changeRequests)
3044
+ .where(eq(changeRequests.state, 'open'));
3045
+ if (open.length === 0)
3046
+ return 0;
3047
+ let wsId;
3048
+ try {
3049
+ wsId = await this.freshDefaultWorkspace();
3050
+ }
3051
+ catch (err) {
3052
+ crLog.warn('background close of empty change requests skipped — could not fetch the shared repository:', { err });
3053
+ return 0;
3054
+ }
3055
+ let closed = 0;
3056
+ for (const { number, sourceBranch } of open) {
3057
+ if (this.tidyStopped)
3058
+ break;
3059
+ try {
3060
+ // Read before the request is judged, so the lease below covers
3061
+ // anything that reached the branch after the judgement.
3062
+ const tip = this.kb.isProtectedBranch(sourceBranch)
3063
+ ? null
3064
+ : (await this.git.branchState(wsId, sourceBranch, this.kb.defaultBranch)).lastCommit;
3065
+ const done = await this.closeIfEmpty(number, {
3066
+ workspaceId: wsId,
3067
+ fresh: true,
3068
+ actor: 'system',
3069
+ reason: 'empty in the background sweep',
3070
+ });
3071
+ if (!done)
3072
+ continue;
3073
+ closed++;
3074
+ if (tip)
3075
+ await this.retireEmptySourceBranch(wsId, sourceBranch, tip);
3076
+ }
3077
+ catch (err) {
3078
+ crLog.warn(`background close of change request #${number} failed — leaving it open:`, { err });
3079
+ }
3080
+ }
3081
+ return closed;
3082
+ }
3083
+ /**
3084
+ * Remove the source branch of a request the background sweep just closed
3085
+ * as empty, from the default branch's freshly fetched workspace `wsId`.
3086
+ * Skips quietly when another request is open from or into it or a save is
3087
+ * landing on it; the delete is leased on `tip`. Never throws.
3088
+ */
3089
+ async retireEmptySourceBranch(wsId, branch, tip) {
3090
+ try {
3091
+ await this.branchLifecycle.run(`branch:${branch}`, async () => {
3092
+ if ((await this.openChangeRequestOn(branch)) !== null)
3093
+ return;
3094
+ await this.fileLocks.whileNoneAcquired(branch, async () => {
3095
+ if (await this.savesInFlight(workspaceIdForBranch(branch))) {
3096
+ crLog.info(`kept "${branch}" after closing its empty change request: a save is still landing on it`);
3097
+ return;
3098
+ }
3099
+ await this.deleteBranchUnlocked(wsId, branch, SYSTEM_ACTOR, { systemCleanup: true, expectTip: tip });
3100
+ });
3101
+ });
3102
+ }
3103
+ catch (err) {
3104
+ crLog.warn(`could not remove "${branch}" after closing its empty change request (non-fatal):`, { err });
3105
+ }
3106
+ }
3107
+ /**
3108
+ * Whether the leftover cleanup below runs — the `retireMergedBranches`
3109
+ * deployment setting, read at every round so the Deployment page applies
3110
+ * without a restart. Set by the composition root; on by default.
3111
+ */
3112
+ leftoverCleanupEnabled = () => true;
3113
+ leftoverRound = null;
3114
+ /**
3115
+ * Remove the branches merged change requests left behind: sources of a
3116
+ * merge from before the post-merge retirement existed (core-backend 0.5.0),
3117
+ * and ones whose retirement failed. What `retireMergedSourceBranch` does
3118
+ * after a merge, applied to every merge on record.
3119
+ *
3120
+ * A branch goes only when ALL hold: it is not protected; it was the source
3121
+ * of a merged request; it has no commit that is not on the default branch;
3122
+ * no request is open from or into it; its tip is contained in the merge
3123
+ * commit recorded for one of those requests — a branch recreated under the
3124
+ * same name after the merge is somebody's new draft, and a request with no
3125
+ * recorded merge commit proves nothing; and its checkout has no saves
3126
+ * landing and no file held. Each is decided and deleted under the branch's
3127
+ * lifecycle lock, like the post-merge retirement.
3128
+ *
3129
+ * Fails SAFE, like the deleted-branch sweep it runs beside: a round whose
3130
+ * fetch fails removes nothing, and a branch whose state cannot be read is
3131
+ * left. The switch covers this round only; the post-merge retirement is not
3132
+ * asked. Never throws. Coalesced, because boot, the remote sync and the
3133
+ * on-demand sweep can all start one. Returns how many it removed.
3134
+ */
3135
+ retireLeftoverMergedBranches() {
3136
+ if (this.leftoverRound)
3137
+ return this.leftoverRound;
3138
+ this.leftoverRound = this.retireLeftoverMergedBranchesOnce()
3139
+ .catch((err) => {
3140
+ log.warn('leftover branch cleanup failed:', { err });
3141
+ return 0;
3142
+ })
3143
+ .finally(() => {
3144
+ this.leftoverRound = null;
3145
+ });
3146
+ return this.leftoverRound;
3147
+ }
3148
+ async retireLeftoverMergedBranchesOnce() {
3149
+ if (!this.leftoverCleanupEnabled())
3150
+ return 0;
3151
+ const merged = await this.db
3152
+ .select({ sourceBranch: changeRequests.sourceBranch, mergedSha: changeRequests.mergedSha })
3153
+ .from(changeRequests)
3154
+ .where(eq(changeRequests.state, 'merged'));
3155
+ const mergeCommits = new Map();
3156
+ for (const row of merged) {
3157
+ if (this.kb.isProtectedBranch(row.sourceBranch))
3158
+ continue;
3159
+ const shas = mergeCommits.get(row.sourceBranch) ?? [];
3160
+ if (row.mergedSha)
3161
+ shas.push(row.mergedSha);
3162
+ mergeCommits.set(row.sourceBranch, shas);
3163
+ }
3164
+ if (mergeCommits.size === 0)
3165
+ return 0;
3166
+ let wsId;
3167
+ try {
3168
+ wsId = await this.freshDefaultWorkspace();
3169
+ }
3170
+ catch (err) {
3171
+ log.warn('leftover branch cleanup skipped — could not fetch the shared repository:', { err });
3172
+ return 0;
3173
+ }
3174
+ let removed = 0;
3175
+ for (const [branch, shas] of mergeCommits) {
3176
+ if (this.tidyStopped)
3177
+ break;
3178
+ if (shas.length === 0)
3179
+ continue;
3180
+ try {
3181
+ const done = await this.branchLifecycle.run(`branch:${branch}`, async () => {
3182
+ const state = await this.git.branchState(wsId, branch, this.kb.defaultBranch);
3183
+ if (!state.exists || state.lastCommit === null || state.unmergedCommits > 0)
3184
+ return false;
3185
+ if ((await this.openChangeRequestOn(branch)) !== null)
3186
+ return false;
3187
+ let contained = false;
3188
+ for (const sha of shas) {
3189
+ if (await this.git.isAncestor(wsId, state.lastCommit, sha).catch(() => false)) {
3190
+ contained = true;
3191
+ break;
3192
+ }
3193
+ }
3194
+ if (!contained)
3195
+ return false;
3196
+ const tip = state.lastCommit;
3197
+ return this.fileLocks.whileNoneAcquired(branch, async () => {
3198
+ if (await this.savesInFlight(workspaceIdForBranch(branch)))
3199
+ return false;
3200
+ await this.deleteBranchUnlocked(wsId, branch, SYSTEM_ACTOR, { systemCleanup: true, expectTip: tip });
3201
+ return true;
3202
+ });
3203
+ });
3204
+ if (done)
3205
+ removed++;
3206
+ }
3207
+ catch (err) {
3208
+ log.warn(`leftover branch cleanup could not remove "${branch}":`, { err });
3209
+ }
3210
+ }
3211
+ if (removed > 0)
3212
+ log.info(`removed ${removed} branch${removed === 1 ? '' : 'es'} left over from merged change requests`);
3213
+ return removed;
3214
+ }
2618
3215
  /**
2619
3216
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
2620
3217
  * authorized set is:
@@ -2744,6 +3341,9 @@ export class WorkflowService {
2744
3341
  // AFTER the event: the applying UI is waiting on `change-request-merged`,
2745
3342
  // and branch retirement is git IO it must never wait behind.
2746
3343
  await this.retireMergedSourceBranch(number, baseBranch, user);
3344
+ // A merge can empty OTHER open requests into the same target; the sweep's
3345
+ // tidy-up closes those. Detached and coalesced, like every kick.
3346
+ this.kickDeletedBranchSweep();
2747
3347
  return { kind: 'merged', result };
2748
3348
  }
2749
3349
  /**
@@ -2811,10 +3411,82 @@ export class WorkflowService {
2811
3411
  this.events?.emit({ kind: 'fs-tree-changed', workspaceId: targetWorkspaceId, branch: targetBranch });
2812
3412
  }
2813
3413
  }
3414
+ /**
3415
+ * How long `mergeBranch` waits for the source's queued commits, and how
3416
+ * often it asks. The worker polls every half second and a commit takes a few
3417
+ * seconds; twenty seconds of waiting is an outage, not latency.
3418
+ *
3419
+ * Twenty, not thirty, because of who is waiting on THIS call: an agent over
3420
+ * `/api/mcp` reaches the tool through `@utcp/http`, whose request timeout is
3421
+ * a fixed 30 seconds (its call-template schema drops any `timeout` field).
3422
+ * The wait plus the merge after it (fetch, merge, push) must fit inside
3423
+ * that, or the agent hears "timeout of 30000ms exceeded" instead of the
3424
+ * `pending-commits` answer this wait exists to give. Mutable for tests only.
3425
+ */
3426
+ mergeWaitForPendingCommits = { timeoutMs: 20_000, pollMs: 250 };
3427
+ /**
3428
+ * Wait until no commit is queued on `branch`, or the bound runs out. A
3429
+ * commit escalated to a person ends the wait at once: nothing will land it.
3430
+ *
3431
+ * Polls the queue rather than draining it: the worker's own per-workspace
3432
+ * serialization stays the only writer to the clone, and the row is deleted
3433
+ * only after its push, so "no row" means the remote holds the commit.
3434
+ */
3435
+ async awaitPendingCommits(branch) {
3436
+ const { timeoutMs, pollMs } = this.mergeWaitForPendingCommits;
3437
+ const deadline = Date.now() + timeoutMs;
3438
+ for (;;) {
3439
+ const { queued, stuck, needsAttention } = await this.pendingCommits.queuedOnBranch(branch);
3440
+ if (needsAttention !== null)
3441
+ return { ok: false, pending: queued + stuck, needsAttention };
3442
+ if (queued === 0)
3443
+ return { ok: true };
3444
+ if (Date.now() >= deadline)
3445
+ return { ok: false, pending: queued };
3446
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
3447
+ }
3448
+ }
3449
+ /** The one question `mergeBranch` refuses on: is a request from `source` into `target` open? */
3450
+ async openRequestBlockingMerge(sourceBranch, targetBranch) {
3451
+ const [open] = await this.db
3452
+ .select({ number: changeRequests.number })
3453
+ .from(changeRequests)
3454
+ .where(and(eq(changeRequests.sourceBranch, sourceBranch), eq(changeRequests.targetBranch, targetBranch), eq(changeRequests.state, 'open')))
3455
+ .limit(1);
3456
+ return open ? open.number : null;
3457
+ }
2814
3458
  async mergeBranch(user, sourceBranch, targetBranch) {
2815
3459
  if (sourceBranch === targetBranch) {
2816
3460
  throw new WorkflowValidationError(`\`source\` and \`target\` are both "${sourceBranch}" — name two different branches.`);
2817
3461
  }
3462
+ // Asked once BEFORE waiting, so a merge the open request forbids is refused
3463
+ // at once rather than after the source's commits have landed. Not the
3464
+ // answer that counts — that one is asked again under the locks below.
3465
+ const openBefore = await this.openRequestBlockingMerge(sourceBranch, targetBranch);
3466
+ if (openBefore !== null)
3467
+ throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, openBefore);
3468
+ // Writes are committed asynchronously: a write tool answers once the bytes
3469
+ // are on disk and the pending-commits worker commits and pushes them a few
3470
+ // seconds later. The merge below merges what the REMOTE holds of the
3471
+ // source, so issued in those seconds it would find nothing new and land
3472
+ // nothing. Wait for them first — OUTSIDE the lifecycle locks, which guard
3473
+ // creating, deleting and opening requests on these branches, and must not
3474
+ // be held for seconds of waiting on a queue.
3475
+ const settled = await this.awaitPendingCommits(sourceBranch);
3476
+ if (!settled.ok) {
3477
+ const message = settled.needsAttention
3478
+ ? `A commit queued on "${sourceBranch}" failed and needs a person: ${settled.needsAttention} ` +
3479
+ `Nothing was merged. Tell the user; merge again once it has been resolved.`
3480
+ : `${settled.pending} write(s) on "${sourceBranch}" are still being committed. ` +
3481
+ `Nothing was merged. Retry the merge shortly.`;
3482
+ return {
3483
+ kind: 'pending-commits',
3484
+ branch: sourceBranch,
3485
+ pending: settled.pending,
3486
+ ...(settled.needsAttention ? { needsAttention: settled.needsAttention } : {}),
3487
+ message,
3488
+ };
3489
+ }
2818
3490
  // Under the lifecycle lock of BOTH branches, the same protocol
2819
3491
  // `openChangeRequest` follows — and for the same window. That method holds
2820
3492
  // these keys across its whole check-then-insert (an auto-merge and a push,
@@ -2828,13 +3500,9 @@ export class WorkflowService {
2828
3500
  // A person merges a change request. Only the request's OWN direction is
2829
3501
  // blocked: the target merged into the source is how a draft under review
2830
3502
  // stays current, and that must keep working while the request is open.
2831
- const [open] = await this.db
2832
- .select({ number: changeRequests.number })
2833
- .from(changeRequests)
2834
- .where(and(eq(changeRequests.sourceBranch, sourceBranch), eq(changeRequests.targetBranch, targetBranch), eq(changeRequests.state, 'open')))
2835
- .limit(1);
2836
- if (open)
2837
- throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, open.number);
3503
+ const open = await this.openRequestBlockingMerge(sourceBranch, targetBranch);
3504
+ if (open !== null)
3505
+ throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, open);
2838
3506
  // The merge runs in the target's own workspace, like a change request's.
2839
3507
  const targetWorkspaceId = (await this.workspaceService.getOrCreateForBranch(targetBranch)).id;
2840
3508
  if (!(await this.git.remoteBranchExists(targetWorkspaceId, sourceBranch))) {
@@ -2894,6 +3562,13 @@ export class WorkflowService {
2894
3562
  return { kind: 'conflicts-need-resolution', conflictedPaths: result.paths };
2895
3563
  }
2896
3564
  await this.pullMergeTarget(targetBranch, user);
3565
+ // No merge commit: the target already held everything on the source, and
3566
+ // `sha` is its unchanged tip. Reported as such — a `merged` that applied
3567
+ // nothing is what sent agents on as if their write had landed.
3568
+ if (result.mergeCommit === null)
3569
+ return { kind: 'nothing-to-merge', sha: result.sha };
3570
+ // What landed on the target may have emptied open requests into it.
3571
+ this.kickDeletedBranchSweep();
2897
3572
  return { kind: 'merged', sha: result.sha };
2898
3573
  });
2899
3574
  }
@@ -3143,8 +3818,9 @@ export class WorkflowService {
3143
3818
  // sweep those unrelated edits into our "preserve roles.yaml" commit under the
3144
3819
  // wrong author/message. Feature branch → the protected-branch gate doesn't
3145
3820
  // fire, so this commits cleanly. Then publish so the merge sees the
3146
- // neutralised source. A non-fast-forward push (the source advanced under us)
3147
- // throws → RolesYamlPreservationError → the merge aborts, fail-closed.
3821
+ // neutralised source. A push that still fails after the cooperative
3822
+ // pull-rebase throws PushNeedsAgentResolutionError → the merge aborts,
3823
+ // fail-closed, with the restore kept locally for the next push.
3148
3824
  const committed = await this.git.commitFile(ws.id, user, ROLES_YAML, 'Preserve official roles.yaml (not editable via change request)', true);
3149
3825
  // We are past the `baseRoles === headRoles` check, so origin/<head> and
3150
3826
  // origin/<base> DO differ — a restore commit is REQUIRED to protect the
@@ -3153,10 +3829,27 @@ export class WorkflowService {
3153
3829
  // with origin/<head>), then origin/<head> STILL carries the divergent
3154
3830
  // roles.yaml and the merge would land it. Treat that as a hard failure —
3155
3831
  // returning false here would fail OPEN. Fail closed instead.
3156
- if (!committed) {
3832
+ //
3833
+ // One shape of "nothing to commit" is not out of sync: a restore an
3834
+ // earlier merge attempt committed, whose push the host refused. The
3835
+ // clone's HEAD already carries the base version — that is why there
3836
+ // was nothing new to commit — and only the push is owed. Without this,
3837
+ // the retry the refusal promises could never succeed: every attempt
3838
+ // would find the file already restored and stop here, and the merge
3839
+ // would stay refused until some unrelated save pushed the branch.
3840
+ //
3841
+ // The question is NOT "does the clone hold unpushed commits" — any
3842
+ // queued save answers yes. It is whether HEAD already holds all of
3843
+ // origin/<head>, so that the push fast-forwards origin to a head whose
3844
+ // roles.yaml is the base version. A clone BEHIND origin would have
3845
+ // origin's divergent copy rebased into HEAD by the cooperative pull on
3846
+ // the way to the push, and the merge would land it: that clone is the
3847
+ // out-of-sync one this guard refuses. A probe that cannot answer
3848
+ // throws, and the guard fails closed with it.
3849
+ if (!committed && !(await this.git.headContainsOrigin(ws.id))) {
3157
3850
  throw new Error('roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge');
3158
3851
  }
3159
- await this.trackedPush(ws.id, user);
3852
+ await this.pushWithRecovery(ws.id, headBranch, ROLES_YAML, user);
3160
3853
  this.accessControl.invalidate(ws.id);
3161
3854
  return true;
3162
3855
  }
@@ -3167,6 +3860,13 @@ export class WorkflowService {
3167
3860
  }
3168
3861
  }
3169
3862
  catch (err) {
3863
+ // A refused push: the restore is committed on the source locally and the
3864
+ // merge does not run (origin still carries the divergent roles.yaml).
3865
+ // Answer it like every other refused push — 409, the saved-locally
3866
+ // sentence, the banner already raised — so the merge can be retried
3867
+ // once the host accepts the push.
3868
+ if (err instanceof PushNeedsAgentResolutionError)
3869
+ throw err;
3170
3870
  const detail = err instanceof Error ? err.message : String(err);
3171
3871
  // Log the raw git/push detail server-side; the thrown error keeps it OFF
3172
3872
  // the client-facing 502 message (see RolesYamlPreservationError).