@bevel-software/platform-core-backend 0.14.0 → 0.19.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 (1307) hide show
  1. package/THIRD-PARTY-NOTICES.md +8 -8
  2. package/dist/core/catalog-cache-invalidation.d.ts +45 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -0
  4. package/dist/core/catalog-cache-invalidation.js +76 -0
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -0
  6. package/dist/core/catalog-revision.d.ts +39 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -0
  8. package/dist/core/catalog-revision.js +94 -0
  9. package/dist/core/catalog-revision.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +7 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -1
  12. package/dist/core/create-core-server.js +279 -64
  13. package/dist/core/create-core-server.js.map +1 -1
  14. package/dist/core/create-core-services.d.ts +66 -2
  15. package/dist/core/create-core-services.d.ts.map +1 -1
  16. package/dist/core/create-core-services.js +290 -111
  17. package/dist/core/create-core-services.js.map +1 -1
  18. package/dist/core/lifecycle.d.ts +138 -0
  19. package/dist/core/lifecycle.d.ts.map +1 -0
  20. package/dist/core/lifecycle.js +218 -0
  21. package/dist/core/lifecycle.js.map +1 -0
  22. package/dist/core/public-config.d.ts +82 -0
  23. package/dist/core/public-config.d.ts.map +1 -0
  24. package/dist/core/public-config.js +78 -0
  25. package/dist/core/public-config.js.map +1 -0
  26. package/dist/core/readiness.d.ts +86 -0
  27. package/dist/core/readiness.d.ts.map +1 -0
  28. package/dist/core/readiness.js +56 -0
  29. package/dist/core/readiness.js.map +1 -0
  30. package/dist/core-config.d.ts +30 -0
  31. package/dist/core-config.d.ts.map +1 -1
  32. package/dist/core-config.js +64 -2
  33. package/dist/core-config.js.map +1 -1
  34. package/dist/index.d.ts +4 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/modules/access/access-control.interface.d.ts +231 -15
  39. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  40. package/dist/modules/access/access-control.service.d.ts +83 -12
  41. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  42. package/dist/modules/access/access-control.service.js +815 -212
  43. package/dist/modules/access/access-control.service.js.map +1 -1
  44. package/dist/modules/access/access-mutation.service.d.ts +71 -5
  45. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  46. package/dist/modules/access/access-mutation.service.js +161 -13
  47. package/dist/modules/access/access-mutation.service.js.map +1 -1
  48. package/dist/modules/access/access-view.d.ts +147 -0
  49. package/dist/modules/access/access-view.d.ts.map +1 -0
  50. package/dist/modules/access/access-view.js +211 -0
  51. package/dist/modules/access/access-view.js.map +1 -0
  52. package/dist/modules/access/access.routes.d.ts.map +1 -1
  53. package/dist/modules/access/access.routes.js +312 -84
  54. package/dist/modules/access/access.routes.js.map +1 -1
  55. package/dist/modules/access/admin-locked-commit.d.ts +2 -3
  56. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  57. package/dist/modules/access/admin-locked-commit.js +9 -8
  58. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  59. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -1
  60. package/dist/modules/access/admin-route-helpers.js +4 -4
  61. package/dist/modules/access/admin-route-helpers.js.map +1 -1
  62. package/dist/modules/access/change-read-gate.d.ts +53 -0
  63. package/dist/modules/access/change-read-gate.d.ts.map +1 -0
  64. package/dist/modules/access/change-read-gate.js +134 -0
  65. package/dist/modules/access/change-read-gate.js.map +1 -0
  66. package/dist/modules/access/creator-access.d.ts +33 -39
  67. package/dist/modules/access/creator-access.d.ts.map +1 -1
  68. package/dist/modules/access/creator-access.js +92 -119
  69. package/dist/modules/access/creator-access.js.map +1 -1
  70. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  71. package/dist/modules/access/directory-sync-bot.js +3 -1
  72. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  73. package/dist/modules/access/groups-admin.service.d.ts +3 -3
  74. package/dist/modules/access/groups-admin.service.js +2 -2
  75. package/dist/modules/access/reference-scan.d.ts +1 -1
  76. package/dist/modules/access/reference-scan.d.ts.map +1 -1
  77. package/dist/modules/access/reference-scan.js +5 -4
  78. package/dist/modules/access/reference-scan.js.map +1 -1
  79. package/dist/modules/access/roles-admin.service.d.ts +72 -13
  80. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  81. package/dist/modules/access/roles-admin.service.js +71 -13
  82. package/dist/modules/access/roles-admin.service.js.map +1 -1
  83. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -1
  84. package/dist/modules/access/synced-groups-committer.js +8 -6
  85. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -1
  87. package/dist/modules/access/synced-groups-writer.js +3 -8
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -1
  89. package/dist/modules/access/user-access-removal.service.d.ts +209 -0
  90. package/dist/modules/access/user-access-removal.service.d.ts.map +1 -0
  91. package/dist/modules/access/user-access-removal.service.js +662 -0
  92. package/dist/modules/access/user-access-removal.service.js.map +1 -0
  93. package/dist/modules/access-model/access-errors.d.ts +32 -1
  94. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  95. package/dist/modules/access-model/access-errors.js +38 -11
  96. package/dist/modules/access-model/access-errors.js.map +1 -1
  97. package/dist/modules/access-model/access-grammar.d.ts +100 -25
  98. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  99. package/dist/modules/access-model/access-grammar.js +162 -48
  100. package/dist/modules/access-model/access-grammar.js.map +1 -1
  101. package/dist/modules/access-model/access-splice.d.ts.map +1 -1
  102. package/dist/modules/access-model/access-splice.js +18 -18
  103. package/dist/modules/access-model/access-splice.js.map +1 -1
  104. package/dist/modules/access-model/access-template.d.ts +22 -0
  105. package/dist/modules/access-model/access-template.d.ts.map +1 -0
  106. package/dist/modules/access-model/access-template.js +42 -0
  107. package/dist/modules/access-model/access-template.js.map +1 -0
  108. package/dist/modules/access-model/change-gate.d.ts +65 -0
  109. package/dist/modules/access-model/change-gate.d.ts.map +1 -0
  110. package/dist/modules/access-model/change-gate.js +25 -0
  111. package/dist/modules/access-model/change-gate.js.map +1 -0
  112. package/dist/modules/access-model/creator.d.ts +13 -16
  113. package/dist/modules/access-model/creator.d.ts.map +1 -1
  114. package/dist/modules/access-model/creator.js.map +1 -1
  115. package/dist/modules/access-model/frontmatter-lines.d.ts +27 -0
  116. package/dist/modules/access-model/frontmatter-lines.d.ts.map +1 -0
  117. package/dist/modules/access-model/frontmatter-lines.js +74 -0
  118. package/dist/modules/access-model/frontmatter-lines.js.map +1 -0
  119. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  120. package/dist/modules/access-model/group-files.js +4 -1
  121. package/dist/modules/access-model/group-files.js.map +1 -1
  122. package/dist/modules/access-model/plugin-principals.d.ts +51 -0
  123. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  124. package/dist/modules/access-model/plugin-principals.js +119 -0
  125. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  126. package/dist/modules/access-model/roles-yaml-guard.d.ts +73 -2
  127. package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -1
  128. package/dist/modules/access-model/roles-yaml-guard.js +153 -3
  129. package/dist/modules/access-model/roles-yaml-guard.js.map +1 -1
  130. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  131. package/dist/modules/admin/admin-access.service.js +5 -2
  132. package/dist/modules/admin/admin-access.service.js.map +1 -1
  133. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +21 -0
  134. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -0
  135. package/dist/modules/agent-instructions/agent-instructions.routes.js +38 -0
  136. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -0
  137. package/dist/modules/agent-instructions/compose.d.ts +71 -0
  138. package/dist/modules/agent-instructions/compose.d.ts.map +1 -0
  139. package/dist/modules/agent-instructions/compose.js +234 -0
  140. package/dist/modules/agent-instructions/compose.js.map +1 -0
  141. package/dist/modules/agent-instructions/index.d.ts +4 -0
  142. package/dist/modules/agent-instructions/index.d.ts.map +1 -0
  143. package/dist/modules/agent-instructions/index.js +4 -0
  144. package/dist/modules/agent-instructions/index.js.map +1 -0
  145. package/dist/modules/agent-instructions/read-preamble.d.ts +37 -0
  146. package/dist/modules/agent-instructions/read-preamble.d.ts.map +1 -0
  147. package/dist/modules/agent-instructions/read-preamble.js +81 -0
  148. package/dist/modules/agent-instructions/read-preamble.js.map +1 -0
  149. package/dist/modules/auth/account-erasure.service.d.ts +30 -4
  150. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  151. package/dist/modules/auth/account-erasure.service.js +96 -10
  152. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  153. package/dist/modules/auth/account.routes.d.ts +6 -3
  154. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  155. package/dist/modules/auth/account.routes.js +109 -10
  156. package/dist/modules/auth/account.routes.js.map +1 -1
  157. package/dist/modules/auth/auth.routes.d.ts +10 -3
  158. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  159. package/dist/modules/auth/auth.routes.js +9 -4
  160. package/dist/modules/auth/auth.routes.js.map +1 -1
  161. package/dist/modules/auth/auth.service.d.ts +89 -5
  162. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  163. package/dist/modules/auth/auth.service.js +160 -25
  164. package/dist/modules/auth/auth.service.js.map +1 -1
  165. package/dist/modules/auth/oidc-auth-provider.d.ts +46 -3
  166. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  167. package/dist/modules/auth/oidc-auth-provider.js +99 -21
  168. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  169. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  170. package/dist/modules/code-mode/code-mode.tool.js +14 -2
  171. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  172. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  173. package/dist/modules/connection-probe/connection-probe.service.js +56 -7
  174. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  175. package/dist/modules/database/advisory-lock.d.ts +103 -0
  176. package/dist/modules/database/advisory-lock.d.ts.map +1 -0
  177. package/dist/modules/database/advisory-lock.js +320 -0
  178. package/dist/modules/database/advisory-lock.js.map +1 -0
  179. package/dist/modules/database/connection.d.ts.map +1 -1
  180. package/dist/modules/database/connection.js +9 -1
  181. package/dist/modules/database/connection.js.map +1 -1
  182. package/dist/modules/database/core-schema.d.ts +672 -0
  183. package/dist/modules/database/core-schema.d.ts.map +1 -1
  184. package/dist/modules/database/core-schema.js +171 -0
  185. package/dist/modules/database/core-schema.js.map +1 -1
  186. package/dist/modules/database/migrate.d.ts.map +1 -1
  187. package/dist/modules/database/migrate.js +26 -6
  188. package/dist/modules/database/migrate.js.map +1 -1
  189. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
  190. package/dist/modules/declared-variables/declared-variables.routes.js +4 -2
  191. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
  192. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  193. package/dist/modules/diff/diff.routes.js +2 -4
  194. package/dist/modules/diff/diff.routes.js.map +1 -1
  195. package/dist/modules/diff/diff.service.d.ts +4 -1
  196. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  197. package/dist/modules/diff/diff.service.js +79 -81
  198. package/dist/modules/diff/diff.service.js.map +1 -1
  199. package/dist/modules/kb-fs/bevel-ignore.d.ts +35 -0
  200. package/dist/modules/kb-fs/bevel-ignore.d.ts.map +1 -0
  201. package/dist/modules/kb-fs/bevel-ignore.js +71 -0
  202. package/dist/modules/kb-fs/bevel-ignore.js.map +1 -0
  203. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  204. package/dist/modules/kb-fs/file-change-notifier.js +3 -1
  205. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  206. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +37 -0
  207. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -0
  208. package/dist/modules/kb-fs/git-guarded-filesystem.js +104 -0
  209. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -0
  210. package/dist/modules/kb-fs/locking-filesystem.d.ts +113 -18
  211. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  212. package/dist/modules/kb-fs/locking-filesystem.js +353 -76
  213. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  214. package/dist/modules/kb-fs/node-fs.d.ts +23 -0
  215. package/dist/modules/kb-fs/node-fs.d.ts.map +1 -0
  216. package/dist/modules/kb-fs/node-fs.js +158 -0
  217. package/dist/modules/kb-fs/node-fs.js.map +1 -0
  218. package/dist/modules/kb-fs/read-only-filesystem.d.ts +10 -9
  219. package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -1
  220. package/dist/modules/kb-fs/read-only-filesystem.js +18 -9
  221. package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -1
  222. package/dist/modules/kb-fs/repo-path.d.ts +100 -3
  223. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  224. package/dist/modules/kb-fs/repo-path.js +195 -3
  225. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  226. package/dist/modules/kb-sync/index.d.ts +6 -0
  227. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  228. package/dist/modules/kb-sync/index.js +5 -0
  229. package/dist/modules/kb-sync/index.js.map +1 -0
  230. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  231. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  232. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  233. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  234. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  235. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  236. package/dist/modules/kb-sync/kb-sync.routes.js +137 -0
  237. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  238. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  239. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  240. package/dist/modules/kb-sync/kb-sync.service.js +185 -0
  241. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  242. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  243. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  244. package/dist/modules/kb-sync/sync-auth.js +62 -0
  245. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  246. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  247. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  248. package/dist/modules/kb-sync/sync-payload.js +105 -0
  249. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  250. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  251. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  252. package/dist/modules/marketplace/git-http.routes.js +192 -0
  253. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  254. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +33 -0
  255. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  256. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +88 -0
  257. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  258. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  259. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  260. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  261. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  262. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +126 -0
  263. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  264. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +192 -0
  265. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  266. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  267. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  268. package/dist/modules/marketplace/github-facade/github-facade.routes.js +252 -0
  269. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  270. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +121 -0
  271. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  272. package/dist/modules/marketplace/github-facade/github-facade.service.js +213 -0
  273. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  274. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  275. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  276. package/dist/modules/marketplace/github-facade/index.js +6 -0
  277. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  278. package/dist/modules/marketplace/index.d.ts +4 -0
  279. package/dist/modules/marketplace/index.d.ts.map +1 -0
  280. package/dist/modules/marketplace/index.js +4 -0
  281. package/dist/modules/marketplace/index.js.map +1 -0
  282. package/dist/modules/marketplace/marketplace-repo.service.d.ts +135 -0
  283. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  284. package/dist/modules/marketplace/marketplace-repo.service.js +318 -0
  285. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  286. package/dist/modules/mcp/downstream-pool.d.ts +107 -0
  287. package/dist/modules/mcp/downstream-pool.d.ts.map +1 -0
  288. package/dist/modules/mcp/downstream-pool.js +221 -0
  289. package/dist/modules/mcp/downstream-pool.js.map +1 -0
  290. package/dist/modules/mcp/downstream-token-refresh.d.ts +77 -0
  291. package/dist/modules/mcp/downstream-token-refresh.d.ts.map +1 -0
  292. package/dist/modules/mcp/downstream-token-refresh.js +166 -0
  293. package/dist/modules/mcp/downstream-token-refresh.js.map +1 -0
  294. package/dist/modules/mcp/first-call-probe.cli-args.d.ts +78 -0
  295. package/dist/modules/mcp/first-call-probe.cli-args.d.ts.map +1 -0
  296. package/dist/modules/mcp/first-call-probe.cli-args.js +151 -0
  297. package/dist/modules/mcp/first-call-probe.cli-args.js.map +1 -0
  298. package/dist/modules/mcp/first-call-probe.cli.d.ts +2 -0
  299. package/dist/modules/mcp/first-call-probe.cli.d.ts.map +1 -0
  300. package/dist/modules/mcp/first-call-probe.cli.js +62 -0
  301. package/dist/modules/mcp/first-call-probe.cli.js.map +1 -0
  302. package/dist/modules/mcp/first-call-probe.d.ts +165 -0
  303. package/dist/modules/mcp/first-call-probe.d.ts.map +1 -0
  304. package/dist/modules/mcp/first-call-probe.js +298 -0
  305. package/dist/modules/mcp/first-call-probe.js.map +1 -0
  306. package/dist/modules/mcp/manual-failure-memo.d.ts +42 -17
  307. package/dist/modules/mcp/manual-failure-memo.d.ts.map +1 -1
  308. package/dist/modules/mcp/manual-failure-memo.js +79 -23
  309. package/dist/modules/mcp/manual-failure-memo.js.map +1 -1
  310. package/dist/modules/mcp/mcp-auth.middleware.d.ts +9 -5
  311. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  312. package/dist/modules/mcp/mcp-auth.middleware.js +24 -16
  313. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  314. package/dist/modules/mcp/mcp.routes.d.ts +4 -4
  315. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  316. package/dist/modules/mcp/mcp.routes.js +83 -179
  317. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  318. package/dist/modules/mcp/mcp.service.d.ts +218 -61
  319. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  320. package/dist/modules/mcp/mcp.service.js +713 -159
  321. package/dist/modules/mcp/mcp.service.js.map +1 -1
  322. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  323. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +8 -2
  324. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  325. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  326. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  327. package/dist/modules/mcp/oauth/oauth-consent.routes.js +16 -2
  328. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  329. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  330. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  331. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  332. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  333. package/dist/modules/mcp/surface-log-throttle.d.ts +59 -0
  334. package/dist/modules/mcp/surface-log-throttle.d.ts.map +1 -0
  335. package/dist/modules/mcp/surface-log-throttle.js +94 -0
  336. package/dist/modules/mcp/surface-log-throttle.js.map +1 -0
  337. package/dist/modules/plugins/compile/compile-marketplace.d.ts +104 -0
  338. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  339. package/dist/modules/plugins/compile/compile-marketplace.js +323 -0
  340. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  341. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +73 -0
  342. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  343. package/dist/modules/plugins/compile/marketplace-compiler.service.js +135 -0
  344. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  345. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  346. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  347. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +158 -0
  348. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  349. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +65 -0
  350. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  351. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +204 -0
  352. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  353. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +39 -0
  354. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  355. package/dist/modules/plugins/discovery/kb-plugin-source.js +110 -0
  356. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  357. package/dist/modules/plugins/discovery/native.source.d.ts +16 -0
  358. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  359. package/dist/modules/plugins/discovery/native.source.js +120 -0
  360. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  361. package/dist/modules/plugins/discovery/plugin-source.d.ts +110 -0
  362. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  363. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  364. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  365. package/dist/modules/plugins/index.d.ts +15 -3
  366. package/dist/modules/plugins/index.d.ts.map +1 -1
  367. package/dist/modules/plugins/index.js +13 -2
  368. package/dist/modules/plugins/index.js.map +1 -1
  369. package/dist/modules/plugins/join-request-jobs.service.d.ts +250 -0
  370. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -0
  371. package/dist/modules/plugins/join-request-jobs.service.js +602 -0
  372. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -0
  373. package/dist/modules/plugins/join-request-records.store.d.ts +161 -0
  374. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -0
  375. package/dist/modules/plugins/join-request-records.store.js +179 -0
  376. package/dist/modules/plugins/join-request-records.store.js.map +1 -0
  377. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  378. package/dist/modules/plugins/join-requests.service.js +4 -2
  379. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  380. package/dist/modules/plugins/plugin-links.d.ts +69 -0
  381. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  382. package/dist/modules/plugins/plugin-links.js +163 -0
  383. package/dist/modules/plugins/plugin-links.js.map +1 -0
  384. package/dist/modules/plugins/plugin-links.service.d.ts +111 -0
  385. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  386. package/dist/modules/plugins/plugin-links.service.js +295 -0
  387. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  388. package/dist/modules/plugins/plugin-provision.service.d.ts +129 -21
  389. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  390. package/dist/modules/plugins/plugin-provision.service.js +320 -75
  391. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  392. package/dist/modules/plugins/plugin-rename.service.d.ts +71 -0
  393. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  394. package/dist/modules/plugins/plugin-rename.service.js +254 -0
  395. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  396. package/dist/modules/plugins/plugins.contract.d.ts +78 -4
  397. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  398. package/dist/modules/plugins/plugins.routes.d.ts +44 -5
  399. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  400. package/dist/modules/plugins/plugins.routes.js +297 -115
  401. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  402. package/dist/modules/plugins/plugins.service.d.ts +82 -3
  403. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  404. package/dist/modules/plugins/plugins.service.js +178 -48
  405. package/dist/modules/plugins/plugins.service.js.map +1 -1
  406. package/dist/modules/plugins/plugins.tools.d.ts +25 -0
  407. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -0
  408. package/dist/modules/plugins/plugins.tools.js +82 -0
  409. package/dist/modules/plugins/plugins.tools.js.map +1 -0
  410. package/dist/modules/plugins/teams.routes.d.ts +42 -0
  411. package/dist/modules/plugins/teams.routes.d.ts.map +1 -0
  412. package/dist/modules/plugins/teams.routes.js +79 -0
  413. package/dist/modules/plugins/teams.routes.js.map +1 -0
  414. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +40 -1
  415. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  416. package/dist/modules/secrets-vault/db-secrets-vault.service.js +202 -37
  417. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  418. package/dist/modules/secrets-vault/index.d.ts +1 -1
  419. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  420. package/dist/modules/secrets-vault/index.js.map +1 -1
  421. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  422. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +5 -3
  423. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  424. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -1
  425. package/dist/modules/secrets-vault/secrets-variable-loader.js +3 -1
  426. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -1
  427. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +78 -0
  428. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  429. package/dist/modules/secrets-vault/secrets-vault.contract.js +40 -0
  430. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  431. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  432. package/dist/modules/secrets-vault/secrets-vault.routes.js +106 -52
  433. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  434. package/dist/modules/settings/connection-check.d.ts +118 -0
  435. package/dist/modules/settings/connection-check.d.ts.map +1 -0
  436. package/dist/modules/settings/connection-check.js +287 -0
  437. package/dist/modules/settings/connection-check.js.map +1 -0
  438. package/dist/modules/settings/deployment-settings.service.d.ts +157 -2
  439. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  440. package/dist/modules/settings/deployment-settings.service.js +412 -35
  441. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  442. package/dist/modules/settings/git-root-folders.d.ts +54 -0
  443. package/dist/modules/settings/git-root-folders.d.ts.map +1 -0
  444. package/dist/modules/settings/git-root-folders.js +130 -0
  445. package/dist/modules/settings/git-root-folders.js.map +1 -0
  446. package/dist/modules/settings/oidc-check.d.ts +85 -0
  447. package/dist/modules/settings/oidc-check.d.ts.map +1 -0
  448. package/dist/modules/settings/oidc-check.js +172 -0
  449. package/dist/modules/settings/oidc-check.js.map +1 -0
  450. package/dist/modules/settings/setup.routes.d.ts +57 -1
  451. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  452. package/dist/modules/settings/setup.routes.js +408 -108
  453. package/dist/modules/settings/setup.routes.js.map +1 -1
  454. package/dist/modules/skills/allowed-tools-check.d.ts +132 -0
  455. package/dist/modules/skills/allowed-tools-check.d.ts.map +1 -0
  456. package/dist/modules/skills/allowed-tools-check.js +260 -0
  457. package/dist/modules/skills/allowed-tools-check.js.map +1 -0
  458. package/dist/modules/skills/index.d.ts +4 -1
  459. package/dist/modules/skills/index.d.ts.map +1 -1
  460. package/dist/modules/skills/index.js +2 -0
  461. package/dist/modules/skills/index.js.map +1 -1
  462. package/dist/modules/skills/pending-skills.service.d.ts +6 -11
  463. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  464. package/dist/modules/skills/pending-skills.service.js +24 -71
  465. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  466. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  467. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  468. package/dist/modules/skills/skill-access-requests.routes.js +171 -0
  469. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  470. package/dist/modules/skills/skills.contract.d.ts +67 -2
  471. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  472. package/dist/modules/skills/skills.routes.d.ts +35 -3
  473. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  474. package/dist/modules/skills/skills.routes.js +57 -4
  475. package/dist/modules/skills/skills.routes.js.map +1 -1
  476. package/dist/modules/skills/skills.service.d.ts +31 -3
  477. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  478. package/dist/modules/skills/skills.service.js +236 -65
  479. package/dist/modules/skills/skills.service.js.map +1 -1
  480. package/dist/modules/skills/skills.tools.d.ts +4 -1
  481. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  482. package/dist/modules/skills/skills.tools.js +41 -6
  483. package/dist/modules/skills/skills.tools.js.map +1 -1
  484. package/dist/modules/tool-auth/connection-key-rejection.d.ts +18 -0
  485. package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -0
  486. package/dist/modules/tool-auth/connection-key-rejection.js +20 -0
  487. package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -0
  488. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts +14 -0
  489. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts.map +1 -0
  490. package/dist/modules/tool-auth/connection-keys-admin.routes.js +67 -0
  491. package/dist/modules/tool-auth/connection-keys-admin.routes.js.map +1 -0
  492. package/dist/modules/tool-auth/external-api-key.interface.d.ts +66 -1
  493. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  494. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  495. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  496. package/dist/modules/tool-auth/external-api-key.service.d.ts +23 -4
  497. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  498. package/dist/modules/tool-auth/external-api-key.service.js +79 -19
  499. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  500. package/dist/modules/tool-auth/internal-token.service.d.ts +5 -2
  501. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  502. package/dist/modules/tool-auth/internal-token.service.js +5 -2
  503. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  504. package/dist/modules/tool-auth/key-or-session.middleware.d.ts +23 -0
  505. package/dist/modules/tool-auth/key-or-session.middleware.d.ts.map +1 -0
  506. package/dist/modules/tool-auth/key-or-session.middleware.js +39 -0
  507. package/dist/modules/tool-auth/key-or-session.middleware.js.map +1 -0
  508. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +7 -1
  509. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  510. package/dist/modules/tool-auth/tool-auth.middleware.js +8 -5
  511. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  512. package/dist/modules/tool-helpers/tool-context.d.ts +14 -0
  513. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  514. package/dist/modules/tool-helpers/tool-context.js +28 -6
  515. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  516. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  517. package/dist/modules/tool-helpers/tool-handler.js +22 -5
  518. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  519. package/dist/modules/tool-helpers/tool.contract.d.ts +6 -1
  520. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  521. package/dist/modules/tool-helpers/tool.contract.js +7 -1
  522. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  523. package/dist/modules/tool-manuals/index.d.ts +2 -1
  524. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  525. package/dist/modules/tool-manuals/index.js +1 -0
  526. package/dist/modules/tool-manuals/index.js.map +1 -1
  527. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  528. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  529. package/dist/modules/tool-manuals/mcp-json-discovery.js +136 -96
  530. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  531. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +10 -2
  532. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  533. package/dist/modules/tool-manuals/mcp-server-edit.service.js +12 -15
  534. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  535. package/dist/modules/tool-manuals/pending-tools.service.d.ts +50 -0
  536. package/dist/modules/tool-manuals/pending-tools.service.d.ts.map +1 -0
  537. package/dist/modules/tool-manuals/pending-tools.service.js +149 -0
  538. package/dist/modules/tool-manuals/pending-tools.service.js.map +1 -0
  539. package/dist/modules/tool-manuals/tool-delete.service.d.ts +135 -0
  540. package/dist/modules/tool-manuals/tool-delete.service.d.ts.map +1 -0
  541. package/dist/modules/tool-manuals/tool-delete.service.js +303 -0
  542. package/dist/modules/tool-manuals/tool-delete.service.js.map +1 -0
  543. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +161 -0
  544. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  545. package/dist/modules/tool-manuals/tool-manuals.contract.js +9 -0
  546. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  547. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +19 -2
  548. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  549. package/dist/modules/tool-manuals/tool-manuals.routes.js +127 -57
  550. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  551. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +121 -2
  552. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  553. package/dist/modules/tool-manuals/tool-manuals.service.js +415 -92
  554. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  555. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  556. package/dist/modules/tool-manuals/tool-manuals.tools.js +78 -6
  557. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  558. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  559. package/dist/modules/workflow/agent-tools/workflow.tools.js +88 -54
  560. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  561. package/dist/modules/workflow/event-bus.d.ts +10 -4
  562. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  563. package/dist/modules/workflow/event-bus.js +14 -11
  564. package/dist/modules/workflow/event-bus.js.map +1 -1
  565. package/dist/modules/workflow/events.routes.d.ts +17 -2
  566. package/dist/modules/workflow/events.routes.d.ts.map +1 -1
  567. package/dist/modules/workflow/events.routes.js +31 -12
  568. package/dist/modules/workflow/events.routes.js.map +1 -1
  569. package/dist/modules/workflow/file-lock.service.d.ts +22 -4
  570. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  571. package/dist/modules/workflow/file-lock.service.js +27 -4
  572. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  573. package/dist/modules/workflow/git/change-request-link.d.ts +20 -0
  574. package/dist/modules/workflow/git/change-request-link.d.ts.map +1 -0
  575. package/dist/modules/workflow/git/change-request-link.js +34 -0
  576. package/dist/modules/workflow/git/change-request-link.js.map +1 -0
  577. package/dist/modules/workflow/git/git-version.d.ts.map +1 -1
  578. package/dist/modules/workflow/git/git-version.js +3 -1
  579. package/dist/modules/workflow/git/git-version.js.map +1 -1
  580. package/dist/modules/workflow/git/git.service.d.ts +303 -6
  581. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  582. package/dist/modules/workflow/git/git.service.js +753 -170
  583. package/dist/modules/workflow/git/git.service.js.map +1 -1
  584. package/dist/modules/workflow/git/node-git-runner.d.ts +36 -0
  585. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -0
  586. package/dist/modules/workflow/git/node-git-runner.js +273 -0
  587. package/dist/modules/workflow/git/node-git-runner.js.map +1 -0
  588. package/dist/modules/workflow/git/pull-request.service.d.ts +57 -2
  589. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  590. package/dist/modules/workflow/git/pull-request.service.js +165 -25
  591. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  592. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  593. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  594. package/dist/modules/workflow/pending-commits.service.js +24 -4
  595. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  596. package/dist/modules/workflow/pending-commits.worker.d.ts +15 -8
  597. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  598. package/dist/modules/workflow/pending-commits.worker.js +107 -58
  599. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  600. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  601. package/dist/modules/workflow/recovery-bot.js +3 -1
  602. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  603. package/dist/modules/workflow/review-workflow/approval-lock.d.ts +60 -0
  604. package/dist/modules/workflow/review-workflow/approval-lock.d.ts.map +1 -0
  605. package/dist/modules/workflow/review-workflow/approval-lock.js +116 -0
  606. package/dist/modules/workflow/review-workflow/approval-lock.js.map +1 -0
  607. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +54 -7
  608. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -1
  609. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +64 -0
  610. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  611. package/dist/modules/workflow/review-workflow/review-workflow.service.js +269 -90
  612. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  613. package/dist/modules/workflow/sanitize-error.d.ts +5 -2
  614. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
  615. package/dist/modules/workflow/sanitize-error.js +8 -4
  616. package/dist/modules/workflow/sanitize-error.js.map +1 -1
  617. package/dist/modules/workflow/workflow.routes.d.ts +2 -0
  618. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  619. package/dist/modules/workflow/workflow.routes.js +259 -29
  620. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  621. package/dist/modules/workflow/workflow.service.d.ts +217 -14
  622. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  623. package/dist/modules/workflow/workflow.service.js +1142 -109
  624. package/dist/modules/workflow/workflow.service.js.map +1 -1
  625. package/dist/modules/workspace/empty-dirs.d.ts +15 -0
  626. package/dist/modules/workspace/empty-dirs.d.ts.map +1 -0
  627. package/dist/modules/workspace/empty-dirs.js +90 -0
  628. package/dist/modules/workspace/empty-dirs.js.map +1 -0
  629. package/dist/modules/workspace/file-readers/content-mode.d.ts +50 -0
  630. package/dist/modules/workspace/file-readers/content-mode.d.ts.map +1 -0
  631. package/dist/modules/workspace/file-readers/content-mode.js +55 -0
  632. package/dist/modules/workspace/file-readers/content-mode.js.map +1 -0
  633. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +4 -1
  634. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
  635. package/dist/modules/workspace/file-readers/doc-extract.service.js +4 -1
  636. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
  637. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +5 -2
  638. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -1
  639. package/dist/modules/workspace/file-readers/doc-extract.types.js +7 -4
  640. package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -1
  641. package/dist/modules/workspace/file-readers/document-reader.d.ts +2 -0
  642. package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -1
  643. package/dist/modules/workspace/file-readers/document-reader.js +5 -0
  644. package/dist/modules/workspace/file-readers/document-reader.js.map +1 -1
  645. package/dist/modules/workspace/file-readers/extract-docx.d.ts +6 -0
  646. package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -1
  647. package/dist/modules/workspace/file-readers/extract-docx.js +6 -0
  648. package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -1
  649. package/dist/modules/workspace/file-readers/file-reader.d.ts +24 -5
  650. package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -1
  651. package/dist/modules/workspace/file-readers/file-reader.js +11 -6
  652. package/dist/modules/workspace/file-readers/file-reader.js.map +1 -1
  653. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -1
  654. package/dist/modules/workspace/file-readers/file-reader.registry.js +6 -1
  655. package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -1
  656. package/dist/modules/workspace/file-readers/image-reader.d.ts +7 -5
  657. package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -1
  658. package/dist/modules/workspace/file-readers/image-reader.js +9 -5
  659. package/dist/modules/workspace/file-readers/image-reader.js.map +1 -1
  660. package/dist/modules/workspace/file-readers/ooxml-text.d.ts +3 -0
  661. package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -1
  662. package/dist/modules/workspace/file-readers/ooxml-text.js +39 -2
  663. package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -1
  664. package/dist/modules/workspace/file-readers/text-reader.d.ts +29 -1
  665. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
  666. package/dist/modules/workspace/file-readers/text-reader.js +57 -3
  667. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
  668. package/dist/modules/workspace/git-internals.middleware.d.ts +28 -0
  669. package/dist/modules/workspace/git-internals.middleware.d.ts.map +1 -0
  670. package/dist/modules/workspace/git-internals.middleware.js +93 -0
  671. package/dist/modules/workspace/git-internals.middleware.js.map +1 -0
  672. package/dist/modules/workspace/not-found.d.ts +80 -0
  673. package/dist/modules/workspace/not-found.d.ts.map +1 -0
  674. package/dist/modules/workspace/not-found.js +112 -0
  675. package/dist/modules/workspace/not-found.js.map +1 -0
  676. package/dist/modules/workspace/session-ontology.gate.d.ts +1 -1
  677. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -1
  678. package/dist/modules/workspace/session-ontology.gate.js +1 -1
  679. package/dist/modules/workspace/session-ontology.gate.js.map +1 -1
  680. package/dist/modules/workspace/startup/beside-checkout.d.ts +42 -0
  681. package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -0
  682. package/dist/modules/workspace/startup/beside-checkout.js +110 -0
  683. package/dist/modules/workspace/startup/beside-checkout.js.map +1 -0
  684. package/dist/modules/workspace/startup/kb-git.d.ts +7 -12
  685. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  686. package/dist/modules/workspace/startup/kb-git.js +34 -35
  687. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  688. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +95 -0
  689. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  690. package/dist/modules/workspace/startup/kb-startup-runner.js +257 -55
  691. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  692. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +45 -0
  693. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -1
  694. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +417 -385
  695. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
  696. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts +48 -0
  697. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts.map +1 -0
  698. package/dist/modules/workspace/startup/steps/personal-spaces.step.js +183 -0
  699. package/dist/modules/workspace/startup/steps/personal-spaces.step.js.map +1 -0
  700. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts +51 -0
  701. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts.map +1 -0
  702. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js +158 -0
  703. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js.map +1 -0
  704. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts +64 -0
  705. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts.map +1 -0
  706. package/dist/modules/workspace/startup/steps/plugin-layout.js +97 -0
  707. package/dist/modules/workspace/startup/steps/plugin-layout.js.map +1 -0
  708. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +41 -0
  709. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  710. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +85 -0
  711. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  712. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +3 -1
  713. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -1
  714. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +4 -12
  715. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
  716. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +6 -1
  717. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  718. package/dist/modules/workspace/startup/steps/seed-tree.js +174 -54
  719. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  720. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +96 -24
  721. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  722. package/dist/modules/workspace/startup/steps/template-files.step.js +521 -126
  723. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  724. package/dist/modules/workspace/startup/steps/template-source.d.ts +94 -0
  725. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -0
  726. package/dist/modules/workspace/startup/steps/template-source.js +160 -0
  727. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -0
  728. package/dist/modules/workspace/workspace.routes.d.ts +14 -2
  729. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  730. package/dist/modules/workspace/workspace.routes.js +657 -246
  731. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  732. package/dist/modules/workspace/workspace.service.d.ts +323 -12
  733. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  734. package/dist/modules/workspace/workspace.service.js +1006 -257
  735. package/dist/modules/workspace/workspace.service.js.map +1 -1
  736. package/dist/modules/workspace/workspace.tools.d.ts +42 -1
  737. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  738. package/dist/modules/workspace/workspace.tools.js +1590 -146
  739. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  740. package/dist/modules/workspace/write-denial.d.ts +76 -0
  741. package/dist/modules/workspace/write-denial.d.ts.map +1 -0
  742. package/dist/modules/workspace/write-denial.js +138 -0
  743. package/dist/modules/workspace/write-denial.js.map +1 -0
  744. package/dist/shared/canonical-file-identity.d.ts +64 -0
  745. package/dist/shared/canonical-file-identity.d.ts.map +1 -0
  746. package/dist/shared/canonical-file-identity.js +92 -0
  747. package/dist/shared/canonical-file-identity.js.map +1 -0
  748. package/dist/shared/domain-errors.d.ts +128 -0
  749. package/dist/shared/domain-errors.d.ts.map +1 -1
  750. package/dist/shared/domain-errors.js +166 -0
  751. package/dist/shared/domain-errors.js.map +1 -1
  752. package/dist/shared/email-identity.d.ts +32 -0
  753. package/dist/shared/email-identity.d.ts.map +1 -0
  754. package/dist/shared/email-identity.js +37 -0
  755. package/dist/shared/email-identity.js.map +1 -0
  756. package/dist/shared/fs-errors.d.ts +8 -0
  757. package/dist/shared/fs-errors.d.ts.map +1 -0
  758. package/dist/shared/fs-errors.js +11 -0
  759. package/dist/shared/fs-errors.js.map +1 -0
  760. package/dist/shared/fs-walk.d.ts +20 -6
  761. package/dist/shared/fs-walk.d.ts.map +1 -1
  762. package/dist/shared/fs-walk.js +35 -27
  763. package/dist/shared/fs-walk.js.map +1 -1
  764. package/dist/shared/fs.contract.d.ts +206 -0
  765. package/dist/shared/fs.contract.d.ts.map +1 -0
  766. package/dist/shared/fs.contract.js +79 -0
  767. package/dist/shared/fs.contract.js.map +1 -0
  768. package/dist/shared/git-failure.d.ts +80 -0
  769. package/dist/shared/git-failure.d.ts.map +1 -0
  770. package/dist/shared/git-failure.js +139 -0
  771. package/dist/shared/git-failure.js.map +1 -0
  772. package/dist/shared/git-internals.d.ts +17 -0
  773. package/dist/shared/git-internals.d.ts.map +1 -0
  774. package/dist/shared/git-internals.js +123 -0
  775. package/dist/shared/git-internals.js.map +1 -0
  776. package/dist/shared/git.contract.d.ts +157 -0
  777. package/dist/shared/git.contract.d.ts.map +1 -0
  778. package/dist/shared/git.contract.js +103 -0
  779. package/dist/shared/git.contract.js.map +1 -0
  780. package/dist/shared/http-errors.d.ts +33 -0
  781. package/dist/shared/http-errors.d.ts.map +1 -0
  782. package/dist/shared/http-errors.js +35 -0
  783. package/dist/shared/http-errors.js.map +1 -0
  784. package/dist/shared/kb-walk.d.ts +52 -0
  785. package/dist/shared/kb-walk.d.ts.map +1 -0
  786. package/dist/shared/kb-walk.js +76 -0
  787. package/dist/shared/kb-walk.js.map +1 -0
  788. package/dist/shared/logger.contract.d.ts +63 -0
  789. package/dist/shared/logger.contract.d.ts.map +1 -0
  790. package/dist/shared/logger.contract.js +126 -0
  791. package/dist/shared/logger.contract.js.map +1 -0
  792. package/dist/shared/logging.d.ts +31 -0
  793. package/dist/shared/logging.d.ts.map +1 -0
  794. package/dist/shared/logging.js +92 -0
  795. package/dist/shared/logging.js.map +1 -0
  796. package/dist/shared/path-containment.d.ts +21 -0
  797. package/dist/shared/path-containment.d.ts.map +1 -0
  798. package/dist/shared/path-containment.js +34 -0
  799. package/dist/shared/path-containment.js.map +1 -0
  800. package/dist/shared/path-order.d.ts +18 -0
  801. package/dist/shared/path-order.d.ts.map +1 -0
  802. package/dist/shared/path-order.js +35 -0
  803. package/dist/shared/path-order.js.map +1 -0
  804. package/dist/shared/pending-proposals.d.ts +71 -0
  805. package/dist/shared/pending-proposals.d.ts.map +1 -0
  806. package/dist/shared/pending-proposals.js +98 -0
  807. package/dist/shared/pending-proposals.js.map +1 -0
  808. package/dist/shared/printable.d.ts +22 -0
  809. package/dist/shared/printable.d.ts.map +1 -0
  810. package/dist/shared/printable.js +65 -0
  811. package/dist/shared/printable.js.map +1 -0
  812. package/dist/shared/redact-secret.d.ts +30 -0
  813. package/dist/shared/redact-secret.d.ts.map +1 -0
  814. package/dist/shared/redact-secret.js +114 -0
  815. package/dist/shared/redact-secret.js.map +1 -0
  816. package/dist/shared/rename-no-replace.d.ts +119 -0
  817. package/dist/shared/rename-no-replace.d.ts.map +1 -0
  818. package/dist/shared/rename-no-replace.js +323 -0
  819. package/dist/shared/rename-no-replace.js.map +1 -0
  820. package/dist/shared/ssrf.d.ts.map +1 -1
  821. package/dist/shared/ssrf.js +49 -18
  822. package/dist/shared/ssrf.js.map +1 -1
  823. package/dist/shared/ttl-cache.d.ts +28 -1
  824. package/dist/shared/ttl-cache.d.ts.map +1 -1
  825. package/dist/shared/ttl-cache.js +33 -1
  826. package/dist/shared/ttl-cache.js.map +1 -1
  827. package/dist/version.d.ts +10 -6
  828. package/dist/version.d.ts.map +1 -1
  829. package/dist/version.js +10 -6
  830. package/dist/version.js.map +1 -1
  831. package/kb-template/.bevelignore +4 -3
  832. package/kb-template/AGENTS.md +364 -54
  833. package/kb-template/KnowledgeBase/How to get started.md +1 -1
  834. package/kb-template/Skills/.gitkeep +0 -0
  835. package/kb-template/access.md +5 -3
  836. package/kb-template/mcp-description.md +27 -0
  837. package/migrations/0005_github_facade.sql +25 -0
  838. package/migrations/0006_api_tokens_revoked_by.sql +1 -0
  839. package/migrations/0007_github_facade_registered.sql +1 -0
  840. package/migrations/0008_change_request_apply_failure.sql +4 -0
  841. package/migrations/0009_change_request_apply_failure_kind.sql +13 -0
  842. package/migrations/0010_plugin_join_requests.sql +15 -0
  843. package/migrations/0011_plugin_join_request_claim.sql +11 -0
  844. package/migrations/meta/0005_snapshot.json +1717 -0
  845. package/migrations/meta/0006_snapshot.json +1723 -0
  846. package/migrations/meta/0007_snapshot.json +1729 -0
  847. package/migrations/meta/0008_snapshot.json +1753 -0
  848. package/migrations/meta/0009_snapshot.json +1759 -0
  849. package/migrations/meta/0010_snapshot.json +1872 -0
  850. package/migrations/meta/0011_snapshot.json +1884 -0
  851. package/migrations/meta/_journal.json +49 -0
  852. package/package.json +8 -7
  853. package/src/__tests__/core-config.domain.test.ts +67 -2
  854. package/src/__tests__/kb-layout-config.test.ts +295 -0
  855. package/src/__tests__/open-change-gate.ts +15 -0
  856. package/src/core/__tests__/catalog-cache-invalidation.test.ts +226 -0
  857. package/src/core/__tests__/catalog-revision.test.ts +190 -0
  858. package/src/core/__tests__/lifecycle.test.ts +369 -0
  859. package/src/core/__tests__/public-config.test.ts +30 -0
  860. package/src/core/__tests__/readiness.test.ts +109 -0
  861. package/src/core/catalog-cache-invalidation.ts +111 -0
  862. package/src/core/catalog-revision.ts +110 -0
  863. package/src/core/create-core-server.ts +358 -63
  864. package/src/core/create-core-services.ts +449 -107
  865. package/src/core/lifecycle.ts +312 -0
  866. package/src/core/public-config.ts +86 -0
  867. package/src/core/readiness.ts +123 -0
  868. package/src/core-config.ts +70 -4
  869. package/src/index.ts +15 -0
  870. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +362 -0
  871. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +2 -1
  872. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +46 -22
  873. package/src/modules/access/__tests__/access-control.deny-dependents.test.ts +215 -0
  874. package/src/modules/access/__tests__/access-control.download-read.test.ts +268 -0
  875. package/src/modules/access/__tests__/access-control.everyone-read.test.ts +145 -0
  876. package/src/modules/access/__tests__/access-control.group-read.test.ts +163 -0
  877. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +88 -0
  878. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +152 -0
  879. package/src/modules/access/__tests__/access-control.prospective.test.ts +189 -0
  880. package/src/modules/access/__tests__/access-control.read-batch.test.ts +2 -1
  881. package/src/modules/access/__tests__/access-control.service.test.ts +142 -81
  882. package/src/modules/access/__tests__/access-groups.test.ts +47 -14
  883. package/src/modules/access/__tests__/access-md-format.test.ts +2 -1
  884. package/src/modules/access/__tests__/access-mutation.service.test.ts +52 -4
  885. package/src/modules/access/__tests__/access-own-read-grant.test.ts +2 -0
  886. package/src/modules/access/__tests__/access-personal-plugin.test.ts +153 -0
  887. package/src/modules/access/__tests__/access-plugin-principals.test.ts +385 -0
  888. package/src/modules/access/__tests__/access-restrict-here.test.ts +450 -0
  889. package/src/modules/access/__tests__/access.routes.batch.test.ts +139 -0
  890. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +394 -0
  891. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +6 -2
  892. package/src/modules/access/__tests__/access.routes.overrides.test.ts +3 -2
  893. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +234 -0
  894. package/src/modules/access/__tests__/access.routes.prospective.test.ts +216 -0
  895. package/src/modules/access/__tests__/access.routes.revoke.test.ts +7 -2
  896. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +360 -0
  897. package/src/modules/access/__tests__/change-read-gate.test.ts +272 -0
  898. package/src/modules/access/__tests__/creator-access.test.ts +81 -83
  899. package/src/modules/access/__tests__/grant-sources.test.ts +3 -2
  900. package/src/modules/access/__tests__/groups-admin.service.test.ts +14 -5
  901. package/src/modules/access/__tests__/roles-admin.security.test.ts +196 -0
  902. package/src/modules/access/__tests__/roles-admin.service.test.ts +127 -2
  903. package/src/modules/access/__tests__/roles-capabilities.test.ts +5 -4
  904. package/src/modules/access/__tests__/roles.routes.test.ts +5 -2
  905. package/src/modules/access/__tests__/user-access-removal.service.test.ts +402 -0
  906. package/src/modules/access/__tests__/users-db-double.ts +63 -0
  907. package/src/modules/access/access-control.interface.ts +269 -18
  908. package/src/modules/access/access-control.service.ts +943 -226
  909. package/src/modules/access/access-mutation.service.ts +182 -13
  910. package/src/modules/access/access-view.ts +316 -0
  911. package/src/modules/access/access.routes.ts +385 -105
  912. package/src/modules/access/admin-locked-commit.ts +12 -14
  913. package/src/modules/access/admin-route-helpers.ts +4 -4
  914. package/src/modules/access/change-read-gate.ts +148 -0
  915. package/src/modules/access/creator-access.ts +89 -124
  916. package/src/modules/access/directory-sync-bot.ts +4 -1
  917. package/src/modules/access/groups-admin.service.ts +3 -3
  918. package/src/modules/access/reference-scan.ts +5 -5
  919. package/src/modules/access/roles-admin.service.ts +94 -15
  920. package/src/modules/access/synced-groups-committer.ts +10 -15
  921. package/src/modules/access/synced-groups-writer.ts +3 -12
  922. package/src/modules/access/user-access-removal.service.ts +711 -0
  923. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  924. package/src/modules/access-model/__tests__/access-splice.test.ts +63 -1
  925. package/src/modules/access-model/__tests__/frontmatter-fences.test.ts +71 -0
  926. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  927. package/src/modules/access-model/__tests__/roles-yaml-guard.test.ts +131 -0
  928. package/src/modules/access-model/access-errors.ts +56 -15
  929. package/src/modules/access-model/access-grammar.ts +198 -49
  930. package/src/modules/access-model/access-splice.ts +18 -18
  931. package/src/modules/access-model/access-template.ts +44 -0
  932. package/src/modules/access-model/change-gate.ts +80 -0
  933. package/src/modules/access-model/creator.ts +18 -21
  934. package/src/modules/access-model/frontmatter-lines.ts +98 -0
  935. package/src/modules/access-model/group-files.ts +4 -0
  936. package/src/modules/access-model/plugin-principals.ts +130 -0
  937. package/src/modules/access-model/roles-yaml-guard.ts +182 -4
  938. package/src/modules/admin/admin-access.service.ts +6 -2
  939. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +100 -0
  940. package/src/modules/agent-instructions/__tests__/compose.test.ts +256 -0
  941. package/src/modules/agent-instructions/__tests__/read-preamble.test.ts +135 -0
  942. package/src/modules/agent-instructions/agent-instructions.routes.ts +41 -0
  943. package/src/modules/agent-instructions/compose.ts +271 -0
  944. package/src/modules/agent-instructions/index.ts +14 -0
  945. package/src/modules/agent-instructions/read-preamble.ts +88 -0
  946. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +209 -0
  947. package/src/modules/auth/__tests__/account.routes.test.ts +178 -2
  948. package/src/modules/auth/__tests__/auth.middleware.test.ts +78 -0
  949. package/src/modules/auth/__tests__/auth.service.test.ts +338 -5
  950. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +352 -12
  951. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +3 -1
  952. package/src/modules/auth/account-erasure.service.ts +117 -10
  953. package/src/modules/auth/account.routes.ts +115 -14
  954. package/src/modules/auth/auth.routes.ts +20 -7
  955. package/src/modules/auth/auth.service.ts +187 -28
  956. package/src/modules/auth/oidc-auth-provider.ts +120 -22
  957. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +109 -1
  958. package/src/modules/code-mode/code-mode.tool.ts +13 -2
  959. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +78 -0
  960. package/src/modules/connection-probe/connection-probe.service.ts +59 -7
  961. package/src/modules/database/__tests__/advisory-lease.test.ts +152 -0
  962. package/src/modules/database/__tests__/advisory-lock.test.ts +147 -0
  963. package/src/modules/database/advisory-lock.ts +346 -0
  964. package/src/modules/database/connection.ts +9 -1
  965. package/src/modules/database/core-schema.ts +177 -0
  966. package/src/modules/database/migrate.ts +27 -6
  967. package/src/modules/declared-variables/declared-variables.routes.ts +6 -3
  968. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -0
  969. package/src/modules/diff/__tests__/diff.service.test.ts +3 -0
  970. package/src/modules/diff/diff.routes.ts +2 -4
  971. package/src/modules/diff/diff.service.ts +75 -79
  972. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +472 -9
  973. package/src/modules/kb-fs/__tests__/node-fs.test.ts +409 -0
  974. package/src/modules/kb-fs/__tests__/repo-path.test.ts +210 -1
  975. package/src/modules/{workspace → kb-fs}/bevel-ignore.ts +22 -15
  976. package/src/modules/kb-fs/file-change-notifier.ts +4 -1
  977. package/src/modules/kb-fs/git-guarded-filesystem.ts +123 -0
  978. package/src/modules/kb-fs/locking-filesystem.ts +399 -109
  979. package/src/modules/kb-fs/node-fs.ts +165 -0
  980. package/src/modules/kb-fs/read-only-filesystem.ts +18 -9
  981. package/src/modules/kb-fs/repo-path.ts +213 -3
  982. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  983. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  984. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  985. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  986. package/src/modules/kb-sync/index.ts +19 -0
  987. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  988. package/src/modules/kb-sync/kb-sync.routes.ts +200 -0
  989. package/src/modules/kb-sync/kb-sync.service.ts +204 -0
  990. package/src/modules/kb-sync/sync-auth.ts +118 -0
  991. package/src/modules/kb-sync/sync-payload.ts +143 -0
  992. package/src/modules/marketplace/__tests__/github-facade.test.ts +575 -0
  993. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +180 -0
  994. package/src/modules/marketplace/__tests__/marketplace-repo.head.test.ts +47 -0
  995. package/src/modules/marketplace/git-http.routes.ts +196 -0
  996. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +110 -0
  997. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  998. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +263 -0
  999. package/src/modules/marketplace/github-facade/github-facade.routes.ts +294 -0
  1000. package/src/modules/marketplace/github-facade/github-facade.service.ts +282 -0
  1001. package/src/modules/marketplace/github-facade/index.ts +26 -0
  1002. package/src/modules/marketplace/index.ts +3 -0
  1003. package/src/modules/marketplace/marketplace-repo.service.ts +352 -0
  1004. package/src/modules/mcp/__tests__/downstream-pool.test.ts +278 -0
  1005. package/src/modules/mcp/__tests__/downstream-token-refresh.test.ts +178 -0
  1006. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +156 -0
  1007. package/src/modules/mcp/__tests__/first-call-probe.cli-args.test.ts +178 -0
  1008. package/src/modules/mcp/__tests__/first-call-probe.test.ts +414 -0
  1009. package/src/modules/mcp/__tests__/manual-failure-memo.test.ts +98 -6
  1010. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +8 -5
  1011. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +1 -3
  1012. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +1055 -59
  1013. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +182 -0
  1014. package/src/modules/mcp/__tests__/mcp.service.test.ts +274 -6
  1015. package/src/modules/mcp/__tests__/surface-log-throttle.test.ts +169 -0
  1016. package/src/modules/mcp/downstream-pool.ts +256 -0
  1017. package/src/modules/mcp/downstream-token-refresh.ts +170 -0
  1018. package/src/modules/mcp/first-call-probe.cli-args.ts +159 -0
  1019. package/src/modules/mcp/first-call-probe.cli.ts +60 -0
  1020. package/src/modules/mcp/first-call-probe.ts +418 -0
  1021. package/src/modules/mcp/manual-failure-memo.ts +81 -23
  1022. package/src/modules/mcp/mcp-auth.middleware.ts +25 -16
  1023. package/src/modules/mcp/mcp.routes.ts +376 -482
  1024. package/src/modules/mcp/mcp.service.ts +846 -176
  1025. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +8 -2
  1026. package/src/modules/mcp/oauth/oauth-consent.routes.ts +28 -6
  1027. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  1028. package/src/modules/mcp/surface-log-throttle.ts +120 -0
  1029. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +166 -0
  1030. package/src/modules/plugins/__tests__/bundle-dialect.test.ts +165 -0
  1031. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +423 -0
  1032. package/src/modules/plugins/__tests__/fake-join-request-store.ts +189 -0
  1033. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +608 -0
  1034. package/src/modules/plugins/__tests__/plugin-creation-display-name.test.ts +241 -0
  1035. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +201 -16
  1036. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +284 -0
  1037. package/src/modules/plugins/__tests__/plugin-names.test.ts +77 -0
  1038. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +330 -20
  1039. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +456 -0
  1040. package/src/modules/plugins/__tests__/plugins.routes.test.ts +441 -43
  1041. package/src/modules/plugins/__tests__/plugins.tools.test.ts +131 -0
  1042. package/src/modules/plugins/__tests__/teams.routes.test.ts +248 -0
  1043. package/src/modules/plugins/compile/compile-marketplace.ts +500 -0
  1044. package/src/modules/plugins/compile/marketplace-compiler.service.ts +151 -0
  1045. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +365 -0
  1046. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +161 -0
  1047. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +250 -0
  1048. package/src/modules/plugins/discovery/kb-plugin-source.ts +117 -0
  1049. package/src/modules/plugins/discovery/native.source.ts +140 -0
  1050. package/src/modules/plugins/discovery/plugin-source.ts +113 -0
  1051. package/src/modules/plugins/index.ts +25 -2
  1052. package/src/modules/plugins/join-request-jobs.service.ts +675 -0
  1053. package/src/modules/plugins/join-request-records.store.ts +353 -0
  1054. package/src/modules/plugins/join-requests.service.ts +7 -4
  1055. package/src/modules/plugins/plugin-links.service.ts +350 -0
  1056. package/src/modules/plugins/plugin-links.ts +187 -0
  1057. package/src/modules/plugins/plugin-provision.service.ts +356 -78
  1058. package/src/modules/plugins/plugin-rename.service.ts +310 -0
  1059. package/src/modules/plugins/plugins.contract.ts +79 -4
  1060. package/src/modules/plugins/plugins.routes.ts +320 -123
  1061. package/src/modules/plugins/plugins.service.ts +184 -57
  1062. package/src/modules/plugins/plugins.tools.ts +88 -0
  1063. package/src/modules/plugins/teams.routes.ts +129 -0
  1064. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +359 -15
  1065. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +248 -0
  1066. package/src/modules/secrets-vault/__tests__/namespace-secrets.test.ts +202 -0
  1067. package/src/modules/secrets-vault/db-secrets-vault.service.ts +217 -35
  1068. package/src/modules/secrets-vault/index.ts +1 -0
  1069. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +6 -3
  1070. package/src/modules/secrets-vault/secrets-variable-loader.ts +4 -4
  1071. package/src/modules/secrets-vault/secrets-vault.contract.ts +93 -0
  1072. package/src/modules/secrets-vault/secrets-vault.routes.ts +99 -38
  1073. package/src/modules/settings/__tests__/connection-check.test.ts +233 -0
  1074. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +240 -1
  1075. package/src/modules/settings/__tests__/git-root-folders.test.ts +247 -0
  1076. package/src/modules/settings/__tests__/oidc-check.test.ts +185 -0
  1077. package/src/modules/settings/__tests__/setup.routes.boot-failure.test.ts +119 -0
  1078. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +170 -0
  1079. package/src/modules/settings/__tests__/setup.routes.oidc.test.ts +555 -0
  1080. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  1081. package/src/modules/settings/__tests__/setup.routes.test.ts +667 -16
  1082. package/src/modules/settings/connection-check.ts +351 -0
  1083. package/src/modules/settings/deployment-settings.service.ts +478 -39
  1084. package/src/modules/settings/git-root-folders.ts +171 -0
  1085. package/src/modules/settings/oidc-check.ts +229 -0
  1086. package/src/modules/settings/setup.routes.ts +480 -110
  1087. package/src/modules/skills/__tests__/allowed-tools-check.test.ts +224 -0
  1088. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +210 -0
  1089. package/src/modules/skills/__tests__/pending-skills.service.test.ts +3 -3
  1090. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  1091. package/src/modules/skills/__tests__/skills.routes.warnings.test.ts +91 -0
  1092. package/src/modules/skills/__tests__/skills.service.test.ts +308 -2
  1093. package/src/modules/skills/allowed-tools-check.ts +352 -0
  1094. package/src/modules/skills/index.ts +5 -0
  1095. package/src/modules/skills/pending-skills.service.ts +26 -85
  1096. package/src/modules/skills/skill-access-requests.routes.ts +203 -0
  1097. package/src/modules/skills/skills.contract.ts +67 -3
  1098. package/src/modules/skills/skills.routes.ts +75 -4
  1099. package/src/modules/skills/skills.service.ts +258 -66
  1100. package/src/modules/skills/skills.tools.ts +43 -5
  1101. package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +192 -0
  1102. package/src/modules/tool-auth/__tests__/connection-keys-admin.routes.test.ts +116 -0
  1103. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +124 -0
  1104. package/src/modules/tool-auth/connection-key-rejection.ts +25 -0
  1105. package/src/modules/tool-auth/connection-keys-admin.routes.ts +76 -0
  1106. package/src/modules/tool-auth/external-api-key.interface.ts +73 -1
  1107. package/src/modules/tool-auth/external-api-key.service.ts +95 -29
  1108. package/src/modules/tool-auth/internal-token.service.ts +5 -2
  1109. package/src/modules/tool-auth/key-or-session.middleware.ts +45 -0
  1110. package/src/modules/tool-auth/tool-auth.middleware.ts +16 -7
  1111. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +228 -0
  1112. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +33 -4
  1113. package/src/modules/tool-helpers/tool-context.ts +46 -7
  1114. package/src/modules/tool-helpers/tool-handler.ts +23 -5
  1115. package/src/modules/tool-helpers/tool.contract.ts +5 -0
  1116. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +24 -1
  1117. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -1
  1118. package/src/modules/tool-manuals/__tests__/pending-tools.service.test.ts +411 -0
  1119. package/src/modules/tool-manuals/__tests__/tool-delete.route.test.ts +133 -0
  1120. package/src/modules/tool-manuals/__tests__/tool-delete.service.test.ts +469 -0
  1121. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +21 -2
  1122. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +11 -4
  1123. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +6 -1
  1124. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.routes.test.ts +254 -0
  1125. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.test.ts +577 -0
  1126. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +6 -1
  1127. package/src/modules/tool-manuals/__tests__/tool-manuals.pending.route.test.ts +102 -0
  1128. package/src/modules/tool-manuals/__tests__/tool-manuals.restart.test.ts +285 -0
  1129. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +178 -2
  1130. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +114 -2
  1131. package/src/modules/tool-manuals/index.ts +3 -0
  1132. package/src/modules/tool-manuals/mcp-json-discovery.ts +164 -95
  1133. package/src/modules/tool-manuals/mcp-server-edit.service.ts +11 -14
  1134. package/src/modules/tool-manuals/pending-tools.service.ts +179 -0
  1135. package/src/modules/tool-manuals/tool-delete.service.ts +401 -0
  1136. package/src/modules/tool-manuals/tool-manuals.contract.ts +165 -0
  1137. package/src/modules/tool-manuals/tool-manuals.routes.ts +138 -54
  1138. package/src/modules/tool-manuals/tool-manuals.service.ts +440 -104
  1139. package/src/modules/tool-manuals/tool-manuals.tools.ts +81 -6
  1140. package/src/modules/workflow/__tests__/apply-failure.test.ts +1038 -0
  1141. package/src/modules/workflow/__tests__/event-bus.test.ts +61 -9
  1142. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +199 -0
  1143. package/src/modules/workflow/__tests__/file-lock.service.path-identity.test.ts +237 -0
  1144. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +18 -0
  1145. package/src/modules/workflow/__tests__/merge-announces-tree.test.ts +220 -0
  1146. package/src/modules/workflow/__tests__/pending-commits.worker.concurrency.test.ts +187 -0
  1147. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +3 -1
  1148. package/src/modules/workflow/__tests__/sanitize-error.test.ts +10 -0
  1149. package/src/modules/workflow/__tests__/workflow.routes.folder-proposals.test.ts +128 -0
  1150. package/src/modules/workflow/__tests__/workflow.routes.fork-point-file.test.ts +149 -0
  1151. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +305 -0
  1152. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +2 -0
  1153. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +3 -0
  1154. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +2 -0
  1155. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +296 -27
  1156. package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +564 -0
  1157. package/src/modules/workflow/__tests__/workflow.service.platform-restore.test.ts +135 -0
  1158. package/src/modules/workflow/__tests__/workflow.service.read-gate.test.ts +172 -0
  1159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +133 -2
  1160. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +442 -0
  1161. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +281 -0
  1162. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +158 -7
  1163. package/src/modules/workflow/agent-tools/workflow.tools.ts +93 -64
  1164. package/src/modules/workflow/event-bus.ts +26 -28
  1165. package/src/modules/workflow/events.routes.ts +56 -21
  1166. package/src/modules/workflow/file-lock.service.ts +27 -4
  1167. package/src/modules/workflow/git/__tests__/change-request-link.test.ts +43 -0
  1168. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -0
  1169. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +238 -1
  1170. package/src/modules/workflow/git/__tests__/git.service.forkPoint.test.ts +245 -0
  1171. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +35 -0
  1172. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +149 -0
  1173. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  1174. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  1175. package/src/modules/workflow/git/__tests__/git.service.revertPathsAndPush.test.ts +177 -0
  1176. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  1177. package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +114 -0
  1178. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +69 -2
  1179. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +121 -0
  1180. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +79 -0
  1181. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  1182. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +1 -1
  1183. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-update.test.ts +111 -0
  1184. package/src/modules/workflow/git/change-request-link.ts +35 -0
  1185. package/src/modules/workflow/git/git-version.ts +5 -2
  1186. package/src/modules/workflow/git/git.service.ts +922 -199
  1187. package/src/modules/workflow/git/node-git-runner.ts +315 -0
  1188. package/src/modules/workflow/git/pull-request.service.ts +188 -27
  1189. package/src/modules/workflow/pending-commits.service.ts +26 -7
  1190. package/src/modules/workflow/pending-commits.worker.ts +114 -69
  1191. package/src/modules/workflow/recovery-bot.ts +4 -1
  1192. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +180 -1
  1193. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  1194. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +520 -0
  1195. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +84 -0
  1196. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +89 -77
  1197. package/src/modules/workflow/review-workflow/approval-lock.ts +122 -0
  1198. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +59 -7
  1199. package/src/modules/workflow/review-workflow/review-workflow.service.ts +309 -105
  1200. package/src/modules/workflow/sanitize-error.ts +9 -4
  1201. package/src/modules/workflow/workflow.routes.ts +273 -29
  1202. package/src/modules/workflow/workflow.service.ts +1341 -174
  1203. package/src/modules/workspace/__tests__/empty-dirs.test.ts +44 -0
  1204. package/src/modules/workspace/__tests__/file-stat-access.test.ts +301 -0
  1205. package/src/modules/workspace/__tests__/git-internals.security.test.ts +792 -0
  1206. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +464 -0
  1207. package/src/modules/workspace/__tests__/workspace.routes.branch-errors.test.ts +139 -0
  1208. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +189 -37
  1209. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +51 -11
  1210. package/src/modules/workspace/__tests__/workspace.routes.download-implies-read.test.ts +161 -0
  1211. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +109 -10
  1212. package/src/modules/workspace/__tests__/workspace.routes.file-writes.test.ts +624 -0
  1213. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +409 -0
  1214. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +327 -0
  1215. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +31 -13
  1216. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +109 -0
  1217. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +94 -0
  1218. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  1219. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +218 -0
  1220. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +98 -13
  1221. package/src/modules/workspace/__tests__/workspace.service.test.ts +811 -42
  1222. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +197 -0
  1223. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +128 -0
  1224. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +212 -0
  1225. package/src/modules/workspace/__tests__/workspace.tools.test.ts +2632 -110
  1226. package/src/modules/workspace/__tests__/write-denial.test.ts +75 -0
  1227. package/src/modules/workspace/empty-dirs.ts +79 -0
  1228. package/src/modules/workspace/file-readers/README.md +32 -0
  1229. package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +59 -2
  1230. package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +133 -4
  1231. package/src/modules/workspace/file-readers/__tests__/fixtures/soft-break.docx +0 -0
  1232. package/src/modules/workspace/file-readers/__tests__/fixtures/tab.docx +0 -0
  1233. package/src/modules/workspace/file-readers/content-mode.ts +87 -0
  1234. package/src/modules/workspace/file-readers/doc-extract.service.ts +4 -1
  1235. package/src/modules/workspace/file-readers/doc-extract.types.ts +8 -4
  1236. package/src/modules/workspace/file-readers/document-reader.ts +6 -0
  1237. package/src/modules/workspace/file-readers/extract-docx.ts +6 -0
  1238. package/src/modules/workspace/file-readers/file-reader.registry.ts +9 -1
  1239. package/src/modules/workspace/file-readers/file-reader.ts +27 -6
  1240. package/src/modules/workspace/file-readers/image-reader.ts +10 -5
  1241. package/src/modules/workspace/file-readers/ooxml-text.ts +38 -2
  1242. package/src/modules/workspace/file-readers/text-reader.ts +61 -4
  1243. package/src/modules/workspace/git-internals.middleware.ts +86 -0
  1244. package/src/modules/workspace/not-found.ts +119 -0
  1245. package/src/modules/workspace/session-ontology.gate.ts +1 -1
  1246. package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +181 -0
  1247. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +361 -0
  1248. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +53 -16
  1249. package/src/modules/workspace/startup/beside-checkout.ts +112 -0
  1250. package/src/modules/workspace/startup/kb-git.ts +43 -36
  1251. package/src/modules/workspace/startup/kb-startup-runner.ts +303 -63
  1252. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1405 -48
  1253. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +460 -417
  1254. package/src/modules/workspace/startup/steps/personal-spaces.step.ts +191 -0
  1255. package/src/modules/workspace/startup/steps/plugin-display-names.step.ts +169 -0
  1256. package/src/modules/workspace/startup/steps/plugin-layout.ts +110 -0
  1257. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +90 -0
  1258. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +6 -12
  1259. package/src/modules/workspace/startup/steps/seed-tree.ts +177 -54
  1260. package/src/modules/workspace/startup/steps/template-files.step.ts +581 -126
  1261. package/src/modules/workspace/startup/steps/template-source.ts +168 -0
  1262. package/src/modules/workspace/workspace.routes.ts +691 -269
  1263. package/src/modules/workspace/workspace.service.ts +1071 -292
  1264. package/src/modules/workspace/workspace.tools.ts +1787 -180
  1265. package/src/modules/workspace/write-denial.ts +189 -0
  1266. package/src/shared/__tests__/canonical-file-identity.test.ts +154 -0
  1267. package/src/shared/__tests__/email-identity.test.ts +35 -0
  1268. package/src/shared/__tests__/filename.test.ts +0 -0
  1269. package/src/shared/__tests__/frontmatter-id.test.ts +5 -0
  1270. package/src/shared/__tests__/git-failure.test.ts +198 -0
  1271. package/src/shared/__tests__/git-internals.test.ts +115 -0
  1272. package/src/shared/__tests__/http-errors.test.ts +42 -0
  1273. package/src/shared/__tests__/logging.test.ts +185 -0
  1274. package/src/shared/__tests__/path-containment.test.ts +60 -0
  1275. package/src/shared/__tests__/path-order.test.ts +24 -0
  1276. package/src/shared/__tests__/pending-proposals.readAt.test.ts +102 -0
  1277. package/src/shared/__tests__/printable.test.ts +64 -0
  1278. package/src/shared/__tests__/redact-secret.test.ts +122 -0
  1279. package/src/shared/__tests__/rename-no-replace.test.ts +466 -0
  1280. package/src/shared/__tests__/ssrf.test.ts +20 -0
  1281. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  1282. package/src/shared/canonical-file-identity.ts +92 -0
  1283. package/src/shared/domain-errors.ts +178 -0
  1284. package/src/shared/email-identity.ts +38 -0
  1285. package/src/shared/fs.contract.ts +225 -0
  1286. package/src/shared/git-failure.ts +196 -0
  1287. package/src/shared/git-internals.ts +124 -0
  1288. package/src/shared/git.contract.ts +170 -0
  1289. package/src/shared/http-errors.ts +36 -0
  1290. package/src/shared/logger.contract.ts +141 -0
  1291. package/src/shared/logging.ts +96 -0
  1292. package/src/shared/path-containment.ts +34 -0
  1293. package/src/shared/path-order.ts +33 -0
  1294. package/src/shared/pending-proposals.ts +161 -0
  1295. package/src/shared/printable.ts +68 -0
  1296. package/src/shared/redact-secret.ts +118 -0
  1297. package/src/shared/rename-no-replace.ts +386 -0
  1298. package/src/shared/ssrf.ts +44 -16
  1299. package/src/shared/ttl-cache.ts +33 -1
  1300. package/src/version.ts +10 -6
  1301. package/src/modules/diff/diff-paths.ts +0 -9
  1302. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +0 -151
  1303. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +0 -226
  1304. package/src/modules/mcp/mcp-session-store.ts +0 -162
  1305. package/src/shared/__tests__/fs-walk.test.ts +0 -42
  1306. package/src/shared/fs-walk.ts +0 -30
  1307. package/src/shared/hash-email.ts +0 -14
@@ -18,6 +18,15 @@
18
18
  */
19
19
 
20
20
  import path from 'node:path';
21
+ import { logger } from '../../shared/logging.js';
22
+
23
+ // One logger per tag this file has always written under, so a line's prefix
24
+ // is unchanged for a reader and its `module` field is exact for a filter.
25
+ const log = logger('workflow');
26
+ const syncLog = logger('sync');
27
+ const lockLog = logger('lock');
28
+ const crLog = logger('cr');
29
+ const mergeLog = logger('merge');
21
30
  import { promises as fs } from 'node:fs';
22
31
  import { execFile } from 'node:child_process';
23
32
  import { promisify } from 'node:util';
@@ -25,24 +34,36 @@ import type {
25
34
  AcquireLockResult,
26
35
  AuthUser,
27
36
  Branch,
37
+ BranchSyncOutcome,
28
38
  BranchWorkspaceStatus,
29
39
  CancelChangeRequestResult,
30
40
  Change,
31
41
  ChangeInput,
32
42
  ChangeRequest,
43
+ ChangeRequestApplyFailureKind,
33
44
  ChangeRequestComment,
34
45
  ChangeRequestDetail,
35
46
  ChangeRequestState,
36
47
  ChangedFile,
37
48
  FileApproval,
38
49
  FileLock,
50
+ FolderChangeRequest,
51
+ FolderChangeRequestRemoval,
39
52
  IWorkflowService,
53
+ MergeBranchOutcome,
40
54
  MergeChangeRequestOutcome,
41
55
  OpenChangeRequestInput,
42
56
  PostChangeRequestCommentInput,
57
+ RemoteSyncPullResult,
43
58
  } from '@bevel-software/platform-shared';
44
- import { isProtectedBranch, DEFAULT_BRANCH } from '@bevel-software/platform-shared';
45
- import { and, eq, or } from 'drizzle-orm';
59
+ import {
60
+ isProtectedBranch,
61
+ DEFAULT_BRANCH,
62
+ isFolderPlaceholder,
63
+ folderPlaceholderPath,
64
+ isPlatformRestoreShape,
65
+ } from '@bevel-software/platform-shared';
66
+ import { and, eq, inArray, isNotNull, isNull, lt, or } from 'drizzle-orm';
46
67
  import type { Database } from '../database/connection.js';
47
68
  import { changeRequests } from '../database/schema.js';
48
69
  import type { GitService } from './git/git.service.js';
@@ -52,30 +73,38 @@ import type { WorkspaceService } from '../workspace/workspace.service.js';
52
73
  import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
53
74
  import type { IAccessControl } from '../access/access-control.interface.js';
54
75
  import { FileLockService } from './file-lock.service.js';
76
+ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
55
77
  import { PendingCommitsService } from './pending-commits.service.js';
56
78
  import type { WorkflowEventBus } from './event-bus.js';
57
79
  import { sanitizeError } from './sanitize-error.js';
58
80
  import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
59
81
  import { WorkflowHooks } from './workflow-hooks.js';
60
82
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
61
- import { hashEmail } from '../../shared/hash-email.js';
83
+ import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
62
84
  import {
63
85
  ChangeRequestConflictsError,
64
86
  DuplicateChangeRequestError,
87
+ OpenChangeRequestBlocksMergeError,
65
88
  RolesYamlPreservationError,
66
89
  PullRebaseConflictError,
67
90
  PushNeedsAgentResolutionError,
91
+ RemoteBranchGoneError,
68
92
  WorkflowDomainError,
69
93
  WorkflowValidationError,
70
94
  } from '../../shared/domain-errors.js';
71
95
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
72
96
  import { AccessDeniedError } from '../access-model/access-errors.js';
97
+ import type { IChangeReadGate } from '../access-model/change-gate.js';
98
+ import { printable } from '../../shared/printable.js';
73
99
 
74
100
  const execFileAsync = promisify(execFile);
75
101
 
76
102
  /** The partial unique index that enforces one OPEN change request per (source, target) pair. */
77
103
  const OPEN_PAIR_CONSTRAINT = 'change_requests_open_pair_unq';
78
104
 
105
+ /** Cap on a persisted apply refusal — long enough to name the files a gate waits on. */
106
+ const APPLY_FAILURE_MAX_LEN = 1000;
107
+
79
108
  /**
80
109
  * True only for a Postgres unique violation (23505) on the open-CR-per-pair
81
110
  * index. `change_requests` also carries `change_requests_number_unq` on its
@@ -100,6 +129,33 @@ function isOpenPairViolation(err: unknown): boolean {
100
129
  return typeof message === 'string' && message.includes(OPEN_PAIR_CONSTRAINT);
101
130
  }
102
131
 
132
+ /**
133
+ * The one sentence a remote-sync conflict is reported with — in the sync
134
+ * response, on the branch's banner and in the log — so whoever sees any of
135
+ * the three reads the same thing: which branch, which files, that automatic
136
+ * recovery is under way, and what to do if it does not clear.
137
+ */
138
+ export function syncConflictMessage(branch: string, conflictedPaths: string[]): string {
139
+ // Bounded: the same sentence goes to the response, the banner and the log,
140
+ // and it must be one sentence in all three whatever the conflict's size.
141
+ // The full path list rides beside it as `conflictedPaths`.
142
+ const shown = conflictedPaths.slice(0, SYNC_CONFLICT_FILES_NAMED);
143
+ const more = conflictedPaths.length - shown.length;
144
+ const files =
145
+ conflictedPaths.length === 0
146
+ ? 'some files'
147
+ : more > 0
148
+ ? `${shown.join(', ')} and ${more} more file${more === 1 ? '' : 's'}`
149
+ : shown.join(', ');
150
+ return (
151
+ `${branch} is not in sync yet: ${files} changed both in Hexis and on the git host. ` +
152
+ `Recovery is queued; if this stays, open the files on ${branch} in Hexis, keep what you want, and save.`
153
+ );
154
+ }
155
+
156
+ /** How many conflicted files the sync-conflict sentence names before "and N more". */
157
+ const SYNC_CONFLICT_FILES_NAMED = 3;
158
+
103
159
  /** Redact the shared GitHub token from any error string before surfacing it. */
104
160
  function redactTokens(msg: string): string {
105
161
  const tokens = [process.env.GITHUB_TOKEN, process.env.GH_TOKEN].filter(
@@ -130,7 +186,9 @@ const formatWriter = (u: { name: string; email: string }): string =>
130
186
  * the overflow is collapsed into a single deduped summary of every eligible
131
187
  * approver across the change. This keeps the body bounded regardless of how
132
188
  * many files the change touches (see {@link MAX_AFFECTED_PATHS_LISTED}).
133
- * Returns '' when no path has resolvable eligibility.
189
+ * Returns '' when no path has resolvable eligibility. The empty-folder
190
+ * placeholder is never listed or counted: it is not content, and no one
191
+ * reviews it.
134
192
  */
135
193
  export function formatAffectedOwnersBlock(
136
194
  paths: string[],
@@ -142,6 +200,7 @@ export function formatAffectedOwnersBlock(
142
200
  const allUsers = new Map<string, string>(); // email -> display, deduped
143
201
  let eligible = 0;
144
202
  for (const p of paths) {
203
+ if (isFolderPlaceholder(p)) continue;
145
204
  const info = resolved.get(p);
146
205
  if (!info) continue;
147
206
  const parts: string[] = [];
@@ -174,6 +233,28 @@ export function formatAffectedOwnersBlock(
174
233
  return out.join('\n');
175
234
  }
176
235
 
236
+ /** Git commit subjects are capped at 200 characters (`GitService.commitFile`). */
237
+ const MAX_COMMIT_SUBJECT = 200;
238
+
239
+ /**
240
+ * A commit subject that always fits: `full` when it does, else `short` (the
241
+ * file's name instead of its path, a count instead of every request), cut to
242
+ * the cap as a last resort. A long path or many requests on one branch must
243
+ * never fail the commit — and with it the whole folder removal.
244
+ */
245
+ function commitSubject(full: string, short: string): string {
246
+ if (full.length <= MAX_COMMIT_SUBJECT) return full;
247
+ if (short.length <= MAX_COMMIT_SUBJECT) return short;
248
+ return `${short.slice(0, MAX_COMMIT_SUBJECT - 1)}…`;
249
+ }
250
+
251
+ const basenameOf = (repoRelPath: string): string => repoRelPath.split('/').pop() ?? repoRelPath;
252
+
253
+ /** `Docs/Sub` and `Docs/Sub/` both → `Docs/Sub/`: a prefix that cannot match `Docs/Subway`. */
254
+ function folderPrefix(folder: string): string {
255
+ return `${folder.replace(/\/+$/, '')}/`;
256
+ }
257
+
177
258
  export class WorkflowService implements IWorkflowService {
178
259
  /**
179
260
  * Concrete `PullRequestService` (not the interface) so the facade can call
@@ -211,6 +292,13 @@ export class WorkflowService implements IWorkflowService {
211
292
  */
212
293
  private readonly pendingCommits: PendingCommitsService,
213
294
  private readonly kbDirName: string,
295
+ /**
296
+ * The read-before-write gate `acquireLock` asks on every branch (see
297
+ * `access-model/change-gate.ts`). Required, and checked at construction:
298
+ * a service without it would let a change land where its author cannot
299
+ * read, so there is no such service.
300
+ */
301
+ private readonly changeGate: IChangeReadGate,
214
302
  /**
215
303
  * Event bus that fans state changes out to connected SSE sessions.
216
304
  * Optional so test harnesses / minimal boots can omit it — emits
@@ -235,7 +323,13 @@ export class WorkflowService implements IWorkflowService {
235
323
  * exercise hooks) unchanged.
236
324
  */
237
325
  public readonly hooks: WorkflowHooks = new WorkflowHooks(),
238
- ) {}
326
+ ) {
327
+ // The type already requires it; this holds for a caller the compiler did
328
+ // not see (plain JavaScript, a positional slip in a harness).
329
+ if (!changeGate || typeof changeGate.assertMayChange !== 'function') {
330
+ throw new Error('WorkflowService requires a read-before-write gate (IChangeReadGate)');
331
+ }
332
+ }
239
333
 
240
334
  // ── Branches ──────────────────────────────────────────────────────────────
241
335
 
@@ -246,7 +340,10 @@ export class WorkflowService implements IWorkflowService {
246
340
  */
247
341
  private orphanSweepStarted = false;
248
342
 
249
- async listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]> {
343
+ async listBranches(
344
+ workspaceId: string,
345
+ opts?: { freshFetch?: boolean; strictFetch?: boolean },
346
+ ): Promise<Branch[]> {
250
347
  const branches = await this.git.listBranches(workspaceId, opts);
251
348
  if (!this.orphanSweepStarted) {
252
349
  this.orphanSweepStarted = true;
@@ -257,18 +354,25 @@ export class WorkflowService implements IWorkflowService {
257
354
  .sweepOrphanedWorkspaces(branches.map((b) => b.name))
258
355
  .then(({ removed }) => {
259
356
  if (removed.length > 0) {
260
- console.log(`[workflow] swept ${removed.length} orphaned workspace(s):`, removed);
357
+ log.info(`swept ${removed.length} orphaned workspace(s):`, { removed });
261
358
  }
262
359
  })
263
360
  .catch((err) => {
264
- console.warn('[workflow] orphan workspace sweep failed:', err);
361
+ log.warn('orphan workspace sweep failed:', { err });
265
362
  });
266
363
  }
267
364
  return branches;
268
365
  }
269
366
 
270
367
  createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch> {
271
- return this.git.createBranch(workspaceId, name, fromBase);
368
+ // Under the branch's lifecycle lock, like `deleteBranch` and the
369
+ // retirement of a clone the host deleted: creating a branch is the one
370
+ // operation that can bring a "gone" branch back, and the retirement's
371
+ // "still gone on origin?" check is only a guarantee if nothing can
372
+ // recreate the branch between that check and the delete.
373
+ return this.branchLifecycle.run(`branch:${name}`, () =>
374
+ this.git.createBranch(workspaceId, name, fromBase),
375
+ );
272
376
  }
273
377
 
274
378
  /**
@@ -413,10 +517,7 @@ export class WorkflowService implements IWorkflowService {
413
517
  await this.workspaceService.deleteWorkspace(branchWorkspaceId);
414
518
  }
415
519
  } catch (err) {
416
- console.warn(
417
- `[workflow] could not retire workspace clone of deleted branch "${name}":`,
418
- err,
419
- );
520
+ log.warn(`could not retire workspace clone of deleted branch "${name}":`, { err });
420
521
  }
421
522
  }
422
523
 
@@ -445,9 +546,43 @@ export class WorkflowService implements IWorkflowService {
445
546
  return this.git.fetch(workspaceId);
446
547
  }
447
548
 
549
+ /**
550
+ * `git pull` on a workspace, plus the one announcement a pull owes the rest
551
+ * of the process.
552
+ *
553
+ * A pull rewrites a working tree without going through the write routes or
554
+ * the commit pipeline, so it emits neither of the signals those produce —
555
+ * and the catalogs that scan the DEFAULT branch's tree (skills, tool
556
+ * manuals, the plugin index) are keyed to exactly that tree. Every path that
557
+ * pulls the default workspace therefore has to say so, or those catalogs
558
+ * keep serving the pre-pull scan for the rest of their TTL: a merge landing
559
+ * an approved skill, the recovery ladder after a non-fast-forward push, and
560
+ * a user's update-from-remote alike. Routing every pull through here makes
561
+ * that ONE mechanism — "the default branch's tree changed" — instead of a
562
+ * separate special case per event. (The remote sync, `syncWorkspaceFromRemote`,
563
+ * pulls directly: it owes the same announcement to every branch and per
564
+ * file, and makes it from the same `treeChanged` verdict.)
565
+ *
566
+ * Announced only after the pull RESOLVES, and only when it CHANGED the
567
+ * tree's content: a pull that threw left the tree as it was, and so did an
568
+ * "already up to date" pull or one that landed only content-identical
569
+ * commits — dropping every catalog and making every attached browser
570
+ * refetch its file tree for a change that did not happen buys a re-scan
571
+ * and nothing else. The pull itself reports the change, since it is the
572
+ * only code that can observe it under the workspace mutex.
573
+ */
574
+ private async pullWorkspace(workspaceId: string): Promise<boolean> {
575
+ const { treeChanged } = await this.git.pull(workspaceId);
576
+ if (treeChanged && branchForWorkspaceId(workspaceId) === DEFAULT_BRANCH) {
577
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: DEFAULT_BRANCH });
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+
448
583
  async updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void> {
449
584
  try {
450
- await this.git.pull(workspaceId);
585
+ await this.pullWorkspace(workspaceId);
451
586
  } catch (err) {
452
587
  if (err instanceof PullRebaseConflictError) {
453
588
  await this.queuePullConflictRecovery(workspaceId, err, user);
@@ -456,6 +591,166 @@ export class WorkflowService implements IWorkflowService {
456
591
  }
457
592
  }
458
593
 
594
+ async retireRemoteGoneClone(workspaceId: string): Promise<boolean> {
595
+ const branch = branchForWorkspaceId(workspaceId);
596
+ const id = workspaceIdForBranch(branch);
597
+ // The lock `createBranch` and `deleteBranch` take: no Hexis operation can
598
+ // bring this branch back into being while the clone is examined and
599
+ // removed. The sync's own hold on the clone ended when its outcome was
600
+ // returned, so the branch is asked about AGAIN here rather than trusted
601
+ // from then — a recreate that landed in between keeps its clone. A clone
602
+ // being bootstrapped right now is left alone too: it can only be cloning
603
+ // a branch that exists, so the retirement is already moot. What remains
604
+ // is a recreate pushed from OUTSIDE Hexis in the milliseconds between the
605
+ // origin check and the delete, with a bootstrap starting in that same
606
+ // window; the next sync of that branch clones it fresh.
607
+ return this.branchLifecycle.run(`branch:${branch}`, async () => {
608
+ if (!(await this.workspaceService.hasBootstrappedWorkspace(id))) return false;
609
+ if (this.workspaceService.isBootstrapInFlight(branch)) return false;
610
+ if (await this.git.remoteBranchExists(branch, branch)) return false;
611
+ await this.workspaceService.deleteWorkspace(id);
612
+ // Whatever a sync still owed that clone is owed to nothing now.
613
+ this.owedAnnouncements.delete(id);
614
+ return true;
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Cap on per-path `file-changed` events one sync announces for a branch.
620
+ * Past it the tree event alone carries the news — a bulk import that
621
+ * rewrote hundreds of files would otherwise fan hundreds of events out to
622
+ * every open browser to say what one refresh says.
623
+ */
624
+ private static readonly SYNC_FILE_EVENT_CAP = 200;
625
+
626
+ /**
627
+ * Announcements a sync still OWES, per workspace: a pull landed but the
628
+ * events for it could not be sent. The pull is not redone on retry — HEAD
629
+ * has not moved since — so the announcement cannot be re-derived from sha
630
+ * movement; it is remembered here and delivered by the next sync of that
631
+ * workspace, merged with whatever that sync finds.
632
+ */
633
+ private readonly owedAnnouncements = new Map<string, { after: string; changedPaths: string[] }>();
634
+
635
+ /**
636
+ * The latest RUNNING apply attempt per change-request number (see
637
+ * `beginApplyAttempt`). Entries leave when their attempt ends, so the map
638
+ * holds only attempts in flight, never every number anyone ever posted.
639
+ */
640
+ private readonly applyAttempts = new Map<number, number>();
641
+ /** Process-wide attempt counter: a token is never reissued, even after its entry leaves. */
642
+ private applyAttemptSeq = 0;
643
+
644
+ async syncWorkspaceFromRemote(workspaceId: string): Promise<BranchSyncOutcome> {
645
+ const branch = branchForWorkspaceId(workspaceId);
646
+ const id = workspaceIdForBranch(branch);
647
+ // Two spellings of one workspace id are in circulation — the encoded
648
+ // directory name (`ali%2Fx`, what the clone listing yields) and the
649
+ // Express-decoded form (`ali/x`, what every HTTP route hands the git
650
+ // layer) — and the git layer's mutex keys on the raw string. Speaking the
651
+ // routes' spelling to git is what puts a webhook's rebase in the same
652
+ // queue as the author's concurrent save on a slashed branch; the event
653
+ // bus gets the encoded form, which every SSE consumer canonicalises.
654
+ const gitId = branch;
655
+ // Every failure below is an OUTCOME, never a throw: the caller runs this
656
+ // over many clones, and one broken clone must not abort the others.
657
+ let pulled: RemoteSyncPullResult;
658
+ try {
659
+ pulled = await this.git.syncFromRemote(gitId);
660
+ } catch (err) {
661
+ if (err instanceof RemoteBranchGoneError) {
662
+ // Not a failure: the host deleted the branch, so there is nothing to
663
+ // sync and the caller retires the stale clone. No banner — nobody is
664
+ // editing a branch that no longer exists. An announcement still owed
665
+ // for that clone dies with it: a branch recreated under the same name
666
+ // is a different tree, and replaying the old paths against it would
667
+ // announce changes that never happened there.
668
+ this.owedAnnouncements.delete(id);
669
+ syncLog.info(`branch "${branch}" no longer exists on the host`);
670
+ return { branch, outcome: 'remote-gone' };
671
+ }
672
+ if (err instanceof PullRebaseConflictError) {
673
+ // Same path as `updateFromRemote`: the rebase was aborted inside
674
+ // the pull (the clone is exactly what it was) and the divergence goes
675
+ // to the retry → recovery-agent → escalate ladder, attributed to the
676
+ // recovery bot since no person triggered this. It is still reported
677
+ // as a conflict because it is not resolved at request time — the
678
+ // caller's retry, or the ladder, settles it; the banner clears on the
679
+ // next clean pull or push.
680
+ await this.queuePullConflictRecovery(gitId, err);
681
+ const message = syncConflictMessage(branch, err.conflictedPaths);
682
+ syncLog.warn(message);
683
+ this.noteGitSyncFailed(gitId, branch, err, { paths: err.conflictedPaths, message });
684
+ return { branch, outcome: 'conflict', conflictedPaths: err.conflictedPaths, error: message };
685
+ }
686
+ const message = sanitizeError(err);
687
+ syncLog.warn(`pull failed for branch "${branch}": ${message}`);
688
+ this.noteGitSyncFailed(gitId, branch, err);
689
+ return { branch, outcome: 'error', error: message };
690
+ }
691
+ // A clean pull is proof origin is reachable and this clone rebases onto
692
+ // it — the condition a `git-sync-failed` banner on this branch was
693
+ // waiting for, whether a sync or a push raised it.
694
+ this.noteGitSyncOk(gitId, branch);
695
+ const { before, after, treeChanged, changedPaths } = pulled;
696
+ // What this branch reports about ITS clone is decided by the pull alone:
697
+ // `updated` when HEAD moved, `up-to-date` otherwise.
698
+ const outcome: BranchSyncOutcome =
699
+ after === null || after === before
700
+ ? { branch, outcome: 'up-to-date', to: after ?? '' }
701
+ : { branch, outcome: 'updated', from: before, to: after };
702
+ // What it ANNOUNCES is what has changed since the last announcement that
703
+ // was actually delivered: this pull's content change, if any, plus one an
704
+ // earlier sync landed but could not send. The announcement keys on the
705
+ // pull's verdict about CONTENT — commits that left the tree identical are
706
+ // nothing for a browser to refetch or a catalog to drop.
707
+ const owed = this.owedAnnouncements.get(id);
708
+ if (!treeChanged && !owed) return outcome;
709
+ const announce = {
710
+ after: after ?? owed?.after ?? '',
711
+ changedPaths: [...new Set([...(owed?.changedPaths ?? []), ...changedPaths])],
712
+ };
713
+ try {
714
+ // Announce the new tree. The tree event alone already refreshes every
715
+ // open file explorer and, on the default branch, drops the skill / tool /
716
+ // plugin catalogues (`catalog-cache-invalidation.ts`); the CR list
717
+ // caches the touched paths it resolved against the old tree, so drop
718
+ // that too.
719
+ this.prs.invalidateListCache();
720
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: id, branch });
721
+ // Per-file events so an open tab on a file the host rewrote refetches
722
+ // instead of showing stale bytes until its next tree refresh. The paths
723
+ // were observed under the same hold as the pull, so they are exactly
724
+ // this sync's — never a save that landed alongside. Capped: a bulk
725
+ // import would otherwise fan hundreds of events out to say what one
726
+ // refresh says.
727
+ if (announce.changedPaths.length <= WorkflowService.SYNC_FILE_EVENT_CAP) {
728
+ for (const p of announce.changedPaths) {
729
+ this.events?.emit({
730
+ kind: 'file-changed',
731
+ workspaceId: id,
732
+ branch,
733
+ path: `${this.kbDirName}/${p}`,
734
+ newSha: announce.after,
735
+ byUserId: 'system',
736
+ byUserName: 'Git sync',
737
+ });
738
+ }
739
+ }
740
+ } catch (err) {
741
+ // The pull landed, so origin is fine and no banner is raised — but the
742
+ // caller must hear that this branch's announcement did not happen, and
743
+ // the announcement stays owed: the retry the error invites will not
744
+ // move HEAD again, so it must find the debt here rather than in the shas.
745
+ this.owedAnnouncements.set(id, announce);
746
+ const message = sanitizeError(err);
747
+ syncLog.warn(`pulled "${branch}" but could not announce it: ${message}`);
748
+ return { branch, outcome: 'error', error: message };
749
+ }
750
+ this.owedAnnouncements.delete(id);
751
+ return outcome;
752
+ }
753
+
459
754
  /**
460
755
  * A pull hit a rebase conflict — the workspace carries local commits that
461
756
  * conflict with origin, a state that never resolves on its own (every
@@ -496,15 +791,12 @@ export class WorkflowService implements IWorkflowService {
496
791
  authorName: user?.name ?? RECOVERY_BOT_NAME,
497
792
  });
498
793
  if (queued) {
499
- console.warn(
500
- `[workflow] pull conflict on ws=${workspaceId} branch=${err.branch} (${err.conflictedPaths.join(', ')}) — queued background recovery`,
794
+ log.warn(
795
+ `pull conflict on ws=${workspaceId} branch=${err.branch} (${err.conflictedPaths.join(', ')}) — queued background recovery`,
501
796
  );
502
797
  }
503
798
  } catch (queueErr) {
504
- console.error(
505
- `[workflow] failed to queue pull-conflict recovery for ws=${workspaceId}:`,
506
- queueErr instanceof Error ? queueErr.message : queueErr,
507
- );
799
+ log.error(`failed to queue pull-conflict recovery for ws=${workspaceId}:`, { err: queueErr });
508
800
  }
509
801
  }
510
802
 
@@ -574,6 +866,23 @@ export class WorkflowService implements IWorkflowService {
574
866
  return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
575
867
  }
576
868
 
869
+ fileAtForkPoint(
870
+ workspaceId: string,
871
+ baseBranch: string,
872
+ sha: string,
873
+ path: string,
874
+ ): Promise<string | null> {
875
+ return this.git.readFileAtForkPoint(workspaceId, baseBranch, sha, path);
876
+ }
877
+
878
+ changeRequestForkPoint(
879
+ workspaceId: string,
880
+ baseBranch: string,
881
+ headBranch: string,
882
+ ): Promise<string | null> {
883
+ return this.git.mergeBaseForPr(workspaceId, baseBranch, headBranch);
884
+ }
885
+
577
886
  showFileAtChange(workspaceId: string, path: string, sha: string): Promise<string> {
578
887
  return this.git.diffFileAtCommit(workspaceId, path, sha);
579
888
  }
@@ -601,13 +910,23 @@ export class WorkflowService implements IWorkflowService {
601
910
  * Always reads at-ref, never at the working tree, so a user can't broaden
602
911
  * their own access by editing `roles.yaml` / `access.md` in the same
603
912
  * session.
913
+ *
914
+ * Resolves to `'restore'` when the write passed ONLY as a platform-file
915
+ * restore — the caller then knows the destination denies this admin, and
916
+ * the read gate that follows stands aside for the same rescue.
604
917
  */
605
918
  private async assertCanWriteAtPath(
606
919
  workspaceId: string,
607
920
  branch: string,
608
921
  userEmail: string,
609
922
  targetPath: string,
610
- ): Promise<void> {
923
+ /**
924
+ * The caller says this write is an admin putting a misplaced platform file
925
+ * back, and names where the file is coming from. Checked in both halves,
926
+ * never taken on trust — see `acquireLock`'s `opts`.
927
+ */
928
+ platformRestore?: { source: string },
929
+ ): Promise<'granted' | 'restore'> {
611
930
  // The lock route passes workspace-relative paths
612
931
  // (`knowledge-base/GTM/.../Foo.md`), but the access model is keyed by
613
932
  // *repo-relative* paths — `git ls-tree` runs inside the inner repo dir,
@@ -617,17 +936,36 @@ export class WorkflowService implements IWorkflowService {
617
936
  // typically only grants Admin → non-admins get 403'd on files their
618
937
  // role's nested access.md actually permits. Mirrors the frontend's
619
938
  // `useFileAccess` prefix-strip on the way INTO the API.
620
- const repoRelative = targetPath.startsWith(`${this.kbDirName}/`)
621
- ? targetPath.slice(this.kbDirName.length + 1)
622
- : targetPath;
939
+ const toRepoRelative = (p: string): string =>
940
+ p.startsWith(`${this.kbDirName}/`) ? p.slice(this.kbDirName.length + 1) : p;
941
+ const repoRelative = toRepoRelative(targetPath);
623
942
  const result = await this.accessControl.canWriteBatchAtRef(
624
943
  workspaceId,
625
944
  `HEAD`,
626
945
  userEmail,
627
946
  [repoRelative],
628
947
  );
629
- if (!result) return; // no config at ref → default-allow (bootstrap)
630
- if (result.get(repoRelative)) return;
948
+ if (!result) return 'granted'; // no config at ref → default-allow (bootstrap)
949
+ if (result.get(repoRelative)) return 'granted';
950
+ // The one write allowed past a destination that denies it: an admin
951
+ // putting a platform file back where the platform reads it. The access
952
+ // module decides who — a repository whose root `access.md` is the file
953
+ // that went missing denies everyone, including the admin who would
954
+ // restore it — and the shape check decides WHICH MOVE may even ask.
955
+ //
956
+ // Both are asked here rather than trusted from the route, because
957
+ // `canRestorePlatformFile` answers only on where the write LANDS: on its
958
+ // own it would approve carrying the root's own `access.md` into a folder
959
+ // that has none, which is the loss this feature exists to prevent, spelled
960
+ // as its own rescue. The source is part of the claim precisely so no
961
+ // future caller can make that claim by passing a flag.
962
+ if (
963
+ typeof platformRestore?.source === 'string' &&
964
+ isPlatformRestoreShape(toRepoRelative(platformRestore.source), repoRelative) &&
965
+ (await this.accessControl.canRestorePlatformFile(workspaceId, userEmail, repoRelative))
966
+ ) {
967
+ return 'restore';
968
+ }
631
969
  const eligible = await this.accessControl.eligibleWritersAtRef(
632
970
  workspaceId,
633
971
  `HEAD`,
@@ -642,13 +980,26 @@ export class WorkflowService implements IWorkflowService {
642
980
 
643
981
  // ── File locks ────────────────────────────────────────────────────────────
644
982
 
983
+ // Every method below canonicalises the caller's spelling into ONE file
984
+ // identity before it does anything with it. `FileLockService` canonicalises
985
+ // too, and has to: it is the coordination point and a caller can reach it
986
+ // without coming through here. The reason to do it again at this layer is
987
+ // that the path does not only key a lock row here. It is also what the
988
+ // permission gate is evaluated against, what `commitFile` stages, what the
989
+ // commit queue enqueues, and what rides out on every `lock-*` event. Left
990
+ // raw, a checkpoint on `./x//a.md` would find its lock and then fail at the
991
+ // commit, and a release would enqueue a row the worker keys differently from
992
+ // the lock it just dropped. Canonicalising is idempotent, so the second pass
993
+ // inside the lock service is free.
994
+
645
995
  async acquireLock(
646
996
  workspaceId: string,
647
997
  branch: string,
648
- targetPath: string,
998
+ rawPath: string,
649
999
  user: AuthUser,
650
- opts?: { coordination?: boolean },
1000
+ opts?: { coordination?: boolean; platformRestore?: { source: string } },
651
1001
  ): Promise<AcquireLockResult> {
1002
+ const targetPath = canonicalFileIdentity(rawPath);
652
1003
  // **Permission check at lock acquisition, not at commit time.** Under the
653
1004
  // "disk is the source of truth" rule, once a write has landed on disk we
654
1005
  // must never reject the commit that publishes it — otherwise we'd be
@@ -658,14 +1009,24 @@ export class WorkflowService implements IWorkflowService {
658
1009
  // the lock and the editor never opens. Once the lock is in hand, the
659
1010
  // commit + push pipeline does not re-check (see `commitFile`).
660
1011
  //
661
- // **Protected branches only** (mirrors the legacy commit-time gate). On
662
- // feature/draft branches anyone can write; canonical state changes go
663
- // through change-request approval, which is where the real security
664
- // boundary lives. Checking at HEAD (not at the working tree) so a user
665
- // can't grant themselves access by editing `roles.yaml` in the same
1012
+ // **Write gate — protected branches only** (mirrors the legacy commit-time
1013
+ // gate). On feature/draft branches anyone can write; canonical state
1014
+ // changes go through change-request approval, which is where the real
1015
+ // security boundary lives. Checking at HEAD (not at the working tree) so a
1016
+ // user can't grant themselves access by editing `roles.yaml` in the same
666
1017
  // session.
667
1018
  //
668
- // **Coordination acquires skip the gate** (see the interface doc): the
1019
+ // **Read gate — every branch.** Nothing is created, changed or removed
1020
+ // where its author cannot read, drafts included: a proposal that lands in
1021
+ // a folder its author cannot see would vanish from them the moment it was
1022
+ // added, and a write grant only implies read — a nearer `deny read` can
1023
+ // take the read away while the write stands. The one exception, a new
1024
+ // folder directly under one of the three roots, and the reasons are in
1025
+ // `access-model/change-gate.ts`. A write that passed only as a
1026
+ // platform-file restore is not asked: that rescue exists exactly for a
1027
+ // destination whose rules deny the admin making it.
1028
+ //
1029
+ // **Coordination acquires skip both gates** (see the interface doc): the
669
1030
  // caller wants only mutual exclusion with the path's writer — e.g. the
670
1031
  // roles admin holding machine-owned `synced-groups.yaml` steady across
671
1032
  // its IdP-mode recheck — and will never write the path. Gating those on
@@ -674,15 +1035,27 @@ export class WorkflowService implements IWorkflowService {
674
1035
  // authority flows from the hold: the mode is persisted on the lock row
675
1036
  // and `commitFileWhileLocked` / `releaseLock` refuse to treat a
676
1037
  // coordination hold as write possession (see those methods).
677
- if (isProtectedBranch(branch) && !opts?.coordination) {
678
- // `assertCanWriteAtPath` throws AccessDeniedError on denial, with the
679
- // eligible-writers payload so the frontend can render a useful refusal.
680
- await this.assertCanWriteAtPath(workspaceId, branch, user.email, targetPath);
1038
+ if (!opts?.coordination) {
1039
+ let via: 'granted' | 'restore' = 'granted';
1040
+ if (isProtectedBranch(branch)) {
1041
+ // `assertCanWriteAtPath` throws AccessDeniedError on denial, with the
1042
+ // eligible-writers payload so the frontend can render a useful refusal.
1043
+ via = await this.assertCanWriteAtPath(
1044
+ workspaceId,
1045
+ branch,
1046
+ user.email,
1047
+ targetPath,
1048
+ opts?.platformRestore,
1049
+ );
1050
+ }
1051
+ if (via !== 'restore') {
1052
+ await this.changeGate.assertMayChange(workspaceId, user.email, targetPath, 'file');
1053
+ }
681
1054
  }
682
1055
  const result = await this.fileLocks.acquire(workspaceId, branch, targetPath, user, opts);
683
1056
  if (result.acquired) {
684
- console.log(
685
- `[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → acquired`,
1057
+ lockLog.info(
1058
+ `ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → acquired`,
686
1059
  );
687
1060
  // Only fire on successful acquisition — a contention "false" return
688
1061
  // means nothing observable changed for other users (someone else
@@ -697,8 +1070,8 @@ export class WorkflowService implements IWorkflowService {
697
1070
  holderName: user.name,
698
1071
  });
699
1072
  } else {
700
- console.log(
701
- `[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`,
1073
+ lockLog.info(
1074
+ `ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`,
702
1075
  );
703
1076
  }
704
1077
  return result;
@@ -707,18 +1080,19 @@ export class WorkflowService implements IWorkflowService {
707
1080
  async heartbeatLock(
708
1081
  workspaceId: string,
709
1082
  branch: string,
710
- targetPath: string,
1083
+ rawPath: string,
711
1084
  user: AuthUser,
712
1085
  ): Promise<FileLock> {
1086
+ const targetPath = canonicalFileIdentity(rawPath);
713
1087
  try {
714
1088
  const lock = await this.fileLocks.heartbeat(workspaceId, branch, targetPath, user);
715
- console.log(
716
- `[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ok (expires ${lock.expiresAt})`,
1089
+ lockLog.info(
1090
+ `HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ok (expires ${lock.expiresAt})`,
717
1091
  );
718
1092
  return lock;
719
1093
  } catch (err) {
720
- console.warn(
721
- `[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → FAIL ${err instanceof Error ? err.message : err}`,
1094
+ lockLog.warn(
1095
+ `HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → FAIL ${err instanceof Error ? err.message : err}`,
722
1096
  );
723
1097
  throw err;
724
1098
  }
@@ -734,10 +1108,11 @@ export class WorkflowService implements IWorkflowService {
734
1108
  async commitFileWhileLocked(
735
1109
  workspaceId: string,
736
1110
  branch: string,
737
- targetPath: string,
1111
+ rawPath: string,
738
1112
  user: AuthUser,
739
1113
  summary?: string,
740
1114
  ): Promise<Change | null> {
1115
+ const targetPath = canonicalFileIdentity(rawPath);
741
1116
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
742
1117
  if (!lock || lock.holderUserId !== user.id) {
743
1118
  throw new WorkflowValidationError(
@@ -782,15 +1157,15 @@ export class WorkflowService implements IWorkflowService {
782
1157
  let recoveryError: unknown = null;
783
1158
  if (looksLikeNonFastForward) {
784
1159
  try {
785
- await this.git.pull(workspaceId);
1160
+ await this.pullWorkspace(workspaceId);
786
1161
  await this.git.push(workspaceId, user);
787
1162
  recovered = true;
788
1163
  this.noteGitSyncOk(workspaceId, branch);
789
1164
  } catch (recoveryErr) {
790
1165
  recoveryError = recoveryErr;
791
- console.warn(
792
- '[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
793
- recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
1166
+ log.warn(
1167
+ 'autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
1168
+ { err: recoveryErr },
794
1169
  );
795
1170
  }
796
1171
  }
@@ -809,10 +1184,7 @@ export class WorkflowService implements IWorkflowService {
809
1184
  if (!looksLikeNonFastForward) {
810
1185
  this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? err);
811
1186
  }
812
- console.warn(
813
- '[workflow] push after autosave commit failed (commit landed locally):',
814
- detail,
815
- );
1187
+ log.warn('push after autosave commit failed (commit landed locally):', { detail });
816
1188
  }
817
1189
  }
818
1190
  this.events?.emit({
@@ -861,21 +1233,20 @@ export class WorkflowService implements IWorkflowService {
861
1233
  async releaseLock(
862
1234
  workspaceId: string,
863
1235
  branch: string,
864
- targetPath: string,
1236
+ rawPath: string,
865
1237
  user: AuthUser,
866
1238
  ): Promise<void> {
1239
+ const targetPath = canonicalFileIdentity(rawPath);
867
1240
  // Ownership check — the lock service's `release` is idempotent and
868
1241
  // would silently no-op for a non-holder, but we'd still enqueue a
869
1242
  // commit attributed to whoever called us. The guard rejects callers
870
1243
  // who don't actually hold the row so impersonation can't enqueue
871
1244
  // commits as a third party.
872
- console.log(
873
- `[lock] RELEASE start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
874
- );
1245
+ lockLog.info(`RELEASE start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`);
875
1246
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
876
1247
  if (!lock || lock.holderUserId !== user.id) {
877
- console.warn(
878
- `[lock] RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`,
1248
+ lockLog.warn(
1249
+ `RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`,
879
1250
  );
880
1251
  throw new WorkflowValidationError(
881
1252
  `Cannot release lock on "${targetPath}": not held by you (or no longer exists).`,
@@ -892,8 +1263,8 @@ export class WorkflowService implements IWorkflowService {
892
1263
  // and `releaseLockUntouched`. Worst case a refused caller strands the
893
1264
  // row until its TTL.
894
1265
  if (lock.mode === 'coordination') {
895
- console.warn(
896
- `[lock] RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → coordination hold (no commit may be enqueued)`,
1266
+ lockLog.warn(
1267
+ `RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → coordination hold (no commit may be enqueued)`,
897
1268
  );
898
1269
  throw new WorkflowValidationError(
899
1270
  `Cannot release lock on "${targetPath}" with a commit: it is a coordination hold. Use releaseLockNoCommit.`,
@@ -918,8 +1289,8 @@ export class WorkflowService implements IWorkflowService {
918
1289
  authorName: user.name,
919
1290
  });
920
1291
  await this.fileLocks.release(workspaceId, branch, targetPath, user);
921
- console.log(
922
- `[lock] RELEASE done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} (commit queued for background worker)`,
1292
+ lockLog.info(
1293
+ `RELEASE done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} (commit queued for background worker)`,
923
1294
  );
924
1295
  // The `file-changed` SSE event used to fire here after the synchronous
925
1296
  // commit landed, carrying the new sha. Under the queue model the
@@ -1021,7 +1392,7 @@ export class WorkflowService implements IWorkflowService {
1021
1392
  // emitted id so every consumer sees one spelling.
1022
1393
  const id = branchForWorkspaceId(workspaceId);
1023
1394
  if (!this.gitSyncFailing.delete(id)) return;
1024
- console.log(`[workflow] git sync recovered for workspace=${id} branch=${branch}`);
1395
+ log.info(`git sync recovered for workspace=${id} branch=${branch}`);
1025
1396
  this.events?.emit({ kind: 'git-sync-recovered', workspaceId: id, branch });
1026
1397
  }
1027
1398
 
@@ -1033,7 +1404,34 @@ export class WorkflowService implements IWorkflowService {
1033
1404
  * machinery is unaffected — all this adds is that someone finds out, rather
1034
1405
  * than the deployment looking healthy while nothing reaches the remote.
1035
1406
  */
1036
- private noteGitSyncFailed(workspaceId: string, branch: string, err: unknown): void {
1407
+ private noteGitSyncFailed(
1408
+ workspaceId: string,
1409
+ branch: string,
1410
+ err: unknown,
1411
+ /**
1412
+ * The files a person must reconcile, and the sentence we composed about
1413
+ * them. That sentence is ours (branch name + repo paths, no git stderr),
1414
+ * so it goes out verbatim — the same string the sync response and the
1415
+ * log carry — rather than through the sanitiser, whose 200-character
1416
+ * clip would make the banner disagree with them. Callers that already
1417
+ * hold the sentence pass it; otherwise it is derived below.
1418
+ */
1419
+ explicitConflict?: { paths: string[]; message: string },
1420
+ ): void {
1421
+ // A rebase conflict is a conflict whichever path ran into it. The remote
1422
+ // sync names its files on purpose; the push-recovery paths (autosave, the
1423
+ // lock release, the queued retries) raise the same banner with the raw
1424
+ // error — and the banner keeps the LATEST failure per branch, so a retry
1425
+ // landing after the sync would replace the conflict variant, with the
1426
+ // files as links, by the generic "check the server logs" one. Seen on
1427
+ // staging: the sync's 409 named the file, the worker's retry a moment
1428
+ // later took the links away. Derive the paths from the error itself so
1429
+ // every path agrees.
1430
+ const conflict =
1431
+ explicitConflict ??
1432
+ (err instanceof PullRebaseConflictError
1433
+ ? { paths: err.conflictedPaths, message: syncConflictMessage(branch, err.conflictedPaths) }
1434
+ : undefined);
1037
1435
  // Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
1038
1436
  const id = branchForWorkspaceId(workspaceId);
1039
1437
  this.gitSyncFailing.add(id);
@@ -1043,7 +1441,8 @@ export class WorkflowService implements IWorkflowService {
1043
1441
  branch,
1044
1442
  // Git stderr can quote a credentialed URL; the banner is user-facing and
1045
1443
  // the string also lands in client logs, so sanitize before it leaves.
1046
- reason: sanitizeError(err),
1444
+ reason: conflict ? conflict.message : sanitizeError(err),
1445
+ ...(conflict ? { conflictedPaths: conflict.paths } : {}),
1047
1446
  });
1048
1447
  }
1049
1448
 
@@ -1102,19 +1501,19 @@ export class WorkflowService implements IWorkflowService {
1102
1501
  let recoveryError: unknown = null;
1103
1502
  if (looksLikeNonFastForward) {
1104
1503
  try {
1105
- await this.git.pull(workspaceId);
1504
+ await this.pullWorkspace(workspaceId);
1106
1505
  await this.git.push(workspaceId, user, opts);
1107
1506
  recovered = true;
1108
1507
  this.noteGitSyncOk(workspaceId, branch);
1109
- console.log(
1110
- `[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
1508
+ log.info(
1509
+ `non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
1111
1510
  );
1112
1511
  } catch (recoveryErr) {
1113
1512
  recoveryError = recoveryErr;
1114
1513
  recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
1115
- console.warn(
1116
- `[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
1117
- recoveryDetail,
1514
+ log.warn(
1515
+ `cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
1516
+ { detail: recoveryDetail },
1118
1517
  );
1119
1518
  }
1120
1519
  }
@@ -1127,9 +1526,9 @@ export class WorkflowService implements IWorkflowService {
1127
1526
  // first rejection may be a stale non-fast-forward the pull already
1128
1527
  // cured); when it was skipped, the first error is all there is.
1129
1528
  this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
1130
- console.warn(
1131
- `[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
1132
- firstDetail,
1529
+ log.warn(
1530
+ `push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
1531
+ { detail: firstDetail },
1133
1532
  );
1134
1533
  throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail);
1135
1534
  }
@@ -1148,22 +1547,23 @@ export class WorkflowService implements IWorkflowService {
1148
1547
  async releaseLockNoCommit(
1149
1548
  workspaceId: string,
1150
1549
  branch: string,
1151
- targetPath: string,
1550
+ rawPath: string,
1152
1551
  user: AuthUser,
1153
1552
  ): Promise<void> {
1553
+ const targetPath = canonicalFileIdentity(rawPath);
1154
1554
  // Verify ownership first so the emit only fires when something
1155
1555
  // observable actually changed. `fileLocks.release` silently no-ops
1156
1556
  // when the caller doesn't hold the row (idempotent-by-design), so
1157
1557
  // without this guard a non-holder calling this method would emit
1158
1558
  // `lock-released` and trick every other client into clearing their
1159
1559
  // "Locked by X" banner even though the lock is still held.
1160
- console.log(
1161
- `[lock] RELEASE-NO-COMMIT start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
1560
+ lockLog.info(
1561
+ `RELEASE-NO-COMMIT start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
1162
1562
  );
1163
1563
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
1164
1564
  if (!lock || lock.holderUserId !== user.id) {
1165
- console.log(
1166
- `[lock] RELEASE-NO-COMMIT no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
1565
+ lockLog.info(
1566
+ `RELEASE-NO-COMMIT no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
1167
1567
  );
1168
1568
  return;
1169
1569
  }
@@ -1191,10 +1591,9 @@ export class WorkflowService implements IWorkflowService {
1191
1591
  } catch (err) {
1192
1592
  // Queue unreadable — fall back to the discard (the strict default:
1193
1593
  // never let a coordination hold end with publishable stray bytes).
1194
- console.warn(
1195
- `[workflow] pending-commit lookup failed for workspace=${workspaceId} path=${targetPath}; discarding:`,
1196
- err instanceof Error ? err.message : err,
1197
- );
1594
+ log.warn(`pending-commit lookup failed for workspace=${workspaceId} path=${targetPath}; discarding:`, {
1595
+ err,
1596
+ });
1198
1597
  }
1199
1598
  }
1200
1599
  let discarded = false;
@@ -1206,15 +1605,14 @@ export class WorkflowService implements IWorkflowService {
1206
1605
  // Best-effort: a discard failure is logged but doesn't block the
1207
1606
  // lock release. Worst case the working tree stays dirty for one
1208
1607
  // path until the next save on it cleans up.
1209
- console.warn(
1210
- `[workflow] discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`,
1211
- err instanceof Error ? err.message : err,
1212
- );
1608
+ log.warn(`discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`, {
1609
+ err,
1610
+ });
1213
1611
  }
1214
1612
  }
1215
1613
  await this.fileLocks.release(workspaceId, branch, targetPath, user);
1216
- console.log(
1217
- `[lock] RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${discarded ? 'discarded + released' : 'released (working tree untouched)'}`,
1614
+ lockLog.info(
1615
+ `RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${discarded ? 'discarded + released' : 'released (working tree untouched)'}`,
1218
1616
  );
1219
1617
  this.events?.emit({
1220
1618
  kind: 'lock-released',
@@ -1253,22 +1651,23 @@ export class WorkflowService implements IWorkflowService {
1253
1651
  async releaseLockUntouched(
1254
1652
  workspaceId: string,
1255
1653
  branch: string,
1256
- targetPath: string,
1654
+ rawPath: string,
1257
1655
  user: AuthUser,
1258
1656
  ): Promise<void> {
1259
- console.log(
1260
- `[lock] RELEASE-UNTOUCHED start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
1657
+ const targetPath = canonicalFileIdentity(rawPath);
1658
+ lockLog.info(
1659
+ `RELEASE-UNTOUCHED start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
1261
1660
  );
1262
1661
  const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
1263
1662
  if (!lock || lock.holderUserId !== user.id) {
1264
- console.log(
1265
- `[lock] RELEASE-UNTOUCHED no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
1663
+ lockLog.info(
1664
+ `RELEASE-UNTOUCHED no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
1266
1665
  );
1267
1666
  return;
1268
1667
  }
1269
1668
  await this.fileLocks.release(workspaceId, branch, targetPath, user);
1270
- console.log(
1271
- `[lock] RELEASE-UNTOUCHED done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → released (disk + queue untouched)`,
1669
+ lockLog.info(
1670
+ `RELEASE-UNTOUCHED done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → released (disk + queue untouched)`,
1272
1671
  );
1273
1672
  this.events?.emit({
1274
1673
  kind: 'lock-released',
@@ -1280,8 +1679,10 @@ export class WorkflowService implements IWorkflowService {
1280
1679
  // nothing on disk moved.
1281
1680
  }
1282
1681
 
1283
- hasQueuedCommit(workspaceId: string, branch: string, targetPath: string): Promise<boolean> {
1284
- return this.pendingCommits.hasLiveRowFor(workspaceId, branch, targetPath);
1682
+ hasQueuedCommit(workspaceId: string, branch: string, rawPath: string): Promise<boolean> {
1683
+ // The row was stored under the canonical identity (`releaseLock`), so the
1684
+ // question is asked under it too — whatever spelling the caller has.
1685
+ return this.pendingCommits.hasLiveRowFor(workspaceId, branch, canonicalFileIdentity(rawPath));
1285
1686
  }
1286
1687
 
1287
1688
  hasUnpushedCommits(workspaceId: string): Promise<boolean> {
@@ -1291,9 +1692,9 @@ export class WorkflowService implements IWorkflowService {
1291
1692
  getLock(
1292
1693
  workspaceId: string,
1293
1694
  branch: string,
1294
- targetPath: string,
1695
+ rawPath: string,
1295
1696
  ): Promise<FileLock | null> {
1296
- return this.fileLocks.get(workspaceId, branch, targetPath);
1697
+ return this.fileLocks.get(workspaceId, branch, canonicalFileIdentity(rawPath));
1297
1698
  }
1298
1699
 
1299
1700
  // ── Change Requests ───────────────────────────────────────────────────────
@@ -1449,7 +1850,7 @@ export class WorkflowService implements IWorkflowService {
1449
1850
  targetBranch: input.targetBranch,
1450
1851
  title: input.title.trim(),
1451
1852
  body,
1452
- authorEmail: user.email.trim().toLowerCase(),
1853
+ authorEmail: canonicalEmail(user.email),
1453
1854
  authorName: user.name,
1454
1855
  })
1455
1856
  .returning({ number: changeRequests.number });
@@ -1531,10 +1932,20 @@ export class WorkflowService implements IWorkflowService {
1531
1932
  * as `ChangeRequestConflictsError`; the caller (UI or agent) is expected
1532
1933
  * to route into the resolution flow exactly as on open.
1533
1934
  *
1534
- * Approvals on files touched by the merge resolution invalidate
1535
- * automatically — they're pinned to the head SHA, and any new commit on
1536
- * source drops the per-file approval gate (handled by the existing
1537
- * approval staleness check). No special bookkeeping needed here.
1935
+ * Approvals are carried across the merge BY CONTENT: every approval on a
1936
+ * file whose bytes the merge left alone is re-pinned to the new head, and
1937
+ * only a file the merge actually changed loses its approval (staleness is
1938
+ * still `row.headSha !== headSha`, unchanged). Without this, a merge
1939
+ * nobody asked for would void every approval on the request — including
1940
+ * the files it never touched — and since the dialog now runs this update
1941
+ * by itself, every stale request would cost a second round of review.
1942
+ * This holds for EVERY caller of the route, agent or UI.
1943
+ *
1944
+ * Authority: the request's author, or anyone who may apply it — the
1945
+ * detail's `viewerCanUpdate`, computed for THIS caller, so the button and
1946
+ * the enforcement read one predicate. A conflicting merge is aborted
1947
+ * inside `mergeFromOrigin`: nothing is committed or pushed, and the branch
1948
+ * is exactly what it was.
1538
1949
  */
1539
1950
  async updateFromTarget(
1540
1951
  workspaceId: string,
@@ -1554,6 +1965,51 @@ export class WorkflowService implements IWorkflowService {
1554
1965
  { kind: 'change-request-not-open', state: detail.state },
1555
1966
  );
1556
1967
  }
1968
+ if (!detail.viewerCanUpdate) {
1969
+ throw new WorkflowDomainError(
1970
+ 'Only the author of this change request, or someone who may apply it, can update it.',
1971
+ 403,
1972
+ );
1973
+ }
1974
+ // Taken BEFORE the head moves (the pull below can move it too): only a
1975
+ // refusal recorded earlier describes the revision this replaces (see
1976
+ // `clearApplyFailure`).
1977
+ const headMovedAfter = new Date();
1978
+ // Freshen the source checkout first, and let a failure stop the Update:
1979
+ // merging onto a head behind its own origin branch would leave a local
1980
+ // merge commit that then cannot push, stranding it in the workspace. A
1981
+ // rebase conflict hands the stranded saves to recovery, as every other
1982
+ // pull does, before the refusal reaches the caller.
1983
+ try {
1984
+ await this.pullWorkspace(workspaceId);
1985
+ } catch (err) {
1986
+ if (err instanceof PullRebaseConflictError) {
1987
+ await this.queuePullConflictRecovery(workspaceId, err, user);
1988
+ }
1989
+ throw err;
1990
+ }
1991
+ // The head an approval is pinned to is whatever is PUBLISHED on the
1992
+ // request's branch — and the pull above may just have moved it: an agent,
1993
+ // or the author from another client, can push while this dialog is still
1994
+ // reading the detail. Resolve it HERE rather than trusting the
1995
+ // `detail.headSha` read before the pull: an approval made against that
1996
+ // newer head carries a sha the pre-pull detail does not name, so carrying
1997
+ // forward from the stale one would find no rows and void every approval
1998
+ // the merge never touched.
1999
+ //
2000
+ // Best effort, like the carry-forward it feeds: the bookkeeping must never
2001
+ // be what stops a request from being brought up to date, so a failed
2002
+ // resolve falls back to the sha the detail reported.
2003
+ let headBeforeMerge = detail.headSha;
2004
+ try {
2005
+ const at = await this.git.resolvePrShas(workspaceId, detail.base, detail.branch);
2006
+ headBeforeMerge = at.headSha;
2007
+ } catch (err) {
2008
+ log.warn(
2009
+ `could not resolve the published head of change request #${number} before updating it from target; falling back to the head its detail reported`,
2010
+ { err },
2011
+ );
2012
+ }
1557
2013
  const outcome = await this.git.mergeFromOrigin(
1558
2014
  workspaceId,
1559
2015
  detail.branch,
@@ -1567,6 +2023,8 @@ export class WorkflowService implements IWorkflowService {
1567
2023
  // already up to date there's nothing to share — short-circuit the push.
1568
2024
  if (!outcome.alreadyUpToDate) {
1569
2025
  await this.trackedPush(workspaceId, user);
2026
+ // The source head moved: the refusal described a revision that is gone.
2027
+ await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
1570
2028
  }
1571
2029
  this.prs.invalidateDetailCache(number);
1572
2030
  const refreshed = await this.prs.getPrDetail(number, {
@@ -1579,6 +2037,51 @@ export class WorkflowService implements IWorkflowService {
1579
2037
  `Refreshed change request #${number} but could not re-fetch its detail.`,
1580
2038
  );
1581
2039
  }
2040
+ // The merge is a commit the APPROVERS did not make. Re-pin their
2041
+ // approvals onto the new head for every file whose bytes it left alone —
2042
+ // git's own two-dot diff between the two heads is the verdict on which
2043
+ // those are. Best effort: a request that IS up to date is worth far more
2044
+ // than the bookkeeping, so a failure here is logged and the fresh detail
2045
+ // still goes back.
2046
+ //
2047
+ // `alreadyUpToDate` is the real gate: no merge commit, no head to compare,
2048
+ // nothing moved under anyone. The other two conditions are belt-and-braces
2049
+ // over a sha that is not a sha — a resolve that failed onto a detail with
2050
+ // no head, or two heads that somehow read equal — both of which
2051
+ // `pathsChangedBetween` and `carryApprovalsForward` would refuse anyway.
2052
+ // A merge that authored a commit cannot leave the head where it was.
2053
+ if (!outcome.alreadyUpToDate && headBeforeMerge && refreshed.headSha !== headBeforeMerge) {
2054
+ try {
2055
+ const changedPaths = await this.git.pathsChangedBetween(
2056
+ workspaceId,
2057
+ headBeforeMerge,
2058
+ refreshed.headSha,
2059
+ );
2060
+ const carried = await this.reviewWorkflow.carryApprovalsForward(
2061
+ number,
2062
+ headBeforeMerge,
2063
+ refreshed.headSha,
2064
+ changedPaths,
2065
+ );
2066
+ // The detail above was assembled before those rows existed, so its
2067
+ // `approvals` (and the merge gate derived from them) still describe
2068
+ // the pre-merge head. Only re-read when something actually carried.
2069
+ if (carried > 0) {
2070
+ this.prs.invalidateDetailCache(number);
2071
+ const withApprovals = await this.prs.getPrDetail(number, {
2072
+ fresh: true,
2073
+ workspaceId,
2074
+ viewerEmail: user.email,
2075
+ });
2076
+ if (withApprovals) return withApprovals;
2077
+ }
2078
+ } catch (err) {
2079
+ log.warn(
2080
+ `could not carry approvals forward on change request #${number} after updating from target`,
2081
+ { err },
2082
+ );
2083
+ }
2084
+ }
1582
2085
  return refreshed;
1583
2086
  }
1584
2087
 
@@ -1623,6 +2126,8 @@ export class WorkflowService implements IWorkflowService {
1623
2126
  authorIdHash: string | null,
1624
2127
  workspaceId: string,
1625
2128
  ): Promise<FileApproval[]> {
2129
+ // Taken BEFORE the approval lands (see `clearApplyFailure`).
2130
+ const approvedAfter = new Date();
1626
2131
  const approvals = await this.reviewWorkflow.approveFile(
1627
2132
  number,
1628
2133
  path,
@@ -1633,6 +2138,14 @@ export class WorkflowService implements IWorkflowService {
1633
2138
  authorIdHash,
1634
2139
  workspaceId,
1635
2140
  );
2141
+ // A recorded approval can answer a GATE refusal only — a conflict or a git
2142
+ // error still describes the request — and only once the gate that refused
2143
+ // would now pass: approving one file of several leaves the rest waiting,
2144
+ // and the refusal naming them still stands. Warnings count too, since an
2145
+ // apply without bypass is refused on them.
2146
+ if (this.gateWouldPass(number, approvals)) {
2147
+ await this.clearApplyFailure(number, { kinds: ['gate'], recordedBefore: approvedAfter });
2148
+ }
1636
2149
  this.prs.invalidateDetailCache(number);
1637
2150
  this.events?.emit({
1638
2151
  kind: 'approval-changed',
@@ -1675,6 +2188,29 @@ export class WorkflowService implements IWorkflowService {
1675
2188
  return approvals;
1676
2189
  }
1677
2190
 
2191
+ /**
2192
+ * The folder placeholder a file's revert takes along, or null. When a
2193
+ * request removed a folder's last file, the placeholder that keeps the
2194
+ * folder came with it (git may even pair them as one rename); reverting the
2195
+ * file alone would leave that hidden placeholder as a change nobody can
2196
+ * see. So it is reverted too — when the request changed it, and only when
2197
+ * that cannot leave the folder with nothing in git: either the file comes
2198
+ * back, or the base had the placeholder to restore.
2199
+ */
2200
+ private async placeholderRevertedWith(
2201
+ workspaceId: string,
2202
+ mergeBase: string,
2203
+ repoRelPath: string,
2204
+ changedPaths: string[],
2205
+ ): Promise<string | null> {
2206
+ if (isFolderPlaceholder(repoRelPath)) return null;
2207
+ const slash = repoRelPath.lastIndexOf('/');
2208
+ const placeholder = folderPlaceholderPath(slash === -1 ? '' : repoRelPath.slice(0, slash));
2209
+ if (!changedPaths.includes(placeholder)) return null;
2210
+ if (await this.git.pathExistsAtRef(workspaceId, mergeBase, repoRelPath)) return placeholder;
2211
+ return (await this.git.pathExistsAtRef(workspaceId, mergeBase, placeholder)) ? placeholder : null;
2212
+ }
2213
+
1678
2214
  /**
1679
2215
  * Decline ONE file of an open change request: restore its merge-base
1680
2216
  * version on the SOURCE branch (commit + push), so the file drops out of
@@ -1704,13 +2240,15 @@ export class WorkflowService implements IWorkflowService {
1704
2240
  }
1705
2241
  const baseBranch = summary.base;
1706
2242
  const headBranch = summary.branch;
2243
+ // Taken BEFORE the head moves (see `clearApplyFailure`).
2244
+ const headMovedAfter = new Date();
1707
2245
 
1708
2246
  const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
1709
2247
  // Best-effort freshen of the source checkout: the diff below reads origin
1710
2248
  // refs, but the restore commits from the working tree — a stale tree
1711
2249
  // would push non-fast-forward and fail loudly anyway; this just makes
1712
2250
  // that rare.
1713
- await this.git.pull(ws.id).catch(() => undefined);
2251
+ await this.pullWorkspace(ws.id).catch(() => undefined);
1714
2252
 
1715
2253
  // The verb acts on the request as it is NOW — never a cached file list.
1716
2254
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
@@ -1736,42 +2274,373 @@ export class WorkflowService implements IWorkflowService {
1736
2274
  throw new WorkflowDomainError('These branches share no history to revert to.', 422);
1737
2275
  }
1738
2276
 
2277
+ const placeholder = await this.placeholderRevertedWith(ws.id, mergeBase, repoRelPath, paths);
2278
+ const reverted = placeholder ? [repoRelPath, placeholder] : [repoRelPath];
2279
+
1739
2280
  // Same per-file lock every other editor of this path takes — a concurrent
1740
2281
  // save must not race the restore between write and commit.
1741
- const lockPath = `${this.kbDirName}/${repoRelPath}`;
1742
- const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
1743
- if (!lock.acquired) {
1744
- throw new WorkflowDomainError(
1745
- `${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`,
1746
- 409,
1747
- );
1748
- }
2282
+ const held: string[] = [];
2283
+ let revertError: { reason: unknown } | null = null;
1749
2284
  try {
1750
- await this.git.restorePathFromRef(ws.id, mergeBase, repoRelPath);
1751
- await this.git.commitFile(
1752
- ws.id,
1753
- user,
1754
- repoRelPath,
1755
- `Revert ${repoRelPath} (declined in change request #${number})`,
1756
- true, // skipValidator — this restores an already-validated base version
1757
- );
2285
+ for (const p of reverted) {
2286
+ const lockPath = `${this.kbDirName}/${p}`;
2287
+ const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
2288
+ if (!lock.acquired) {
2289
+ throw new WorkflowDomainError(
2290
+ p === repoRelPath
2291
+ ? `${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`
2292
+ : `The folder of ${repoRelPath} is being changed by ${lock.lock.holderName} — try again once the edit settles.`,
2293
+ 409,
2294
+ );
2295
+ }
2296
+ held.push(lockPath);
2297
+ }
2298
+ for (const p of reverted) {
2299
+ await this.git.restorePathFromRef(ws.id, mergeBase, p);
2300
+ await this.git.commitFile(
2301
+ ws.id,
2302
+ user,
2303
+ p,
2304
+ `Revert ${p} (declined in change request #${number})`,
2305
+ true, // skipValidator — this restores an already-validated base version
2306
+ );
2307
+ }
1758
2308
  await this.trackedPush(ws.id, user);
1759
- } finally {
1760
- // Committed inline — drop the lock row directly rather than enqueueing
1761
- // a duplicate commit through releaseLock.
1762
- await this.fileLocks.release(ws.id, headBranch, lockPath, user);
2309
+ } catch (err) {
2310
+ revertError = { reason: err };
1763
2311
  }
2312
+ // Committed inline — drop the lock rows directly rather than enqueueing
2313
+ // a duplicate commit through releaseLock. Each is released on its own, on
2314
+ // success and failure alike: one failed release must not leave the other
2315
+ // lock held. The revert's own error wins; otherwise a failed release
2316
+ // still surfaces, as a single release's did.
2317
+ const released = await Promise.allSettled(
2318
+ held.map((lockPath) => this.fileLocks.release(ws.id, headBranch, lockPath, user)),
2319
+ );
2320
+ if (revertError) throw revertError.reason;
2321
+ const failedRelease = released.find((r): r is PromiseRejectedResult => r.status === 'rejected');
2322
+ if (failedRelease) throw failedRelease.reason;
2323
+ // The source head moved: the refusal described a revision that is gone.
2324
+ await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
1764
2325
  this.prs.invalidateDetailCache(number);
1765
2326
 
1766
2327
  const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1767
2328
  if (remaining.length > 0) {
1768
- return { closed: false, remainingPaths: remaining };
2329
+ // Answered like the file list: the placeholder is not a remaining file.
2330
+ return { closed: false, remainingPaths: remaining.filter((p) => !isFolderPlaceholder(p)) };
1769
2331
  }
1770
2332
  // Every change declined → the request proposes nothing.
1771
2333
  await this.closeEmptyChangeRequest(number, user);
1772
2334
  return { closed: true, remainingPaths: [] };
1773
2335
  }
1774
2336
 
2337
+ /**
2338
+ * The open change requests that propose files under a KB folder, and
2339
+ * whether the caller may take those files out of each — what a folder
2340
+ * delete asks before it offers "Delete folder and its proposed changes".
2341
+ *
2342
+ * The caller may act on a request they authored, on any request as an
2343
+ * admin, or on a request whose every proposed file under the folder they
2344
+ * may write — per file, not at the folder's top: a subfolder can deny a
2345
+ * writer of its parent. The last two are judged on `origin/<base>`, like
2346
+ * every other request verb. An access answer that cannot be resolved
2347
+ * (null) is a no.
2348
+ */
2349
+ async changeRequestsUnderFolder(folder: string, user: AuthUser): Promise<FolderChangeRequest[]> {
2350
+ const prefix = folderPrefix(folder);
2351
+ const open = await this.prs.listOpenPrs({ fresh: true });
2352
+ // The list's touched paths are best-effort: a diff it could not read comes
2353
+ // back EMPTY, indistinguishable from a request proposing nothing. This
2354
+ // answer gates a destructive action, so a request listed empty is read
2355
+ // again here, and one whose diff still cannot be read fails the question
2356
+ // rather than dropping out of it.
2357
+ const resolved = await Promise.all(
2358
+ open.map(async (cr) => {
2359
+ if (cr.touchedNodePaths.length > 0) return { cr, touched: cr.touchedNodePaths };
2360
+ try {
2361
+ const ws = await this.workspaceService.getOrCreateForBranch(cr.branch);
2362
+ return { cr, touched: await this.git.changedPathsForPr(ws.id, cr.base, cr.branch) };
2363
+ } catch (err) {
2364
+ log.warn(
2365
+ `changedPathsForPr failed for #${cr.number} under folder ${printable(folder)}: ${printable(sanitizeError(err))}`,
2366
+ );
2367
+ throw new WorkflowDomainError(
2368
+ `Couldn't read what change request #${cr.number} proposes — try again in a moment.`,
2369
+ 500,
2370
+ );
2371
+ }
2372
+ }),
2373
+ );
2374
+ const touching = resolved
2375
+ .map(({ cr, touched }) => ({ cr, paths: touched.filter((p) => p.startsWith(prefix)) }))
2376
+ .filter((t) => t.paths.length > 0);
2377
+ if (touching.length === 0) return [];
2378
+ const callerHash = hashEmail(user.email);
2379
+ // One admin answer per base branch; the requests almost always share one.
2380
+ const baseWorkspaces = new Map<string, Promise<string>>();
2381
+ const baseWorkspace = (base: string): Promise<string> => {
2382
+ let ws = baseWorkspaces.get(base);
2383
+ if (!ws) {
2384
+ ws = this.workspaceService.getOrCreateForBranch(base).then((w) => w.id);
2385
+ baseWorkspaces.set(base, ws);
2386
+ }
2387
+ return ws;
2388
+ };
2389
+ const adminByBase = new Map<string, Promise<boolean>>();
2390
+ const adminOn = (base: string): Promise<boolean> => {
2391
+ let admin = adminByBase.get(base);
2392
+ if (!admin) {
2393
+ admin = baseWorkspace(base).then(
2394
+ async (wsId) => (await this.accessControl.canWriteAtRef(wsId, `origin/${base}`, user.email, 'roles.yaml')) === true,
2395
+ );
2396
+ adminByBase.set(base, admin);
2397
+ }
2398
+ return admin;
2399
+ };
2400
+ const writerOfAll = async (base: string, paths: string[]): Promise<boolean> => {
2401
+ const answers = await this.accessControl.canWriteBatchAtRef(
2402
+ await baseWorkspace(base),
2403
+ `origin/${base}`,
2404
+ user.email,
2405
+ paths,
2406
+ );
2407
+ return answers !== null && paths.every((p) => answers.get(p) === true);
2408
+ };
2409
+
2410
+ return Promise.all(
2411
+ touching.map(async ({ cr, paths }): Promise<FolderChangeRequest> => {
2412
+ const mine = !!cr.authorId && cr.authorId === callerHash;
2413
+ const listed = {
2414
+ number: cr.number,
2415
+ title: cr.title,
2416
+ authorName: cr.appAuthor?.name ?? cr.author.name ?? null,
2417
+ mine,
2418
+ paths,
2419
+ };
2420
+ if (mine || (await adminOn(cr.base)) || (await writerOfAll(cr.base, paths))) {
2421
+ return { ...listed, mayRemove: true };
2422
+ }
2423
+ return {
2424
+ ...listed,
2425
+ mayRemove: false,
2426
+ reason: `#${cr.number} was proposed by ${cr.appAuthor?.name ?? 'someone else'}; only its author, an admin or someone who can write every file it proposes here can change it.`,
2427
+ };
2428
+ }),
2429
+ );
2430
+ }
2431
+
2432
+ /**
2433
+ * Take every file under `folder` out of every open change request that
2434
+ * proposes one — the request half of "Delete folder and its proposed
2435
+ * changes". All-or-nothing on permission: when the caller may not act on
2436
+ * even one of the requests, nothing is touched. A request left proposing
2437
+ * nothing is withdrawn (closed, its branch retired), exactly as declining
2438
+ * its last file would.
2439
+ */
2440
+ async removeFolderFromChangeRequests(
2441
+ folder: string,
2442
+ user: AuthUser,
2443
+ ): Promise<FolderChangeRequestRemoval[]> {
2444
+ const requests = await this.changeRequestsUnderFolder(folder, user);
2445
+ const refused = requests.filter((r) => !r.mayRemove);
2446
+ if (refused.length > 0) {
2447
+ throw new WorkflowDomainError(
2448
+ `You can't remove proposed changes from ${refused.map((r) => `#${r.number}`).join(', ')} — only the author, an admin or someone who can write every file it proposes here can.`,
2449
+ 403,
2450
+ );
2451
+ }
2452
+ const prefix = folderPrefix(folder);
2453
+
2454
+ // Everything that can refuse happens before the first commit, so a
2455
+ // refusal leaves every request as it was: each request's source checkout
2456
+ // brought up to date (a failed pull stops here — restoring into a stale
2457
+ // or conflicted tree could commit the wrong content), its paths read as
2458
+ // they are NOW rather than as the list saw them, its merge base found,
2459
+ // and every file's lock held.
2460
+ const plans: { number: number; branch: string; wsId: string; paths: string[]; mergeBase: string | null }[] = [];
2461
+ for (const request of requests) {
2462
+ const summary = await this.prs.getPr(request.number);
2463
+ if (!summary || summary.state !== 'open') continue;
2464
+ const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2465
+ try {
2466
+ await this.pullWorkspace(ws.id);
2467
+ } catch (err) {
2468
+ log.warn(
2469
+ `pull failed for #${summary.number} before removing folder ${printable(folder)}: ${printable(sanitizeError(err))}`,
2470
+ );
2471
+ throw new WorkflowDomainError(
2472
+ `#${summary.number} could not be brought up to date, so nothing was removed — try again in a moment.`,
2473
+ 409,
2474
+ );
2475
+ }
2476
+ const paths = (await this.git.changedPathsForPr(ws.id, summary.base, summary.branch)).filter((p) =>
2477
+ p.startsWith(prefix),
2478
+ );
2479
+ // The permission above was judged on the paths the request proposed
2480
+ // then. One proposed since was never judged — a writer of every earlier
2481
+ // file need not be one of it — so the request has to be asked about again.
2482
+ const unjudged = paths.filter((p) => !request.paths.includes(p));
2483
+ if (unjudged.length > 0) {
2484
+ throw new WorkflowDomainError(
2485
+ `#${summary.number} changed while the folder was being deleted (${unjudged.length === 1 ? unjudged[0] :`${unjudged.length} new files`}), so no proposed changes were removed — try again.`,
2486
+ 409,
2487
+ );
2488
+ }
2489
+ let mergeBase: string | null = null;
2490
+ if (paths.length > 0) {
2491
+ mergeBase = await this.git.mergeBaseForPr(ws.id, summary.base, summary.branch);
2492
+ if (!mergeBase) {
2493
+ throw new WorkflowDomainError(`#${summary.number} shares no history with its target to revert to.`, 422);
2494
+ }
2495
+ }
2496
+ plans.push({ number: summary.number, branch: summary.branch, wsId: ws.id, paths, mergeBase });
2497
+ }
2498
+
2499
+ // Requests can share a source branch, and so a workspace and even a file.
2500
+ // The work is grouped per workspace: each file is locked, restored and
2501
+ // committed once, and each branch pushed once. One restore can only undo a
2502
+ // shared file for requests that agree on what it reverts TO — requests on
2503
+ // one branch aimed at targets with different merge bases do not, and one
2504
+ // of them would be reported emptied while still proposing the file. That
2505
+ // combination is refused here, before anything is locked or changed.
2506
+ const byWorkspace = new Map<
2507
+ string,
2508
+ { wsId: string; branch: string; plans: typeof plans; paths: { path: string; mergeBase: string }[] }
2509
+ >();
2510
+ for (const plan of plans) {
2511
+ let group = byWorkspace.get(plan.wsId);
2512
+ if (!group) {
2513
+ group = { wsId: plan.wsId, branch: plan.branch, plans: [], paths: [] };
2514
+ byWorkspace.set(plan.wsId, group);
2515
+ }
2516
+ group.plans.push(plan);
2517
+ for (const repoRelPath of plan.paths) {
2518
+ const seen = group.paths.find((p) => p.path === repoRelPath);
2519
+ if (!seen) {
2520
+ group.paths.push({ path: repoRelPath, mergeBase: plan.mergeBase! });
2521
+ } else if (seen.mergeBase !== plan.mergeBase) {
2522
+ const sharing = group.plans.map((other) => `#${other.number}`).join(', ');
2523
+ throw new WorkflowDomainError(
2524
+ `${sharing} propose ${repoRelPath} from the same branch against different targets, so it can't be taken out of both at once. Decline it in each request instead.`,
2525
+ 422,
2526
+ );
2527
+ }
2528
+ }
2529
+ }
2530
+ const groups = [...byWorkspace.values()];
2531
+ const toChange = groups.filter((g) => g.paths.length > 0);
2532
+
2533
+ const held: { wsId: string; branch: string; lockPath: string }[] = [];
2534
+ // Every held lock is let go, each on its own: one failed release must not
2535
+ // keep the rest held until they expire.
2536
+ const releaseAll = async () => {
2537
+ for (const h of held.splice(0)) {
2538
+ try {
2539
+ await this.fileLocks.release(h.wsId, h.branch, h.lockPath, user);
2540
+ } catch (err) {
2541
+ log.warn(`lock release failed for ${printable(h.lockPath)}: ${printable(sanitizeError(err))}`);
2542
+ }
2543
+ }
2544
+ };
2545
+ const pushed: typeof toChange = [];
2546
+ let pushFailure: { group: (typeof toChange)[number]; err: unknown } | null = null;
2547
+ try {
2548
+ for (const group of toChange) {
2549
+ for (const { path: repoRelPath } of group.paths) {
2550
+ const lockPath = `${this.kbDirName}/${repoRelPath}`;
2551
+ const lock = await this.fileLocks.acquire(group.wsId, group.branch, lockPath, user);
2552
+ if (!lock.acquired) {
2553
+ throw new WorkflowDomainError(
2554
+ `${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`,
2555
+ 409,
2556
+ );
2557
+ }
2558
+ held.push({ wsId: group.wsId, branch: group.branch, lockPath });
2559
+ }
2560
+ }
2561
+ // The git half is ONE operation under one reservation of every checkout
2562
+ // involved: it records each HEAD, commits every revert before pushing
2563
+ // anything, pushes branch by branch, and undoes what didn't publish (see
2564
+ // `GitService.revertPathsAndPush`). A failed restore or commit throws
2565
+ // with every request left as it was. Pushes can't be made atomic, so a
2566
+ // failed push leaves the requests split into "done" and "untouched", and
2567
+ // running the action again finishes the rest.
2568
+ if (toChange.length > 0) {
2569
+ const outcome = await this.git.revertPathsAndPush(
2570
+ user,
2571
+ toChange.map((group) => {
2572
+ const numbers = group.plans.map((plan) => `#${plan.number}`).join(', ');
2573
+ return {
2574
+ workspaceId: group.wsId,
2575
+ paths: group.paths.map(({ path: repoRelPath, mergeBase }) => ({
2576
+ path: repoRelPath,
2577
+ ref: mergeBase,
2578
+ subject: commitSubject(
2579
+ `Revert ${repoRelPath} (folder deleted; removed from change request ${numbers})`,
2580
+ `Revert ${basenameOf(repoRelPath)} (folder deleted; removed from ${group.plans.length === 1 ? `change request ${numbers}` : `${group.plans.length} change requests`})`,
2581
+ ),
2582
+ undoSubject: commitSubject(
2583
+ `Undo revert of ${repoRelPath} (folder removal stopped)`,
2584
+ `Undo revert of ${basenameOf(repoRelPath)} (folder removal stopped)`,
2585
+ ),
2586
+ })),
2587
+ };
2588
+ }),
2589
+ );
2590
+ for (const group of toChange) {
2591
+ if (outcome.pushed.includes(group.wsId)) {
2592
+ this.noteGitSyncOk(group.wsId, group.branch);
2593
+ pushed.push(group);
2594
+ for (const plan of group.plans) this.prs.invalidateDetailCache(plan.number);
2595
+ } else if (outcome.failed?.workspaceId === group.wsId) {
2596
+ this.noteGitSyncFailed(group.wsId, group.branch, outcome.failed.error);
2597
+ pushFailure = { group, err: outcome.failed.error };
2598
+ }
2599
+ }
2600
+ }
2601
+ } finally {
2602
+ await releaseAll();
2603
+ }
2604
+
2605
+ const finished = (pushFailure ? pushed : groups).flatMap((group) => group.plans);
2606
+ if (pushFailure && finished.length === 0) throw pushFailure.err;
2607
+ const results: FolderChangeRequestRemoval[] = [];
2608
+ for (const plan of finished) {
2609
+ const withdrawn = await this.closeEmptyChangeRequest(plan.number, user);
2610
+ // A request still open is read once more, to say why: a file under the
2611
+ // folder proposed while this ran (never judged, so left alone), or —
2612
+ // proposing nothing at all — a save still landing kept it open.
2613
+ let stillProposed: string[] = [];
2614
+ let keptForSaves = false;
2615
+ if (!withdrawn) {
2616
+ try {
2617
+ const summary = await this.prs.getPr(plan.number);
2618
+ if (summary?.state === 'open') {
2619
+ const now = await this.git.changedPathsForPr(plan.wsId, summary.base, summary.branch);
2620
+ stillProposed = now.filter((p) => p.startsWith(prefix) && !isFolderPlaceholder(p));
2621
+ keptForSaves = now.length === 0;
2622
+ }
2623
+ } catch (err) {
2624
+ log.warn(
2625
+ `could not re-read #${plan.number} after removing folder ${printable(folder)}: ${printable(sanitizeError(err))}`,
2626
+ );
2627
+ }
2628
+ }
2629
+ results.push({ number: plan.number, removedPaths: plan.paths, withdrawn, stillProposed, keptForSaves });
2630
+ }
2631
+ if (pushFailure) {
2632
+ const failed = pushFailure.group.plans.map((plan) => `#${plan.number}`).join(', ');
2633
+ log.warn(
2634
+ `push failed for ${failed} removing folder ${printable(folder)}: ${printable(sanitizeError(pushFailure.err))}`,
2635
+ );
2636
+ throw new WorkflowDomainError(
2637
+ `The folder's files were removed from ${finished.map((plan) => `#${plan.number}`).join(', ')}, but ${failed} could not be updated and it and any later requests were left as they were. Run the delete again to finish.`,
2638
+ 500,
2639
+ );
2640
+ }
2641
+ return results;
2642
+ }
2643
+
1775
2644
  /**
1776
2645
  * DELETE a change request outright: close it (whatever its diff says) and
1777
2646
  * retire its source branch — the request, its proposal, and the branch that
@@ -1875,23 +2744,29 @@ export class WorkflowService implements IWorkflowService {
1875
2744
  * is AUTHORITATIVE — recomputed, and a diff failure aborts rather than
1876
2745
  * closes, so a transient git error can never eat a live request.
1877
2746
  *
2747
+ * An empty diff is not yet an empty request while a save to its branch is
2748
+ * still landing: one under a held lock or queued for the commit worker is
2749
+ * on disk but not in the diff. Retiring the branch deletes its checkout and
2750
+ * that save with it, so such a request stays open — the save commits and it
2751
+ * proposes something again, or a later look closes it.
2752
+ *
1878
2753
  * Returns true when THIS call closed it.
1879
2754
  */
1880
2755
  async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
1881
2756
  const summary = await this.prs.getPr(number);
1882
2757
  if (!summary || summary.state !== 'open') return false;
1883
2758
  let paths: string[];
2759
+ let wsId: string;
1884
2760
  try {
1885
2761
  const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
2762
+ wsId = ws.id;
1886
2763
  paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
1887
2764
  } catch (err) {
1888
- console.warn(
1889
- `[cr] empty-check for change request #${number} failed — leaving it open:`,
1890
- err,
1891
- );
2765
+ crLog.warn(`empty-check for change request #${number} failed — leaving it open:`, { err });
1892
2766
  return false;
1893
2767
  }
1894
2768
  if (paths.length > 0) return false;
2769
+ if (await this.savesInFlight(wsId)) return false;
1895
2770
 
1896
2771
  // Guard on `state = 'open'` so a concurrent merge or withdraw wins the
1897
2772
  // race and this becomes a no-op.
@@ -1908,6 +2783,21 @@ export class WorkflowService implements IWorkflowService {
1908
2783
  return true;
1909
2784
  }
1910
2785
 
2786
+ /**
2787
+ * Whether a save to this branch's checkout is still landing: a live file
2788
+ * lock (the write is mid-flight) or any queued commit, stuck ones included
2789
+ * (their file is still only on disk). An answer that cannot be read is a
2790
+ * yes — the caller is about to delete the checkout.
2791
+ */
2792
+ private async savesInFlight(wsId: string): Promise<boolean> {
2793
+ try {
2794
+ return (await this.fileLocks.hasAnyActive(wsId)) || (await this.pendingCommits.hasAnyForWorkspace(wsId));
2795
+ } catch (err) {
2796
+ crLog.warn(`could not tell whether saves are landing on ${printable(wsId)} — keeping its request open:`, { err });
2797
+ return true;
2798
+ }
2799
+ }
2800
+
1911
2801
  /**
1912
2802
  * Close every open change request either of whose branches no longer exists.
1913
2803
  *
@@ -1971,13 +2861,13 @@ export class WorkflowService implements IWorkflowService {
1971
2861
  });
1972
2862
  live = new Set(branches.map((b) => b.name));
1973
2863
  } catch (err) {
1974
- console.warn('[cr] branch sweep skipped — could not list branches:', err);
2864
+ crLog.warn('branch sweep skipped — could not list branches:', { err });
1975
2865
  return 0;
1976
2866
  }
1977
2867
  // An empty branch list means something is wrong with the clone, not that
1978
2868
  // every branch in the repo was deleted at once. Refuse to act on it.
1979
2869
  if (live.size === 0) {
1980
- console.warn('[cr] branch sweep skipped — branch list came back empty');
2870
+ crLog.warn('branch sweep skipped — branch list came back empty');
1981
2871
  return 0;
1982
2872
  }
1983
2873
 
@@ -2003,9 +2893,7 @@ export class WorkflowService implements IWorkflowService {
2003
2893
  if (updated.length === 0) continue;
2004
2894
  this.prs.invalidateDetailCache(cr.number);
2005
2895
  this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
2006
- console.log(
2007
- `[cr] closed change request #${cr.number}: branch "${missing}" no longer exists`,
2008
- );
2896
+ crLog.info(`closed change request #${cr.number}: branch "${missing}" no longer exists`);
2009
2897
  closed++;
2010
2898
  }
2011
2899
  return closed;
@@ -2031,12 +2919,10 @@ export class WorkflowService implements IWorkflowService {
2031
2919
  this.sweepKick = this.closeChangeRequestsWithDeletedBranches()
2032
2920
  .then((n) => {
2033
2921
  if (n > 0) {
2034
- console.log(
2035
- `[cr] on-demand sweep closed ${n} stranded change request${n === 1 ? '' : 's'}`,
2036
- );
2922
+ crLog.info(`on-demand sweep closed ${n} stranded change request${n === 1 ? '' : 's'}`);
2037
2923
  }
2038
2924
  })
2039
- .catch((err) => console.warn('[cr] on-demand deleted-branch sweep failed:', err))
2925
+ .catch((err) => crLog.warn('on-demand deleted-branch sweep failed:', { err }))
2040
2926
  .finally(() => {
2041
2927
  this.sweepKick = null;
2042
2928
  });
@@ -2161,7 +3047,7 @@ export class WorkflowService implements IWorkflowService {
2161
3047
  // roles.yaml differs from the base branch's, we restore the base version on
2162
3048
  // the source branch (commit + push) BEFORE merging, so the merged diff
2163
3049
  // carries no roles.yaml change. roles.yaml is mutable ONLY via the admin
2164
- // Roles & Members surface (itself admin-gated). The rest of the CR merges
3050
+ // App roles surface (itself admin-gated). The rest of the CR merges
2165
3051
  // normally. Best-effort by design is NOT acceptable here — a failure to
2166
3052
  // neutralise must abort the merge, never fall through.
2167
3053
  const preserved = await this.preserveBaseRolesYaml(number, user, baseBranch);
@@ -2211,17 +3097,31 @@ export class WorkflowService implements IWorkflowService {
2211
3097
  }
2212
3098
  throw err;
2213
3099
  }
2214
- // The merge landed on `origin/<baseBranch>`. Pull the
2215
- // TARGET branch's own workspace so its working tree doesn't fall behind the
2216
- // remote — the file tools serve the working tree (not origin), so without
2217
- // this a read right after a merge misses the just-merged change. Best-effort:
2218
- // the merge already succeeded on origin, so a pull hiccup must not fail the
2219
- // response (a later fetch/pull reconciles).
3100
+ await this.pullMergeTarget(baseBranch, user);
3101
+ this.prs.invalidateDetailCache(number);
3102
+ this.events?.emit({ kind: 'change-request-merged', number });
3103
+ // AFTER the event: the applying UI is waiting on `change-request-merged`,
3104
+ // and branch retirement is git IO it must never wait behind.
3105
+ await this.retireMergedSourceBranch(number, baseBranch, user);
3106
+ return { kind: 'merged', result };
3107
+ }
3108
+
3109
+ /**
3110
+ * The merge landed on `origin/<targetBranch>`. Pull the TARGET branch's own
3111
+ * workspace so its working tree doesn't fall behind the remote — the file
3112
+ * tools serve the working tree (not origin), so without this a read right
3113
+ * after a merge misses the just-merged change. Best-effort: the merge already
3114
+ * succeeded on origin, so a pull hiccup must not fail the response (a later
3115
+ * fetch/pull reconciles).
3116
+ */
3117
+ private async pullMergeTarget(targetBranch: string, user: AuthUser): Promise<void> {
2220
3118
  let targetWorkspaceId: string | undefined;
3119
+ /** Whether the pull below already announced the new tree. See the emit after it. */
3120
+ let announced = false;
2221
3121
  try {
2222
- const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
3122
+ const targetWorkspace = await this.workspaceService.getOrCreateForBranch(targetBranch);
2223
3123
  targetWorkspaceId = targetWorkspace.id;
2224
- await this.git.pull(targetWorkspace.id);
3124
+ announced = await this.pullWorkspace(targetWorkspace.id);
2225
3125
  } catch (err) {
2226
3126
  // Still best-effort for the merge response (the merge already landed
2227
3127
  // on origin) — but a rebase CONFLICT here means the target workspace
@@ -2232,17 +3132,289 @@ export class WorkflowService implements IWorkflowService {
2232
3132
  if (err instanceof PullRebaseConflictError && targetWorkspaceId) {
2233
3133
  await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
2234
3134
  }
2235
- console.warn(
2236
- `[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
3135
+ mergeLog.warn(`post-merge pull of target "${targetBranch}" failed — its workspace may be momentarily behind origin`, {
2237
3136
  err,
2238
- );
3137
+ });
2239
3138
  }
3139
+ // THE MERGE ITSELF CHANGED THE TREE, and the pull above almost never says
3140
+ // so. Both callers — applying a change request, and an agent merging a
3141
+ // branch — run `git merge --no-ff` in this very workspace and push the
3142
+ // result, so by the time we pull, the clone is already at origin: the pull
3143
+ // reports "up to date", `treeChanged` is false, and nothing is announced —
3144
+ // while the working tree holds a `.tool` or a `SKILL.md` that was not
3145
+ // there a second ago.
3146
+ //
3147
+ // Nothing else covers it. `change-request-merged` carries no branch and no
3148
+ // workspace, so the catalog subscriber cannot act on it; a later
3149
+ // `POST /api/sync/<branch>` answers "up-to-date" without pulling, for the
3150
+ // same reason. So an approved tool stayed invisible — to every open MCP
3151
+ // connection and every browser — until some unrelated write to the default
3152
+ // branch happened to drop the caches, or the catalogs' own TTL ran out.
3153
+ // That is the merge path of "whichever path made the commit".
3154
+ //
3155
+ // Emitted only when the pull did not already: on the rare path where the
3156
+ // clone WAS behind (someone else pushed between the merge and the pull),
3157
+ // the pull announced the same tree and a second event buys a duplicate
3158
+ // re-scan and a duplicate browser refetch.
3159
+ //
3160
+ // A pull that FAILED is still announced, and that is not a guess about a
3161
+ // tree nobody reconciled. The merge ran in THIS workspace — the target
3162
+ // branch's own, resolved above — before pushing, so the merged bytes were
3163
+ // on that disk before the pull was attempted, and a pull that could not
3164
+ // run cannot un-merge them. (For a change request, `targetBranch` is the
3165
+ // branch the merge acted on: `preserveBaseRolesYaml` refuses the merge
3166
+ // outright if it disagrees with the request's own base.) The failure the
3167
+ // pull reports is about OTHER people's commits not arriving, which leaves
3168
+ // the catalogs no staler than they were.
3169
+ if (!announced && targetWorkspaceId && targetBranch === DEFAULT_BRANCH) {
3170
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: targetWorkspaceId, branch: DEFAULT_BRANCH });
3171
+ }
3172
+ }
3173
+
3174
+ async mergeBranch(user: AuthUser, sourceBranch: string, targetBranch: string): Promise<MergeBranchOutcome> {
3175
+ if (sourceBranch === targetBranch) {
3176
+ throw new WorkflowValidationError(`\`source\` and \`target\` are both "${sourceBranch}" — name two different branches.`);
3177
+ }
3178
+
3179
+ // Under the lifecycle lock of BOTH branches, the same protocol
3180
+ // `openChangeRequest` follows — and for the same window. That method holds
3181
+ // these keys across its whole check-then-insert (an auto-merge and a push,
3182
+ // seconds of it), so the "no open request" answer below is only sound
3183
+ // while they are held: a bare read would let a person open the request
3184
+ // mid-merge and the agent would land its content anyway, which is the one
3185
+ // thing this tool exists to prevent. `runOnBranchPair` drops a protected
3186
+ // target's key; the source key is held either way, and every competing
3187
+ // request from this source takes it too.
3188
+ return this.runOnBranchPair(sourceBranch, targetBranch, async () => {
3189
+ // A person merges a change request. Only the request's OWN direction is
3190
+ // blocked: the target merged into the source is how a draft under review
3191
+ // stays current, and that must keep working while the request is open.
3192
+ const [open] = await this.db
3193
+ .select({ number: changeRequests.number })
3194
+ .from(changeRequests)
3195
+ .where(
3196
+ and(
3197
+ eq(changeRequests.sourceBranch, sourceBranch),
3198
+ eq(changeRequests.targetBranch, targetBranch),
3199
+ eq(changeRequests.state, 'open'),
3200
+ ),
3201
+ )
3202
+ .limit(1);
3203
+ if (open) throw new OpenChangeRequestBlocksMergeError(sourceBranch, targetBranch, open.number);
3204
+
3205
+ // The merge runs in the target's own workspace, like a change request's.
3206
+ const targetWorkspaceId = (await this.workspaceService.getOrCreateForBranch(targetBranch)).id;
3207
+ if (!(await this.git.remoteBranchExists(targetWorkspaceId, sourceBranch))) {
3208
+ throw new WorkflowValidationError(`No branch named "${sourceBranch}" on the shared remote.`);
3209
+ }
3210
+
3211
+ const result = await this.git.mergeChangeRequest(
3212
+ targetWorkspaceId,
3213
+ sourceBranch,
3214
+ targetBranch,
3215
+ {
3216
+ subject: `Merge ${sourceBranch} into ${targetBranch}`,
3217
+ body: `Merged via Bevel by ${user.name} <${user.email}>`,
3218
+ },
3219
+ user,
3220
+ {
3221
+ // The merge resets that workspace to the published target tip, which
3222
+ // would throw away edits still on their way out. Refuse until they
3223
+ // are shared — asked inside the merge's own reservation, so a save
3224
+ // cannot land between the question and the reset.
3225
+ requireCleanTarget: true,
3226
+ // Into a protected branch, a merge is a commit of every file it
3227
+ // changes: allowed only for a caller who could commit each of them
3228
+ // directly (the same at-HEAD rule the write lock applies). The hook
3229
+ // runs inside that reservation too, against the target tip this
3230
+ // merge is built on rather than a workspace HEAD that may be behind
3231
+ // it — so the rules read are the ones the merge is about to change.
3232
+ //
3233
+ // `changedPaths` is the authorizing set: roles.yaml kept (unlike a
3234
+ // change request's merge, nothing strips it here — so its presence
3235
+ // is what the hook refuses on) and both sides of a rename, since the
3236
+ // old name is a file this merge deletes.
3237
+ authorize: isProtectedBranch(targetBranch)
3238
+ ? async ({ sha, changedPaths }) => {
3239
+ if (changedPaths.length === 0) return;
3240
+ // roles.yaml never changes through a merge. A change request's
3241
+ // merge restores the target's copy on the source first
3242
+ // (`preserveBaseRolesYaml`): a draft is a free-for-all, and the
3243
+ // merge is what lands its content on a protected branch. This
3244
+ // path lands draft content the same way, so it refuses rather
3245
+ // than authorizing — even an admin, who could write the file
3246
+ // directly, gets the app's roles surface for that, where the
3247
+ // file is validated (parsable, groups that exist); the agent's
3248
+ // own write tools refuse it under the same guard. A merged
3249
+ // roles.yaml would bypass both, on the one file the access
3250
+ // model cannot survive being wrong.
3251
+ if (changedPaths.includes('roles.yaml')) {
3252
+ throw new WorkflowDomainError(
3253
+ `"${targetBranch}" is protected and this merge would change its roles.yaml. ` +
3254
+ `Roles never change through a merge: restore roles.yaml on "${sourceBranch}" to the version on ` +
3255
+ `"${targetBranch}" and merge again, and ask the user to change roles in the app.`,
3256
+ 403,
3257
+ { kind: 'protected-merge-changes-roles', targetBranch, sourceBranch },
3258
+ );
3259
+ }
3260
+ const allowed = await this.accessControl.canWriteBatchAtRef(
3261
+ targetWorkspaceId,
3262
+ sha,
3263
+ user.email,
3264
+ changedPaths,
3265
+ );
3266
+ const denied = allowed ? changedPaths.filter((p) => !allowed.get(p)) : [];
3267
+ if (denied.length === 0) return;
3268
+ throw new WorkflowDomainError(
3269
+ `"${targetBranch}" is protected and you cannot commit directly to it: ` +
3270
+ `${denied.length} changed file(s) are outside your write access (${denied.join(', ')}). ` +
3271
+ `Open a change request from "${sourceBranch}" into "${targetBranch}" and ask the user to review it in the app.`,
3272
+ 403,
3273
+ { kind: 'protected-merge-target', targetBranch, deniedPaths: denied },
3274
+ );
3275
+ }
3276
+ : undefined,
3277
+ },
3278
+ );
3279
+ if (result.kind === 'conflicts') {
3280
+ return { kind: 'conflicts-need-resolution', conflictedPaths: result.paths };
3281
+ }
3282
+ await this.pullMergeTarget(targetBranch, user);
3283
+ return { kind: 'merged', sha: result.sha };
3284
+ });
3285
+ }
3286
+
3287
+ /**
3288
+ * Start an apply attempt on `number` and return its token. Only the latest
3289
+ * attempt may record a refusal: when two people apply the same request at
3290
+ * once, an older attempt that finishes last must not overwrite the newer
3291
+ * one's verdict. In-process, like the detail and list caches this reads with.
3292
+ * Every attempt must be ended with `endApplyAttempt`.
3293
+ */
3294
+ beginApplyAttempt(number: number): number {
3295
+ const attempt = ++this.applyAttemptSeq;
3296
+ this.applyAttempts.set(number, attempt);
3297
+ return attempt;
3298
+ }
3299
+
3300
+ /**
3301
+ * An attempt finished. Its entry leaves only if no newer attempt replaced it;
3302
+ * an older attempt ending later then finds no entry and records nothing,
3303
+ * because its token can never match one issued again.
3304
+ */
3305
+ endApplyAttempt(number: number, attempt: number): void {
3306
+ if (this.applyAttempts.get(number) === attempt) this.applyAttempts.delete(number);
3307
+ }
3308
+
3309
+ /**
3310
+ * Persist why an apply did not land and tell every session. The apply route
3311
+ * already answers the clicker directly (user-scoped `merge-failed`); this is
3312
+ * for everyone else who can see the still-open request — above all its
3313
+ * author, who otherwise sees it pending forever with no word of the refusal.
3314
+ * The event carries only the number: the reason is read back through the
3315
+ * list and detail endpoints, so a session that misses the event gets the
3316
+ * same answer on its next fetch.
3317
+ *
3318
+ * Returns false, writing and announcing nothing, when a newer attempt has
3319
+ * started since `attempt` or the request is no longer open (a concurrent
3320
+ * apply landed it) — a refusal nobody can act on must not reach anyone.
3321
+ */
3322
+ async recordApplyFailure(
3323
+ number: number,
3324
+ failure: { reason: string; kind: ChangeRequestApplyFailureKind; at?: Date },
3325
+ user: AuthUser,
3326
+ attempt: number,
3327
+ ): Promise<boolean> {
3328
+ if (this.applyAttempts.get(number) !== attempt) return false;
3329
+ const at = failure.at ?? new Date();
3330
+ const updated = await this.db
3331
+ .update(changeRequests)
3332
+ .set({
3333
+ applyFailureReason: sanitizeError(failure.reason, { maxLen: APPLY_FAILURE_MAX_LEN }),
3334
+ applyFailureConflicts: failure.kind === 'conflicts',
3335
+ applyFailureKind: failure.kind,
3336
+ applyFailedAt: at,
3337
+ applyFailedByName: user.name,
3338
+ })
3339
+ // The write itself refuses to go backwards: the attempt map above is an
3340
+ // early exit within this process, but an older UPDATE still in flight (or
3341
+ // one from another replica) must never replace a newer refusal.
3342
+ .where(
3343
+ and(
3344
+ eq(changeRequests.number, number),
3345
+ eq(changeRequests.state, 'open'),
3346
+ or(isNull(changeRequests.applyFailedAt), lt(changeRequests.applyFailedAt, at)),
3347
+ ),
3348
+ )
3349
+ .returning({ number: changeRequests.number });
3350
+ if (updated.length === 0) return false;
2240
3351
  this.prs.invalidateDetailCache(number);
2241
- this.events?.emit({ kind: 'change-request-merged', number });
2242
- // AFTER the event: the applying UI is waiting on `change-request-merged`,
2243
- // and branch retirement is git IO it must never wait behind.
2244
- await this.retireMergedSourceBranch(number, baseBranch, user);
2245
- return { kind: 'merged', result };
3352
+ this.events?.emit({ kind: 'change-request-apply-failed', number });
3353
+ return true;
3354
+ }
3355
+
3356
+ /**
3357
+ * Forget a request's recorded refusal because a change made it obsolete.
3358
+ * Otherwise the request keeps saying "<name> could not apply this: Waiting on
3359
+ * approval…" after the approval arrived, with nothing dating it.
3360
+ *
3361
+ * WHICH refusals a change makes obsolete is `kinds`: an approval answers only
3362
+ * the gate — a conflict or a git error is untouched by it and must keep
3363
+ * saying so — while a moved source head replaces the revision every kind of
3364
+ * refusal described (omit `kinds` to clear any). A row with no recorded kind
3365
+ * is only cleared by the latter. `recordedBefore` is taken before the change
3366
+ * starts: a refusal recorded after it is newer than the change and stays. Announced on the same event a new refusal
3367
+ * uses — every list and open dialog re-reads the request. Best-effort: the
3368
+ * mutation that triggered it already happened and must not fail on this.
3369
+ */
3370
+ /**
3371
+ * Whether the merge gate would now let an apply through without bypass — no
3372
+ * hard reason, no warning. Part of the best-effort clear: an evaluation that
3373
+ * fails answers "no" (the refusal stays) instead of failing the approval.
3374
+ */
3375
+ private gateWouldPass(number: number, approvals: FileApproval[]): boolean {
3376
+ try {
3377
+ const gate = this.reviewWorkflow.evaluateMergeGate({ prNumber: number, state: 'open', approvals });
3378
+ return gate?.mergeable === true && gate.warnings.length === 0;
3379
+ } catch (err) {
3380
+ crLog.warn(`could not re-evaluate the merge gate of change request #${number}; keeping its refusal:`, { err });
3381
+ return false;
3382
+ }
3383
+ }
3384
+
3385
+ private async clearApplyFailure(
3386
+ number: number,
3387
+ scope: { kinds?: readonly ChangeRequestApplyFailureKind[]; recordedBefore: Date },
3388
+ ): Promise<void> {
3389
+ const { kinds, recordedBefore } = scope;
3390
+ try {
3391
+ const cleared = await this.db
3392
+ .update(changeRequests)
3393
+ .set({
3394
+ applyFailureReason: null,
3395
+ applyFailureConflicts: null,
3396
+ applyFailedAt: null,
3397
+ applyFailedByName: null,
3398
+ applyFailureKind: null,
3399
+ })
3400
+ .where(
3401
+ and(
3402
+ eq(changeRequests.number, number),
3403
+ isNotNull(changeRequests.applyFailedAt),
3404
+ // Only a refusal that PREDATES the change: one an apply recorded
3405
+ // while the change was landing may already have seen it, and is
3406
+ // the newer verdict — never erase it.
3407
+ lt(changeRequests.applyFailedAt, recordedBefore),
3408
+ kinds ? inArray(changeRequests.applyFailureKind, [...kinds]) : undefined,
3409
+ ),
3410
+ )
3411
+ .returning({ number: changeRequests.number });
3412
+ if (cleared.length === 0) return;
3413
+ this.prs.invalidateDetailCache(number);
3414
+ this.events?.emit({ kind: 'change-request-apply-failed', number });
3415
+ } catch (err) {
3416
+ crLog.warn(`could not clear the recorded apply failure of change request #${number}:`, { err });
3417
+ }
2246
3418
  }
2247
3419
 
2248
3420
  /**
@@ -2293,13 +3465,12 @@ export class WorkflowService implements IWorkflowService {
2293
3465
  // "does origin still have it?" probe skips the remote delete.
2294
3466
  await this.workspaceService.ensureRemotesFetched(targetWs.id).catch(() => {});
2295
3467
  await this.deleteBranchUnlocked(targetWs.id, sourceBranch, user, { systemCleanup: true });
2296
- console.log(`[merge] retired merged source branch "${sourceBranch}"`);
3468
+ mergeLog.info(`retired merged source branch "${sourceBranch}"`);
2297
3469
  });
2298
3470
  } catch (err) {
2299
- console.warn(
2300
- `[merge] could not retire the merged source branch of change request #${number} (non-fatal):`,
3471
+ mergeLog.warn(`could not retire the merged source branch of change request #${number} (non-fatal):`, {
2301
3472
  err,
2302
- );
3473
+ });
2303
3474
  }
2304
3475
  }
2305
3476
 
@@ -2423,7 +3594,7 @@ export class WorkflowService implements IWorkflowService {
2423
3594
  const detail = err instanceof Error ? err.message : String(err);
2424
3595
  // Log the raw git/push detail server-side; the thrown error keeps it OFF
2425
3596
  // the client-facing 502 message (see RolesYamlPreservationError).
2426
- console.warn(`[merge] roles.yaml preservation failed for #${number}:`, detail);
3597
+ mergeLog.warn(`roles.yaml preservation failed for #${number}:`, { detail });
2427
3598
  throw new RolesYamlPreservationError(detail);
2428
3599
  }
2429
3600
  }
@@ -2448,10 +3619,9 @@ export class WorkflowService implements IWorkflowService {
2448
3619
  );
2449
3620
  return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
2450
3621
  } catch (err) {
2451
- console.warn(
2452
- '[workflow] listChangedPathsBetweenBranches failed:',
2453
- redactTokens(err instanceof Error ? err.message : String(err)),
2454
- );
3622
+ log.warn('listChangedPathsBetweenBranches failed:', {
3623
+ detail: redactTokens(err instanceof Error ? err.message : String(err)),
3624
+ });
2455
3625
  return [];
2456
3626
  }
2457
3627
  }
@@ -2483,10 +3653,7 @@ export class WorkflowService implements IWorkflowService {
2483
3653
  paths,
2484
3654
  );
2485
3655
  } catch (err) {
2486
- console.warn(
2487
- '[workflow] eligibleWritersForPathsAtRef failed:',
2488
- err instanceof Error ? err.message : String(err),
2489
- );
3656
+ log.warn('eligibleWritersForPathsAtRef failed:', { err });
2490
3657
  }
2491
3658
  if (!resolved || resolved.size === 0) return '';
2492
3659