@bevel-software/platform-core-backend 0.15.1 → 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 (1220) hide show
  1. package/THIRD-PARTY-NOTICES.md +4 -4
  2. package/dist/core/catalog-cache-invalidation.d.ts +16 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -1
  4. package/dist/core/catalog-cache-invalidation.js +6 -1
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -1
  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 +199 -75
  13. package/dist/core/create-core-server.js.map +1 -1
  14. package/dist/core/create-core-services.d.ts +49 -2
  15. package/dist/core/create-core-services.d.ts.map +1 -1
  16. package/dist/core/create-core-services.js +212 -86
  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 +215 -11
  39. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  40. package/dist/modules/access/access-control.service.d.ts +57 -11
  41. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  42. package/dist/modules/access/access-control.service.js +616 -155
  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 +255 -79
  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 +49 -24
  98. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  99. package/dist/modules/access-model/access-grammar.js +68 -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/plugin-principals.d.ts +5 -3
  120. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -1
  121. package/dist/modules/access-model/plugin-principals.js +11 -17
  122. package/dist/modules/access-model/plugin-principals.js.map +1 -1
  123. package/dist/modules/access-model/roles-yaml-guard.d.ts +73 -2
  124. package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -1
  125. package/dist/modules/access-model/roles-yaml-guard.js +153 -3
  126. package/dist/modules/access-model/roles-yaml-guard.js.map +1 -1
  127. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  128. package/dist/modules/admin/admin-access.service.js +5 -2
  129. package/dist/modules/admin/admin-access.service.js.map +1 -1
  130. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +21 -0
  131. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -0
  132. package/dist/modules/agent-instructions/agent-instructions.routes.js +38 -0
  133. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -0
  134. package/dist/modules/agent-instructions/compose.d.ts +71 -0
  135. package/dist/modules/agent-instructions/compose.d.ts.map +1 -0
  136. package/dist/modules/agent-instructions/compose.js +234 -0
  137. package/dist/modules/agent-instructions/compose.js.map +1 -0
  138. package/dist/modules/agent-instructions/index.d.ts +4 -0
  139. package/dist/modules/agent-instructions/index.d.ts.map +1 -0
  140. package/dist/modules/agent-instructions/index.js +4 -0
  141. package/dist/modules/agent-instructions/index.js.map +1 -0
  142. package/dist/modules/agent-instructions/read-preamble.d.ts +37 -0
  143. package/dist/modules/agent-instructions/read-preamble.d.ts.map +1 -0
  144. package/dist/modules/agent-instructions/read-preamble.js +81 -0
  145. package/dist/modules/agent-instructions/read-preamble.js.map +1 -0
  146. package/dist/modules/auth/account-erasure.service.d.ts +30 -4
  147. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  148. package/dist/modules/auth/account-erasure.service.js +96 -10
  149. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  150. package/dist/modules/auth/account.routes.d.ts +6 -3
  151. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  152. package/dist/modules/auth/account.routes.js +109 -10
  153. package/dist/modules/auth/account.routes.js.map +1 -1
  154. package/dist/modules/auth/auth.routes.d.ts +10 -3
  155. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  156. package/dist/modules/auth/auth.routes.js +9 -4
  157. package/dist/modules/auth/auth.routes.js.map +1 -1
  158. package/dist/modules/auth/auth.service.d.ts +89 -5
  159. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  160. package/dist/modules/auth/auth.service.js +160 -25
  161. package/dist/modules/auth/auth.service.js.map +1 -1
  162. package/dist/modules/auth/oidc-auth-provider.d.ts +46 -3
  163. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  164. package/dist/modules/auth/oidc-auth-provider.js +99 -21
  165. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  166. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  167. package/dist/modules/code-mode/code-mode.tool.js +14 -2
  168. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  169. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  170. package/dist/modules/connection-probe/connection-probe.service.js +56 -7
  171. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  172. package/dist/modules/database/advisory-lock.d.ts +103 -0
  173. package/dist/modules/database/advisory-lock.d.ts.map +1 -0
  174. package/dist/modules/database/advisory-lock.js +320 -0
  175. package/dist/modules/database/advisory-lock.js.map +1 -0
  176. package/dist/modules/database/connection.d.ts.map +1 -1
  177. package/dist/modules/database/connection.js +9 -1
  178. package/dist/modules/database/connection.js.map +1 -1
  179. package/dist/modules/database/core-schema.d.ts +346 -0
  180. package/dist/modules/database/core-schema.d.ts.map +1 -1
  181. package/dist/modules/database/core-schema.js +119 -0
  182. package/dist/modules/database/core-schema.js.map +1 -1
  183. package/dist/modules/database/migrate.d.ts.map +1 -1
  184. package/dist/modules/database/migrate.js +26 -6
  185. package/dist/modules/database/migrate.js.map +1 -1
  186. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
  187. package/dist/modules/declared-variables/declared-variables.routes.js +4 -2
  188. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
  189. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  190. package/dist/modules/diff/diff.routes.js +2 -4
  191. package/dist/modules/diff/diff.routes.js.map +1 -1
  192. package/dist/modules/diff/diff.service.d.ts +4 -1
  193. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  194. package/dist/modules/diff/diff.service.js +79 -81
  195. package/dist/modules/diff/diff.service.js.map +1 -1
  196. package/dist/modules/kb-fs/bevel-ignore.d.ts +35 -0
  197. package/dist/modules/kb-fs/bevel-ignore.d.ts.map +1 -0
  198. package/dist/modules/kb-fs/bevel-ignore.js +71 -0
  199. package/dist/modules/kb-fs/bevel-ignore.js.map +1 -0
  200. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  201. package/dist/modules/kb-fs/file-change-notifier.js +3 -1
  202. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  203. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +37 -0
  204. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -0
  205. package/dist/modules/kb-fs/git-guarded-filesystem.js +104 -0
  206. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -0
  207. package/dist/modules/kb-fs/locking-filesystem.d.ts +113 -18
  208. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  209. package/dist/modules/kb-fs/locking-filesystem.js +353 -76
  210. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  211. package/dist/modules/kb-fs/node-fs.d.ts +23 -0
  212. package/dist/modules/kb-fs/node-fs.d.ts.map +1 -0
  213. package/dist/modules/kb-fs/node-fs.js +158 -0
  214. package/dist/modules/kb-fs/node-fs.js.map +1 -0
  215. package/dist/modules/kb-fs/read-only-filesystem.d.ts +10 -9
  216. package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -1
  217. package/dist/modules/kb-fs/read-only-filesystem.js +18 -9
  218. package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -1
  219. package/dist/modules/kb-fs/repo-path.d.ts +100 -3
  220. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  221. package/dist/modules/kb-fs/repo-path.js +195 -3
  222. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  223. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  224. package/dist/modules/kb-sync/kb-sync.routes.js +4 -2
  225. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  226. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -1
  227. package/dist/modules/kb-sync/kb-sync.service.js +7 -5
  228. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -1
  229. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -1
  230. package/dist/modules/marketplace/git-http.routes.js +6 -4
  231. package/dist/modules/marketplace/git-http.routes.js.map +1 -1
  232. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +12 -2
  233. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -1
  234. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +32 -3
  235. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -1
  236. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +27 -0
  237. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -1
  238. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +45 -0
  239. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -1
  240. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -1
  241. package/dist/modules/marketplace/github-facade/github-facade.routes.js +30 -6
  242. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -1
  243. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +21 -9
  244. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -1
  245. package/dist/modules/marketplace/github-facade/github-facade.service.js +60 -19
  246. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -1
  247. package/dist/modules/marketplace/github-facade/index.d.ts +1 -1
  248. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -1
  249. package/dist/modules/marketplace/github-facade/index.js +1 -1
  250. package/dist/modules/marketplace/github-facade/index.js.map +1 -1
  251. package/dist/modules/marketplace/marketplace-repo.service.d.ts +14 -1
  252. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -1
  253. package/dist/modules/marketplace/marketplace-repo.service.js +26 -8
  254. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -1
  255. package/dist/modules/mcp/downstream-pool.d.ts +107 -0
  256. package/dist/modules/mcp/downstream-pool.d.ts.map +1 -0
  257. package/dist/modules/mcp/downstream-pool.js +221 -0
  258. package/dist/modules/mcp/downstream-pool.js.map +1 -0
  259. package/dist/modules/mcp/downstream-token-refresh.d.ts +77 -0
  260. package/dist/modules/mcp/downstream-token-refresh.d.ts.map +1 -0
  261. package/dist/modules/mcp/downstream-token-refresh.js +166 -0
  262. package/dist/modules/mcp/downstream-token-refresh.js.map +1 -0
  263. package/dist/modules/mcp/first-call-probe.cli-args.d.ts +78 -0
  264. package/dist/modules/mcp/first-call-probe.cli-args.d.ts.map +1 -0
  265. package/dist/modules/mcp/first-call-probe.cli-args.js +151 -0
  266. package/dist/modules/mcp/first-call-probe.cli-args.js.map +1 -0
  267. package/dist/modules/mcp/first-call-probe.cli.d.ts +2 -0
  268. package/dist/modules/mcp/first-call-probe.cli.d.ts.map +1 -0
  269. package/dist/modules/mcp/first-call-probe.cli.js +62 -0
  270. package/dist/modules/mcp/first-call-probe.cli.js.map +1 -0
  271. package/dist/modules/mcp/first-call-probe.d.ts +165 -0
  272. package/dist/modules/mcp/first-call-probe.d.ts.map +1 -0
  273. package/dist/modules/mcp/first-call-probe.js +298 -0
  274. package/dist/modules/mcp/first-call-probe.js.map +1 -0
  275. package/dist/modules/mcp/manual-failure-memo.d.ts +42 -17
  276. package/dist/modules/mcp/manual-failure-memo.d.ts.map +1 -1
  277. package/dist/modules/mcp/manual-failure-memo.js +79 -23
  278. package/dist/modules/mcp/manual-failure-memo.js.map +1 -1
  279. package/dist/modules/mcp/mcp-auth.middleware.d.ts +9 -5
  280. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  281. package/dist/modules/mcp/mcp-auth.middleware.js +24 -16
  282. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  283. package/dist/modules/mcp/mcp.routes.d.ts +4 -4
  284. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  285. package/dist/modules/mcp/mcp.routes.js +83 -179
  286. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  287. package/dist/modules/mcp/mcp.service.d.ts +218 -61
  288. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  289. package/dist/modules/mcp/mcp.service.js +713 -159
  290. package/dist/modules/mcp/mcp.service.js.map +1 -1
  291. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  292. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +4 -2
  293. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  294. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  295. package/dist/modules/mcp/oauth/oauth-consent.routes.js +3 -1
  296. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  297. package/dist/modules/mcp/surface-log-throttle.d.ts +59 -0
  298. package/dist/modules/mcp/surface-log-throttle.d.ts.map +1 -0
  299. package/dist/modules/mcp/surface-log-throttle.js +94 -0
  300. package/dist/modules/mcp/surface-log-throttle.js.map +1 -0
  301. package/dist/modules/plugins/compile/compile-marketplace.d.ts +13 -6
  302. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -1
  303. package/dist/modules/plugins/compile/compile-marketplace.js +119 -36
  304. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -1
  305. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +8 -1
  306. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -1
  307. package/dist/modules/plugins/compile/marketplace-compiler.service.js +9 -6
  308. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -1
  309. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -1
  310. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +32 -9
  311. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -1
  312. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +26 -6
  313. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -1
  314. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +71 -14
  315. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -1
  316. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +4 -2
  317. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -1
  318. package/dist/modules/plugins/discovery/kb-plugin-source.js +9 -6
  319. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -1
  320. package/dist/modules/plugins/discovery/native.source.d.ts +2 -1
  321. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -1
  322. package/dist/modules/plugins/discovery/native.source.js +22 -4
  323. package/dist/modules/plugins/discovery/native.source.js.map +1 -1
  324. package/dist/modules/plugins/discovery/plugin-source.d.ts +17 -3
  325. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -1
  326. package/dist/modules/plugins/index.d.ts +6 -3
  327. package/dist/modules/plugins/index.d.ts.map +1 -1
  328. package/dist/modules/plugins/index.js +6 -3
  329. package/dist/modules/plugins/index.js.map +1 -1
  330. package/dist/modules/plugins/join-request-jobs.service.d.ts +250 -0
  331. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -0
  332. package/dist/modules/plugins/join-request-jobs.service.js +602 -0
  333. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -0
  334. package/dist/modules/plugins/join-request-records.store.d.ts +161 -0
  335. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -0
  336. package/dist/modules/plugins/join-request-records.store.js +179 -0
  337. package/dist/modules/plugins/join-request-records.store.js.map +1 -0
  338. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  339. package/dist/modules/plugins/join-requests.service.js +4 -2
  340. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  341. package/dist/modules/plugins/plugin-links.d.ts +4 -1
  342. package/dist/modules/plugins/plugin-links.d.ts.map +1 -1
  343. package/dist/modules/plugins/plugin-links.js +6 -3
  344. package/dist/modules/plugins/plugin-links.js.map +1 -1
  345. package/dist/modules/plugins/plugin-links.service.d.ts +2 -5
  346. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -1
  347. package/dist/modules/plugins/plugin-links.service.js +4 -13
  348. package/dist/modules/plugins/plugin-links.service.js.map +1 -1
  349. package/dist/modules/plugins/plugin-provision.service.d.ts +91 -19
  350. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  351. package/dist/modules/plugins/plugin-provision.service.js +183 -38
  352. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  353. package/dist/modules/plugins/plugin-rename.service.d.ts +3 -1
  354. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -1
  355. package/dist/modules/plugins/plugin-rename.service.js +20 -11
  356. package/dist/modules/plugins/plugin-rename.service.js.map +1 -1
  357. package/dist/modules/plugins/plugins.contract.d.ts +52 -4
  358. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  359. package/dist/modules/plugins/plugins.routes.d.ts +38 -5
  360. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  361. package/dist/modules/plugins/plugins.routes.js +190 -116
  362. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  363. package/dist/modules/plugins/plugins.service.d.ts +38 -6
  364. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  365. package/dist/modules/plugins/plugins.service.js +86 -17
  366. package/dist/modules/plugins/plugins.service.js.map +1 -1
  367. package/dist/modules/plugins/plugins.tools.d.ts +25 -0
  368. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -0
  369. package/dist/modules/plugins/plugins.tools.js +82 -0
  370. package/dist/modules/plugins/plugins.tools.js.map +1 -0
  371. package/dist/modules/plugins/teams.routes.d.ts +42 -0
  372. package/dist/modules/plugins/teams.routes.d.ts.map +1 -0
  373. package/dist/modules/plugins/teams.routes.js +79 -0
  374. package/dist/modules/plugins/teams.routes.js.map +1 -0
  375. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +40 -1
  376. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  377. package/dist/modules/secrets-vault/db-secrets-vault.service.js +202 -37
  378. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  379. package/dist/modules/secrets-vault/index.d.ts +1 -1
  380. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  381. package/dist/modules/secrets-vault/index.js.map +1 -1
  382. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  383. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +5 -3
  384. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  385. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -1
  386. package/dist/modules/secrets-vault/secrets-variable-loader.js +3 -1
  387. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -1
  388. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +78 -0
  389. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  390. package/dist/modules/secrets-vault/secrets-vault.contract.js +40 -0
  391. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  392. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  393. package/dist/modules/secrets-vault/secrets-vault.routes.js +106 -52
  394. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  395. package/dist/modules/settings/connection-check.d.ts +118 -0
  396. package/dist/modules/settings/connection-check.d.ts.map +1 -0
  397. package/dist/modules/settings/connection-check.js +287 -0
  398. package/dist/modules/settings/connection-check.js.map +1 -0
  399. package/dist/modules/settings/deployment-settings.service.d.ts +153 -7
  400. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  401. package/dist/modules/settings/deployment-settings.service.js +348 -51
  402. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  403. package/dist/modules/settings/git-root-folders.d.ts +54 -0
  404. package/dist/modules/settings/git-root-folders.d.ts.map +1 -0
  405. package/dist/modules/settings/git-root-folders.js +130 -0
  406. package/dist/modules/settings/git-root-folders.js.map +1 -0
  407. package/dist/modules/settings/oidc-check.d.ts +85 -0
  408. package/dist/modules/settings/oidc-check.d.ts.map +1 -0
  409. package/dist/modules/settings/oidc-check.js +172 -0
  410. package/dist/modules/settings/oidc-check.js.map +1 -0
  411. package/dist/modules/settings/setup.routes.d.ts +31 -1
  412. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  413. package/dist/modules/settings/setup.routes.js +400 -108
  414. package/dist/modules/settings/setup.routes.js.map +1 -1
  415. package/dist/modules/skills/allowed-tools-check.d.ts +132 -0
  416. package/dist/modules/skills/allowed-tools-check.d.ts.map +1 -0
  417. package/dist/modules/skills/allowed-tools-check.js +260 -0
  418. package/dist/modules/skills/allowed-tools-check.js.map +1 -0
  419. package/dist/modules/skills/index.d.ts +3 -1
  420. package/dist/modules/skills/index.d.ts.map +1 -1
  421. package/dist/modules/skills/index.js +1 -0
  422. package/dist/modules/skills/index.js.map +1 -1
  423. package/dist/modules/skills/pending-skills.service.d.ts +6 -11
  424. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  425. package/dist/modules/skills/pending-skills.service.js +18 -70
  426. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  427. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  428. package/dist/modules/skills/skill-access-requests.routes.js +9 -6
  429. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  430. package/dist/modules/skills/skills.contract.d.ts +44 -10
  431. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  432. package/dist/modules/skills/skills.routes.d.ts +8 -1
  433. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  434. package/dist/modules/skills/skills.routes.js +16 -2
  435. package/dist/modules/skills/skills.routes.js.map +1 -1
  436. package/dist/modules/skills/skills.service.d.ts +31 -7
  437. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  438. package/dist/modules/skills/skills.service.js +191 -81
  439. package/dist/modules/skills/skills.service.js.map +1 -1
  440. package/dist/modules/skills/skills.tools.d.ts +4 -1
  441. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  442. package/dist/modules/skills/skills.tools.js +41 -6
  443. package/dist/modules/skills/skills.tools.js.map +1 -1
  444. package/dist/modules/tool-auth/connection-key-rejection.d.ts +18 -0
  445. package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -0
  446. package/dist/modules/tool-auth/connection-key-rejection.js +20 -0
  447. package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -0
  448. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts +14 -0
  449. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts.map +1 -0
  450. package/dist/modules/tool-auth/connection-keys-admin.routes.js +67 -0
  451. package/dist/modules/tool-auth/connection-keys-admin.routes.js.map +1 -0
  452. package/dist/modules/tool-auth/external-api-key.interface.d.ts +51 -3
  453. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  454. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  455. package/dist/modules/tool-auth/external-api-key.service.d.ts +16 -6
  456. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  457. package/dist/modules/tool-auth/external-api-key.service.js +61 -18
  458. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  459. package/dist/modules/tool-auth/internal-token.service.d.ts +5 -2
  460. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  461. package/dist/modules/tool-auth/internal-token.service.js +5 -2
  462. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  463. package/dist/modules/tool-auth/key-or-session.middleware.d.ts +23 -0
  464. package/dist/modules/tool-auth/key-or-session.middleware.d.ts.map +1 -0
  465. package/dist/modules/tool-auth/key-or-session.middleware.js +39 -0
  466. package/dist/modules/tool-auth/key-or-session.middleware.js.map +1 -0
  467. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +7 -1
  468. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  469. package/dist/modules/tool-auth/tool-auth.middleware.js +8 -5
  470. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  471. package/dist/modules/tool-helpers/tool-context.d.ts +14 -0
  472. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  473. package/dist/modules/tool-helpers/tool-context.js +28 -6
  474. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  475. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  476. package/dist/modules/tool-helpers/tool-handler.js +22 -5
  477. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  478. package/dist/modules/tool-helpers/tool.contract.d.ts +6 -1
  479. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  480. package/dist/modules/tool-helpers/tool.contract.js +7 -1
  481. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  482. package/dist/modules/tool-manuals/index.d.ts +2 -1
  483. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  484. package/dist/modules/tool-manuals/index.js +1 -0
  485. package/dist/modules/tool-manuals/index.js.map +1 -1
  486. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  487. package/dist/modules/tool-manuals/mcp-json-discovery.js +8 -6
  488. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  489. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +10 -2
  490. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  491. package/dist/modules/tool-manuals/mcp-server-edit.service.js +12 -15
  492. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  493. package/dist/modules/tool-manuals/pending-tools.service.d.ts +50 -0
  494. package/dist/modules/tool-manuals/pending-tools.service.d.ts.map +1 -0
  495. package/dist/modules/tool-manuals/pending-tools.service.js +149 -0
  496. package/dist/modules/tool-manuals/pending-tools.service.js.map +1 -0
  497. package/dist/modules/tool-manuals/tool-delete.service.d.ts +135 -0
  498. package/dist/modules/tool-manuals/tool-delete.service.d.ts.map +1 -0
  499. package/dist/modules/tool-manuals/tool-delete.service.js +303 -0
  500. package/dist/modules/tool-manuals/tool-delete.service.js.map +1 -0
  501. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +161 -0
  502. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  503. package/dist/modules/tool-manuals/tool-manuals.contract.js +9 -0
  504. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  505. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +19 -2
  506. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  507. package/dist/modules/tool-manuals/tool-manuals.routes.js +127 -57
  508. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  509. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +119 -5
  510. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  511. package/dist/modules/tool-manuals/tool-manuals.service.js +404 -74
  512. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  513. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  514. package/dist/modules/tool-manuals/tool-manuals.tools.js +78 -6
  515. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  516. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  517. package/dist/modules/workflow/agent-tools/workflow.tools.js +88 -54
  518. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  519. package/dist/modules/workflow/event-bus.d.ts +10 -4
  520. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  521. package/dist/modules/workflow/event-bus.js +14 -11
  522. package/dist/modules/workflow/event-bus.js.map +1 -1
  523. package/dist/modules/workflow/events.routes.d.ts +17 -2
  524. package/dist/modules/workflow/events.routes.d.ts.map +1 -1
  525. package/dist/modules/workflow/events.routes.js +31 -12
  526. package/dist/modules/workflow/events.routes.js.map +1 -1
  527. package/dist/modules/workflow/file-lock.service.d.ts +22 -4
  528. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  529. package/dist/modules/workflow/file-lock.service.js +27 -4
  530. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  531. package/dist/modules/workflow/git/change-request-link.d.ts +20 -0
  532. package/dist/modules/workflow/git/change-request-link.d.ts.map +1 -0
  533. package/dist/modules/workflow/git/change-request-link.js +34 -0
  534. package/dist/modules/workflow/git/change-request-link.js.map +1 -0
  535. package/dist/modules/workflow/git/git-version.d.ts.map +1 -1
  536. package/dist/modules/workflow/git/git-version.js +3 -1
  537. package/dist/modules/workflow/git/git-version.js.map +1 -1
  538. package/dist/modules/workflow/git/git.service.d.ts +263 -4
  539. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  540. package/dist/modules/workflow/git/git.service.js +528 -118
  541. package/dist/modules/workflow/git/git.service.js.map +1 -1
  542. package/dist/modules/workflow/git/node-git-runner.d.ts +36 -0
  543. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -0
  544. package/dist/modules/workflow/git/node-git-runner.js +273 -0
  545. package/dist/modules/workflow/git/node-git-runner.js.map +1 -0
  546. package/dist/modules/workflow/git/pull-request.service.d.ts +51 -2
  547. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  548. package/dist/modules/workflow/git/pull-request.service.js +157 -25
  549. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  550. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  551. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  552. package/dist/modules/workflow/pending-commits.service.js +24 -4
  553. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  554. package/dist/modules/workflow/pending-commits.worker.d.ts +15 -8
  555. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  556. package/dist/modules/workflow/pending-commits.worker.js +107 -58
  557. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  558. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  559. package/dist/modules/workflow/recovery-bot.js +3 -1
  560. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  561. package/dist/modules/workflow/review-workflow/approval-lock.d.ts +60 -0
  562. package/dist/modules/workflow/review-workflow/approval-lock.d.ts.map +1 -0
  563. package/dist/modules/workflow/review-workflow/approval-lock.js +116 -0
  564. package/dist/modules/workflow/review-workflow/approval-lock.js.map +1 -0
  565. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +54 -7
  566. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -1
  567. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +64 -0
  568. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  569. package/dist/modules/workflow/review-workflow/review-workflow.service.js +269 -90
  570. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  571. package/dist/modules/workflow/sanitize-error.d.ts +5 -2
  572. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
  573. package/dist/modules/workflow/sanitize-error.js +8 -4
  574. package/dist/modules/workflow/sanitize-error.js.map +1 -1
  575. package/dist/modules/workflow/workflow.routes.d.ts +2 -0
  576. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  577. package/dist/modules/workflow/workflow.routes.js +259 -29
  578. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  579. package/dist/modules/workflow/workflow.service.d.ts +166 -14
  580. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  581. package/dist/modules/workflow/workflow.service.js +906 -106
  582. package/dist/modules/workflow/workflow.service.js.map +1 -1
  583. package/dist/modules/workspace/empty-dirs.d.ts +15 -0
  584. package/dist/modules/workspace/empty-dirs.d.ts.map +1 -0
  585. package/dist/modules/workspace/empty-dirs.js +90 -0
  586. package/dist/modules/workspace/empty-dirs.js.map +1 -0
  587. package/dist/modules/workspace/file-readers/content-mode.d.ts +50 -0
  588. package/dist/modules/workspace/file-readers/content-mode.d.ts.map +1 -0
  589. package/dist/modules/workspace/file-readers/content-mode.js +55 -0
  590. package/dist/modules/workspace/file-readers/content-mode.js.map +1 -0
  591. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +4 -1
  592. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
  593. package/dist/modules/workspace/file-readers/doc-extract.service.js +4 -1
  594. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
  595. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +5 -2
  596. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -1
  597. package/dist/modules/workspace/file-readers/doc-extract.types.js +7 -4
  598. package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -1
  599. package/dist/modules/workspace/file-readers/document-reader.d.ts +2 -0
  600. package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -1
  601. package/dist/modules/workspace/file-readers/document-reader.js +5 -0
  602. package/dist/modules/workspace/file-readers/document-reader.js.map +1 -1
  603. package/dist/modules/workspace/file-readers/extract-docx.d.ts +6 -0
  604. package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -1
  605. package/dist/modules/workspace/file-readers/extract-docx.js +6 -0
  606. package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -1
  607. package/dist/modules/workspace/file-readers/file-reader.d.ts +24 -5
  608. package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -1
  609. package/dist/modules/workspace/file-readers/file-reader.js +11 -6
  610. package/dist/modules/workspace/file-readers/file-reader.js.map +1 -1
  611. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -1
  612. package/dist/modules/workspace/file-readers/file-reader.registry.js +6 -1
  613. package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -1
  614. package/dist/modules/workspace/file-readers/image-reader.d.ts +7 -5
  615. package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -1
  616. package/dist/modules/workspace/file-readers/image-reader.js +9 -5
  617. package/dist/modules/workspace/file-readers/image-reader.js.map +1 -1
  618. package/dist/modules/workspace/file-readers/ooxml-text.d.ts +3 -0
  619. package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -1
  620. package/dist/modules/workspace/file-readers/ooxml-text.js +39 -2
  621. package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -1
  622. package/dist/modules/workspace/file-readers/text-reader.d.ts +29 -1
  623. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
  624. package/dist/modules/workspace/file-readers/text-reader.js +57 -3
  625. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
  626. package/dist/modules/workspace/git-internals.middleware.d.ts +28 -0
  627. package/dist/modules/workspace/git-internals.middleware.d.ts.map +1 -0
  628. package/dist/modules/workspace/git-internals.middleware.js +93 -0
  629. package/dist/modules/workspace/git-internals.middleware.js.map +1 -0
  630. package/dist/modules/workspace/not-found.d.ts +80 -0
  631. package/dist/modules/workspace/not-found.d.ts.map +1 -0
  632. package/dist/modules/workspace/not-found.js +112 -0
  633. package/dist/modules/workspace/not-found.js.map +1 -0
  634. package/dist/modules/workspace/session-ontology.gate.d.ts +1 -1
  635. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -1
  636. package/dist/modules/workspace/session-ontology.gate.js +1 -1
  637. package/dist/modules/workspace/session-ontology.gate.js.map +1 -1
  638. package/dist/modules/workspace/startup/beside-checkout.d.ts +42 -0
  639. package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -0
  640. package/dist/modules/workspace/startup/beside-checkout.js +110 -0
  641. package/dist/modules/workspace/startup/beside-checkout.js.map +1 -0
  642. package/dist/modules/workspace/startup/kb-git.d.ts +7 -12
  643. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  644. package/dist/modules/workspace/startup/kb-git.js +34 -35
  645. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  646. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +95 -0
  647. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  648. package/dist/modules/workspace/startup/kb-startup-runner.js +257 -55
  649. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  650. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +45 -0
  651. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -1
  652. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +417 -385
  653. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
  654. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts +48 -0
  655. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts.map +1 -0
  656. package/dist/modules/workspace/startup/steps/personal-spaces.step.js +183 -0
  657. package/dist/modules/workspace/startup/steps/personal-spaces.step.js.map +1 -0
  658. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts +51 -0
  659. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts.map +1 -0
  660. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js +158 -0
  661. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js.map +1 -0
  662. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts +64 -0
  663. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts.map +1 -0
  664. package/dist/modules/workspace/startup/steps/plugin-layout.js +97 -0
  665. package/dist/modules/workspace/startup/steps/plugin-layout.js.map +1 -0
  666. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +7 -1
  667. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -1
  668. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +45 -81
  669. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -1
  670. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +3 -1
  671. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -1
  672. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +4 -12
  673. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
  674. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +6 -1
  675. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  676. package/dist/modules/workspace/startup/steps/seed-tree.js +154 -78
  677. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  678. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +96 -24
  679. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  680. package/dist/modules/workspace/startup/steps/template-files.step.js +446 -138
  681. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  682. package/dist/modules/workspace/startup/steps/template-source.d.ts +94 -0
  683. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -0
  684. package/dist/modules/workspace/startup/steps/template-source.js +160 -0
  685. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -0
  686. package/dist/modules/workspace/workspace.routes.d.ts +14 -2
  687. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  688. package/dist/modules/workspace/workspace.routes.js +649 -238
  689. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  690. package/dist/modules/workspace/workspace.service.d.ts +290 -12
  691. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  692. package/dist/modules/workspace/workspace.service.js +924 -269
  693. package/dist/modules/workspace/workspace.service.js.map +1 -1
  694. package/dist/modules/workspace/workspace.tools.d.ts +42 -1
  695. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  696. package/dist/modules/workspace/workspace.tools.js +1590 -146
  697. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  698. package/dist/modules/workspace/write-denial.d.ts +76 -0
  699. package/dist/modules/workspace/write-denial.d.ts.map +1 -0
  700. package/dist/modules/workspace/write-denial.js +138 -0
  701. package/dist/modules/workspace/write-denial.js.map +1 -0
  702. package/dist/shared/canonical-file-identity.d.ts +64 -0
  703. package/dist/shared/canonical-file-identity.d.ts.map +1 -0
  704. package/dist/shared/canonical-file-identity.js +92 -0
  705. package/dist/shared/canonical-file-identity.js.map +1 -0
  706. package/dist/shared/domain-errors.d.ts +108 -0
  707. package/dist/shared/domain-errors.d.ts.map +1 -1
  708. package/dist/shared/domain-errors.js +137 -0
  709. package/dist/shared/domain-errors.js.map +1 -1
  710. package/dist/shared/email-identity.d.ts +32 -0
  711. package/dist/shared/email-identity.d.ts.map +1 -0
  712. package/dist/shared/email-identity.js +37 -0
  713. package/dist/shared/email-identity.js.map +1 -0
  714. package/dist/shared/fs.contract.d.ts +206 -0
  715. package/dist/shared/fs.contract.d.ts.map +1 -0
  716. package/dist/shared/fs.contract.js +79 -0
  717. package/dist/shared/fs.contract.js.map +1 -0
  718. package/dist/shared/git-failure.d.ts +80 -0
  719. package/dist/shared/git-failure.d.ts.map +1 -0
  720. package/dist/shared/git-failure.js +139 -0
  721. package/dist/shared/git-failure.js.map +1 -0
  722. package/dist/shared/git-internals.d.ts +17 -0
  723. package/dist/shared/git-internals.d.ts.map +1 -0
  724. package/dist/shared/git-internals.js +123 -0
  725. package/dist/shared/git-internals.js.map +1 -0
  726. package/dist/shared/git.contract.d.ts +157 -0
  727. package/dist/shared/git.contract.d.ts.map +1 -0
  728. package/dist/shared/git.contract.js +103 -0
  729. package/dist/shared/git.contract.js.map +1 -0
  730. package/dist/shared/http-errors.d.ts +33 -0
  731. package/dist/shared/http-errors.d.ts.map +1 -0
  732. package/dist/shared/http-errors.js +35 -0
  733. package/dist/shared/http-errors.js.map +1 -0
  734. package/dist/shared/logger.contract.d.ts +63 -0
  735. package/dist/shared/logger.contract.d.ts.map +1 -0
  736. package/dist/shared/logger.contract.js +126 -0
  737. package/dist/shared/logger.contract.js.map +1 -0
  738. package/dist/shared/logging.d.ts +31 -0
  739. package/dist/shared/logging.d.ts.map +1 -0
  740. package/dist/shared/logging.js +92 -0
  741. package/dist/shared/logging.js.map +1 -0
  742. package/dist/shared/path-containment.d.ts +21 -0
  743. package/dist/shared/path-containment.d.ts.map +1 -0
  744. package/dist/shared/path-containment.js +34 -0
  745. package/dist/shared/path-containment.js.map +1 -0
  746. package/dist/shared/pending-proposals.d.ts +71 -0
  747. package/dist/shared/pending-proposals.d.ts.map +1 -0
  748. package/dist/shared/pending-proposals.js +98 -0
  749. package/dist/shared/pending-proposals.js.map +1 -0
  750. package/dist/shared/printable.d.ts +22 -0
  751. package/dist/shared/printable.d.ts.map +1 -0
  752. package/dist/shared/printable.js +65 -0
  753. package/dist/shared/printable.js.map +1 -0
  754. package/dist/shared/redact-secret.d.ts +30 -0
  755. package/dist/shared/redact-secret.d.ts.map +1 -0
  756. package/dist/shared/redact-secret.js +114 -0
  757. package/dist/shared/redact-secret.js.map +1 -0
  758. package/dist/shared/rename-no-replace.d.ts +119 -0
  759. package/dist/shared/rename-no-replace.d.ts.map +1 -0
  760. package/dist/shared/rename-no-replace.js +323 -0
  761. package/dist/shared/rename-no-replace.js.map +1 -0
  762. package/dist/shared/ssrf.d.ts.map +1 -1
  763. package/dist/shared/ssrf.js +49 -18
  764. package/dist/shared/ssrf.js.map +1 -1
  765. package/dist/version.d.ts +10 -6
  766. package/dist/version.d.ts.map +1 -1
  767. package/dist/version.js +10 -6
  768. package/dist/version.js.map +1 -1
  769. package/kb-template/.bevelignore +4 -3
  770. package/kb-template/AGENTS.md +281 -36
  771. package/kb-template/KnowledgeBase/How to get started.md +1 -1
  772. package/kb-template/access.md +5 -3
  773. package/kb-template/mcp-description.md +27 -0
  774. package/migrations/0006_api_tokens_revoked_by.sql +1 -0
  775. package/migrations/0007_github_facade_registered.sql +1 -0
  776. package/migrations/0008_change_request_apply_failure.sql +4 -0
  777. package/migrations/0009_change_request_apply_failure_kind.sql +13 -0
  778. package/migrations/0010_plugin_join_requests.sql +15 -0
  779. package/migrations/0011_plugin_join_request_claim.sql +11 -0
  780. package/migrations/meta/0006_snapshot.json +1723 -0
  781. package/migrations/meta/0007_snapshot.json +1729 -0
  782. package/migrations/meta/0008_snapshot.json +1753 -0
  783. package/migrations/meta/0009_snapshot.json +1759 -0
  784. package/migrations/meta/0010_snapshot.json +1872 -0
  785. package/migrations/meta/0011_snapshot.json +1884 -0
  786. package/migrations/meta/_journal.json +42 -0
  787. package/package.json +6 -5
  788. package/src/__tests__/core-config.domain.test.ts +67 -2
  789. package/src/__tests__/kb-layout-config.test.ts +186 -1
  790. package/src/__tests__/open-change-gate.ts +15 -0
  791. package/src/core/__tests__/catalog-cache-invalidation.test.ts +83 -3
  792. package/src/core/__tests__/catalog-revision.test.ts +190 -0
  793. package/src/core/__tests__/lifecycle.test.ts +369 -0
  794. package/src/core/__tests__/public-config.test.ts +30 -0
  795. package/src/core/__tests__/readiness.test.ts +109 -0
  796. package/src/core/catalog-cache-invalidation.ts +20 -1
  797. package/src/core/catalog-revision.ts +110 -0
  798. package/src/core/create-core-server.ts +258 -79
  799. package/src/core/create-core-services.ts +302 -81
  800. package/src/core/lifecycle.ts +312 -0
  801. package/src/core/public-config.ts +86 -0
  802. package/src/core/readiness.ts +123 -0
  803. package/src/core-config.ts +70 -4
  804. package/src/index.ts +15 -0
  805. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +362 -0
  806. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +2 -1
  807. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +46 -22
  808. package/src/modules/access/__tests__/access-control.deny-dependents.test.ts +215 -0
  809. package/src/modules/access/__tests__/access-control.download-read.test.ts +268 -0
  810. package/src/modules/access/__tests__/access-control.everyone-read.test.ts +145 -0
  811. package/src/modules/access/__tests__/access-control.group-read.test.ts +163 -0
  812. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +2 -0
  813. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +152 -0
  814. package/src/modules/access/__tests__/access-control.prospective.test.ts +189 -0
  815. package/src/modules/access/__tests__/access-control.read-batch.test.ts +2 -1
  816. package/src/modules/access/__tests__/access-control.service.test.ts +123 -78
  817. package/src/modules/access/__tests__/access-groups.test.ts +47 -14
  818. package/src/modules/access/__tests__/access-md-format.test.ts +2 -1
  819. package/src/modules/access/__tests__/access-mutation.service.test.ts +52 -4
  820. package/src/modules/access/__tests__/access-own-read-grant.test.ts +2 -0
  821. package/src/modules/access/__tests__/access-personal-plugin.test.ts +153 -0
  822. package/src/modules/access/__tests__/access-plugin-principals.test.ts +18 -1
  823. package/src/modules/access/__tests__/access-restrict-here.test.ts +450 -0
  824. package/src/modules/access/__tests__/access.routes.batch.test.ts +139 -0
  825. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +394 -0
  826. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +6 -2
  827. package/src/modules/access/__tests__/access.routes.overrides.test.ts +3 -2
  828. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +234 -0
  829. package/src/modules/access/__tests__/access.routes.prospective.test.ts +216 -0
  830. package/src/modules/access/__tests__/access.routes.revoke.test.ts +7 -2
  831. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +360 -0
  832. package/src/modules/access/__tests__/change-read-gate.test.ts +272 -0
  833. package/src/modules/access/__tests__/creator-access.test.ts +81 -83
  834. package/src/modules/access/__tests__/grant-sources.test.ts +3 -2
  835. package/src/modules/access/__tests__/groups-admin.service.test.ts +6 -5
  836. package/src/modules/access/__tests__/roles-admin.security.test.ts +196 -0
  837. package/src/modules/access/__tests__/roles-admin.service.test.ts +127 -2
  838. package/src/modules/access/__tests__/roles-capabilities.test.ts +5 -4
  839. package/src/modules/access/__tests__/roles.routes.test.ts +5 -2
  840. package/src/modules/access/__tests__/user-access-removal.service.test.ts +402 -0
  841. package/src/modules/access/__tests__/users-db-double.ts +63 -0
  842. package/src/modules/access/access-control.interface.ts +251 -11
  843. package/src/modules/access/access-control.service.ts +719 -164
  844. package/src/modules/access/access-mutation.service.ts +182 -13
  845. package/src/modules/access/access-view.ts +316 -0
  846. package/src/modules/access/access.routes.ts +293 -95
  847. package/src/modules/access/admin-locked-commit.ts +12 -14
  848. package/src/modules/access/admin-route-helpers.ts +4 -4
  849. package/src/modules/access/change-read-gate.ts +148 -0
  850. package/src/modules/access/creator-access.ts +89 -124
  851. package/src/modules/access/directory-sync-bot.ts +4 -1
  852. package/src/modules/access/groups-admin.service.ts +3 -3
  853. package/src/modules/access/reference-scan.ts +5 -5
  854. package/src/modules/access/roles-admin.service.ts +94 -15
  855. package/src/modules/access/synced-groups-committer.ts +10 -15
  856. package/src/modules/access/synced-groups-writer.ts +3 -12
  857. package/src/modules/access/user-access-removal.service.ts +711 -0
  858. package/src/modules/access-model/__tests__/access-splice.test.ts +63 -1
  859. package/src/modules/access-model/__tests__/frontmatter-fences.test.ts +71 -0
  860. package/src/modules/access-model/__tests__/roles-yaml-guard.test.ts +131 -0
  861. package/src/modules/access-model/access-errors.ts +56 -15
  862. package/src/modules/access-model/access-grammar.ts +85 -48
  863. package/src/modules/access-model/access-splice.ts +18 -18
  864. package/src/modules/access-model/access-template.ts +44 -0
  865. package/src/modules/access-model/change-gate.ts +80 -0
  866. package/src/modules/access-model/creator.ts +18 -21
  867. package/src/modules/access-model/frontmatter-lines.ts +98 -0
  868. package/src/modules/access-model/plugin-principals.ts +12 -19
  869. package/src/modules/access-model/roles-yaml-guard.ts +182 -4
  870. package/src/modules/admin/admin-access.service.ts +6 -2
  871. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +100 -0
  872. package/src/modules/agent-instructions/__tests__/compose.test.ts +256 -0
  873. package/src/modules/agent-instructions/__tests__/read-preamble.test.ts +135 -0
  874. package/src/modules/agent-instructions/agent-instructions.routes.ts +41 -0
  875. package/src/modules/agent-instructions/compose.ts +271 -0
  876. package/src/modules/agent-instructions/index.ts +14 -0
  877. package/src/modules/agent-instructions/read-preamble.ts +88 -0
  878. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +209 -0
  879. package/src/modules/auth/__tests__/account.routes.test.ts +178 -2
  880. package/src/modules/auth/__tests__/auth.middleware.test.ts +78 -0
  881. package/src/modules/auth/__tests__/auth.service.test.ts +338 -5
  882. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +352 -12
  883. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +3 -1
  884. package/src/modules/auth/account-erasure.service.ts +117 -10
  885. package/src/modules/auth/account.routes.ts +115 -14
  886. package/src/modules/auth/auth.routes.ts +20 -7
  887. package/src/modules/auth/auth.service.ts +187 -28
  888. package/src/modules/auth/oidc-auth-provider.ts +120 -22
  889. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +109 -1
  890. package/src/modules/code-mode/code-mode.tool.ts +13 -2
  891. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +78 -0
  892. package/src/modules/connection-probe/connection-probe.service.ts +59 -7
  893. package/src/modules/database/__tests__/advisory-lease.test.ts +152 -0
  894. package/src/modules/database/__tests__/advisory-lock.test.ts +147 -0
  895. package/src/modules/database/advisory-lock.ts +346 -0
  896. package/src/modules/database/connection.ts +9 -1
  897. package/src/modules/database/core-schema.ts +123 -0
  898. package/src/modules/database/migrate.ts +27 -6
  899. package/src/modules/declared-variables/declared-variables.routes.ts +6 -3
  900. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -0
  901. package/src/modules/diff/__tests__/diff.service.test.ts +3 -0
  902. package/src/modules/diff/diff.routes.ts +2 -4
  903. package/src/modules/diff/diff.service.ts +75 -79
  904. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +472 -9
  905. package/src/modules/kb-fs/__tests__/node-fs.test.ts +409 -0
  906. package/src/modules/kb-fs/__tests__/repo-path.test.ts +210 -1
  907. package/src/modules/{workspace → kb-fs}/bevel-ignore.ts +22 -15
  908. package/src/modules/kb-fs/file-change-notifier.ts +4 -1
  909. package/src/modules/kb-fs/git-guarded-filesystem.ts +123 -0
  910. package/src/modules/kb-fs/locking-filesystem.ts +399 -109
  911. package/src/modules/kb-fs/node-fs.ts +165 -0
  912. package/src/modules/kb-fs/read-only-filesystem.ts +18 -9
  913. package/src/modules/kb-fs/repo-path.ts +213 -3
  914. package/src/modules/kb-sync/kb-sync.routes.ts +5 -2
  915. package/src/modules/kb-sync/kb-sync.service.ts +8 -5
  916. package/src/modules/marketplace/__tests__/github-facade.test.ts +134 -2
  917. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +2 -1
  918. package/src/modules/marketplace/__tests__/marketplace-repo.head.test.ts +47 -0
  919. package/src/modules/marketplace/git-http.routes.ts +7 -4
  920. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +35 -3
  921. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +55 -0
  922. package/src/modules/marketplace/github-facade/github-facade.routes.ts +32 -6
  923. package/src/modules/marketplace/github-facade/github-facade.service.ts +80 -19
  924. package/src/modules/marketplace/github-facade/index.ts +1 -0
  925. package/src/modules/marketplace/marketplace-repo.service.ts +23 -8
  926. package/src/modules/mcp/__tests__/downstream-pool.test.ts +278 -0
  927. package/src/modules/mcp/__tests__/downstream-token-refresh.test.ts +178 -0
  928. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +156 -0
  929. package/src/modules/mcp/__tests__/first-call-probe.cli-args.test.ts +178 -0
  930. package/src/modules/mcp/__tests__/first-call-probe.test.ts +414 -0
  931. package/src/modules/mcp/__tests__/manual-failure-memo.test.ts +98 -6
  932. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +8 -5
  933. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +1 -3
  934. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +1055 -59
  935. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +182 -0
  936. package/src/modules/mcp/__tests__/mcp.service.test.ts +274 -6
  937. package/src/modules/mcp/__tests__/surface-log-throttle.test.ts +169 -0
  938. package/src/modules/mcp/downstream-pool.ts +256 -0
  939. package/src/modules/mcp/downstream-token-refresh.ts +170 -0
  940. package/src/modules/mcp/first-call-probe.cli-args.ts +159 -0
  941. package/src/modules/mcp/first-call-probe.cli.ts +60 -0
  942. package/src/modules/mcp/first-call-probe.ts +418 -0
  943. package/src/modules/mcp/manual-failure-memo.ts +81 -23
  944. package/src/modules/mcp/mcp-auth.middleware.ts +25 -16
  945. package/src/modules/mcp/mcp.routes.ts +376 -482
  946. package/src/modules/mcp/mcp.service.ts +846 -176
  947. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +5 -2
  948. package/src/modules/mcp/oauth/oauth-consent.routes.ts +4 -4
  949. package/src/modules/mcp/surface-log-throttle.ts +120 -0
  950. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +10 -7
  951. package/src/modules/plugins/__tests__/bundle-dialect.test.ts +165 -0
  952. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +139 -10
  953. package/src/modules/plugins/__tests__/fake-join-request-store.ts +189 -0
  954. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +608 -0
  955. package/src/modules/plugins/__tests__/plugin-creation-display-name.test.ts +241 -0
  956. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +148 -7
  957. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +10 -13
  958. package/src/modules/plugins/__tests__/plugin-names.test.ts +77 -0
  959. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +129 -19
  960. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +91 -29
  961. package/src/modules/plugins/__tests__/plugins.routes.test.ts +370 -19
  962. package/src/modules/plugins/__tests__/plugins.tools.test.ts +131 -0
  963. package/src/modules/plugins/__tests__/teams.routes.test.ts +248 -0
  964. package/src/modules/plugins/compile/compile-marketplace.ts +143 -46
  965. package/src/modules/plugins/compile/marketplace-compiler.service.ts +8 -7
  966. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +56 -20
  967. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +30 -9
  968. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +93 -18
  969. package/src/modules/plugins/discovery/kb-plugin-source.ts +10 -7
  970. package/src/modules/plugins/discovery/native.source.ts +21 -3
  971. package/src/modules/plugins/discovery/plugin-source.ts +17 -3
  972. package/src/modules/plugins/index.ts +16 -3
  973. package/src/modules/plugins/join-request-jobs.service.ts +675 -0
  974. package/src/modules/plugins/join-request-records.store.ts +353 -0
  975. package/src/modules/plugins/join-requests.service.ts +7 -4
  976. package/src/modules/plugins/plugin-links.service.ts +4 -17
  977. package/src/modules/plugins/plugin-links.ts +6 -3
  978. package/src/modules/plugins/plugin-provision.service.ts +209 -37
  979. package/src/modules/plugins/plugin-rename.service.ts +21 -9
  980. package/src/modules/plugins/plugins.contract.ts +52 -4
  981. package/src/modules/plugins/plugins.routes.ts +204 -124
  982. package/src/modules/plugins/plugins.service.ts +92 -18
  983. package/src/modules/plugins/plugins.tools.ts +88 -0
  984. package/src/modules/plugins/teams.routes.ts +129 -0
  985. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +359 -15
  986. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +248 -0
  987. package/src/modules/secrets-vault/__tests__/namespace-secrets.test.ts +202 -0
  988. package/src/modules/secrets-vault/db-secrets-vault.service.ts +217 -35
  989. package/src/modules/secrets-vault/index.ts +1 -0
  990. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +6 -3
  991. package/src/modules/secrets-vault/secrets-variable-loader.ts +4 -4
  992. package/src/modules/secrets-vault/secrets-vault.contract.ts +93 -0
  993. package/src/modules/secrets-vault/secrets-vault.routes.ts +99 -38
  994. package/src/modules/settings/__tests__/connection-check.test.ts +233 -0
  995. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +188 -6
  996. package/src/modules/settings/__tests__/git-root-folders.test.ts +247 -0
  997. package/src/modules/settings/__tests__/oidc-check.test.ts +185 -0
  998. package/src/modules/settings/__tests__/setup.routes.boot-failure.test.ts +119 -0
  999. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +170 -0
  1000. package/src/modules/settings/__tests__/setup.routes.oidc.test.ts +555 -0
  1001. package/src/modules/settings/__tests__/setup.routes.test.ts +667 -16
  1002. package/src/modules/settings/connection-check.ts +351 -0
  1003. package/src/modules/settings/deployment-settings.service.ts +403 -57
  1004. package/src/modules/settings/git-root-folders.ts +171 -0
  1005. package/src/modules/settings/oidc-check.ts +229 -0
  1006. package/src/modules/settings/setup.routes.ts +456 -110
  1007. package/src/modules/skills/__tests__/allowed-tools-check.test.ts +224 -0
  1008. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +210 -0
  1009. package/src/modules/skills/__tests__/pending-skills.service.test.ts +3 -3
  1010. package/src/modules/skills/__tests__/skills.routes.warnings.test.ts +91 -0
  1011. package/src/modules/skills/__tests__/skills.service.test.ts +223 -2
  1012. package/src/modules/skills/allowed-tools-check.ts +352 -0
  1013. package/src/modules/skills/index.ts +4 -0
  1014. package/src/modules/skills/pending-skills.service.ts +21 -82
  1015. package/src/modules/skills/skill-access-requests.routes.ts +10 -6
  1016. package/src/modules/skills/skills.contract.ts +43 -11
  1017. package/src/modules/skills/skills.routes.ts +16 -1
  1018. package/src/modules/skills/skills.service.ts +214 -84
  1019. package/src/modules/skills/skills.tools.ts +43 -5
  1020. package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +192 -0
  1021. package/src/modules/tool-auth/__tests__/connection-keys-admin.routes.test.ts +116 -0
  1022. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +124 -0
  1023. package/src/modules/tool-auth/connection-key-rejection.ts +25 -0
  1024. package/src/modules/tool-auth/connection-keys-admin.routes.ts +76 -0
  1025. package/src/modules/tool-auth/external-api-key.interface.ts +56 -3
  1026. package/src/modules/tool-auth/external-api-key.service.ts +73 -25
  1027. package/src/modules/tool-auth/internal-token.service.ts +5 -2
  1028. package/src/modules/tool-auth/key-or-session.middleware.ts +45 -0
  1029. package/src/modules/tool-auth/tool-auth.middleware.ts +16 -7
  1030. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +228 -0
  1031. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +33 -4
  1032. package/src/modules/tool-helpers/tool-context.ts +46 -7
  1033. package/src/modules/tool-helpers/tool-handler.ts +23 -5
  1034. package/src/modules/tool-helpers/tool.contract.ts +5 -0
  1035. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -1
  1036. package/src/modules/tool-manuals/__tests__/pending-tools.service.test.ts +411 -0
  1037. package/src/modules/tool-manuals/__tests__/tool-delete.route.test.ts +133 -0
  1038. package/src/modules/tool-manuals/__tests__/tool-delete.service.test.ts +469 -0
  1039. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +21 -2
  1040. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +9 -4
  1041. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +6 -1
  1042. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.routes.test.ts +254 -0
  1043. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.test.ts +577 -0
  1044. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +6 -1
  1045. package/src/modules/tool-manuals/__tests__/tool-manuals.pending.route.test.ts +102 -0
  1046. package/src/modules/tool-manuals/__tests__/tool-manuals.restart.test.ts +285 -0
  1047. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +178 -2
  1048. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +114 -2
  1049. package/src/modules/tool-manuals/index.ts +3 -0
  1050. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -10
  1051. package/src/modules/tool-manuals/mcp-server-edit.service.ts +11 -14
  1052. package/src/modules/tool-manuals/pending-tools.service.ts +179 -0
  1053. package/src/modules/tool-manuals/tool-delete.service.ts +401 -0
  1054. package/src/modules/tool-manuals/tool-manuals.contract.ts +165 -0
  1055. package/src/modules/tool-manuals/tool-manuals.routes.ts +138 -54
  1056. package/src/modules/tool-manuals/tool-manuals.service.ts +432 -87
  1057. package/src/modules/tool-manuals/tool-manuals.tools.ts +81 -6
  1058. package/src/modules/workflow/__tests__/apply-failure.test.ts +1038 -0
  1059. package/src/modules/workflow/__tests__/event-bus.test.ts +61 -9
  1060. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +199 -0
  1061. package/src/modules/workflow/__tests__/file-lock.service.path-identity.test.ts +237 -0
  1062. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +18 -0
  1063. package/src/modules/workflow/__tests__/merge-announces-tree.test.ts +220 -0
  1064. package/src/modules/workflow/__tests__/pending-commits.worker.concurrency.test.ts +187 -0
  1065. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +2 -0
  1066. package/src/modules/workflow/__tests__/sanitize-error.test.ts +10 -0
  1067. package/src/modules/workflow/__tests__/workflow.routes.folder-proposals.test.ts +128 -0
  1068. package/src/modules/workflow/__tests__/workflow.routes.fork-point-file.test.ts +149 -0
  1069. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +305 -0
  1070. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +2 -0
  1071. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +2 -0
  1072. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +2 -0
  1073. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +294 -25
  1074. package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +564 -0
  1075. package/src/modules/workflow/__tests__/workflow.service.platform-restore.test.ts +135 -0
  1076. package/src/modules/workflow/__tests__/workflow.service.read-gate.test.ts +172 -0
  1077. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +14 -0
  1078. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +5 -0
  1079. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +281 -0
  1080. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +158 -7
  1081. package/src/modules/workflow/agent-tools/workflow.tools.ts +93 -64
  1082. package/src/modules/workflow/event-bus.ts +26 -28
  1083. package/src/modules/workflow/events.routes.ts +56 -21
  1084. package/src/modules/workflow/file-lock.service.ts +27 -4
  1085. package/src/modules/workflow/git/__tests__/change-request-link.test.ts +43 -0
  1086. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -0
  1087. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +238 -1
  1088. package/src/modules/workflow/git/__tests__/git.service.forkPoint.test.ts +245 -0
  1089. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +35 -0
  1090. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +149 -0
  1091. package/src/modules/workflow/git/__tests__/git.service.revertPathsAndPush.test.ts +177 -0
  1092. package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +114 -0
  1093. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +69 -2
  1094. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +121 -0
  1095. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +79 -0
  1096. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  1097. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +1 -1
  1098. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-update.test.ts +111 -0
  1099. package/src/modules/workflow/git/change-request-link.ts +35 -0
  1100. package/src/modules/workflow/git/git-version.ts +5 -2
  1101. package/src/modules/workflow/git/git.service.ts +684 -144
  1102. package/src/modules/workflow/git/node-git-runner.ts +315 -0
  1103. package/src/modules/workflow/git/pull-request.service.ts +179 -27
  1104. package/src/modules/workflow/pending-commits.service.ts +26 -7
  1105. package/src/modules/workflow/pending-commits.worker.ts +114 -69
  1106. package/src/modules/workflow/recovery-bot.ts +4 -1
  1107. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +180 -1
  1108. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  1109. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +520 -0
  1110. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +84 -0
  1111. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +89 -77
  1112. package/src/modules/workflow/review-workflow/approval-lock.ts +122 -0
  1113. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +59 -7
  1114. package/src/modules/workflow/review-workflow/review-workflow.service.ts +309 -105
  1115. package/src/modules/workflow/sanitize-error.ts +9 -4
  1116. package/src/modules/workflow/workflow.routes.ts +273 -29
  1117. package/src/modules/workflow/workflow.service.ts +1087 -171
  1118. package/src/modules/workspace/__tests__/empty-dirs.test.ts +44 -0
  1119. package/src/modules/workspace/__tests__/file-stat-access.test.ts +301 -0
  1120. package/src/modules/workspace/__tests__/git-internals.security.test.ts +792 -0
  1121. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +464 -0
  1122. package/src/modules/workspace/__tests__/workspace.routes.branch-errors.test.ts +139 -0
  1123. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +189 -37
  1124. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +51 -11
  1125. package/src/modules/workspace/__tests__/workspace.routes.download-implies-read.test.ts +161 -0
  1126. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +109 -10
  1127. package/src/modules/workspace/__tests__/workspace.routes.file-writes.test.ts +624 -0
  1128. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +409 -0
  1129. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +327 -0
  1130. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +25 -6
  1131. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +109 -0
  1132. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -5
  1133. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +218 -0
  1134. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +56 -1
  1135. package/src/modules/workspace/__tests__/workspace.service.test.ts +811 -42
  1136. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +197 -0
  1137. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +128 -0
  1138. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +212 -0
  1139. package/src/modules/workspace/__tests__/workspace.tools.test.ts +2632 -110
  1140. package/src/modules/workspace/__tests__/write-denial.test.ts +75 -0
  1141. package/src/modules/workspace/empty-dirs.ts +79 -0
  1142. package/src/modules/workspace/file-readers/README.md +32 -0
  1143. package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +59 -2
  1144. package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +133 -4
  1145. package/src/modules/workspace/file-readers/__tests__/fixtures/soft-break.docx +0 -0
  1146. package/src/modules/workspace/file-readers/__tests__/fixtures/tab.docx +0 -0
  1147. package/src/modules/workspace/file-readers/content-mode.ts +87 -0
  1148. package/src/modules/workspace/file-readers/doc-extract.service.ts +4 -1
  1149. package/src/modules/workspace/file-readers/doc-extract.types.ts +8 -4
  1150. package/src/modules/workspace/file-readers/document-reader.ts +6 -0
  1151. package/src/modules/workspace/file-readers/extract-docx.ts +6 -0
  1152. package/src/modules/workspace/file-readers/file-reader.registry.ts +9 -1
  1153. package/src/modules/workspace/file-readers/file-reader.ts +27 -6
  1154. package/src/modules/workspace/file-readers/image-reader.ts +10 -5
  1155. package/src/modules/workspace/file-readers/ooxml-text.ts +38 -2
  1156. package/src/modules/workspace/file-readers/text-reader.ts +61 -4
  1157. package/src/modules/workspace/git-internals.middleware.ts +86 -0
  1158. package/src/modules/workspace/not-found.ts +119 -0
  1159. package/src/modules/workspace/session-ontology.gate.ts +1 -1
  1160. package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +181 -0
  1161. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +361 -0
  1162. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +53 -16
  1163. package/src/modules/workspace/startup/beside-checkout.ts +112 -0
  1164. package/src/modules/workspace/startup/kb-git.ts +43 -36
  1165. package/src/modules/workspace/startup/kb-startup-runner.ts +303 -63
  1166. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1215 -73
  1167. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +460 -417
  1168. package/src/modules/workspace/startup/steps/personal-spaces.step.ts +191 -0
  1169. package/src/modules/workspace/startup/steps/plugin-display-names.step.ts +169 -0
  1170. package/src/modules/workspace/startup/steps/plugin-layout.ts +110 -0
  1171. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +46 -85
  1172. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +6 -12
  1173. package/src/modules/workspace/startup/steps/seed-tree.ts +159 -78
  1174. package/src/modules/workspace/startup/steps/template-files.step.ts +492 -141
  1175. package/src/modules/workspace/startup/steps/template-source.ts +168 -0
  1176. package/src/modules/workspace/workspace.routes.ts +687 -257
  1177. package/src/modules/workspace/workspace.service.ts +991 -303
  1178. package/src/modules/workspace/workspace.tools.ts +1787 -180
  1179. package/src/modules/workspace/write-denial.ts +189 -0
  1180. package/src/shared/__tests__/canonical-file-identity.test.ts +154 -0
  1181. package/src/shared/__tests__/email-identity.test.ts +35 -0
  1182. package/src/shared/__tests__/filename.test.ts +0 -0
  1183. package/src/shared/__tests__/frontmatter-id.test.ts +5 -0
  1184. package/src/shared/__tests__/git-failure.test.ts +198 -0
  1185. package/src/shared/__tests__/git-internals.test.ts +115 -0
  1186. package/src/shared/__tests__/http-errors.test.ts +42 -0
  1187. package/src/shared/__tests__/logging.test.ts +185 -0
  1188. package/src/shared/__tests__/path-containment.test.ts +60 -0
  1189. package/src/shared/__tests__/pending-proposals.readAt.test.ts +102 -0
  1190. package/src/shared/__tests__/printable.test.ts +64 -0
  1191. package/src/shared/__tests__/redact-secret.test.ts +122 -0
  1192. package/src/shared/__tests__/rename-no-replace.test.ts +466 -0
  1193. package/src/shared/__tests__/ssrf.test.ts +20 -0
  1194. package/src/shared/canonical-file-identity.ts +92 -0
  1195. package/src/shared/domain-errors.ts +149 -0
  1196. package/src/shared/email-identity.ts +38 -0
  1197. package/src/shared/fs.contract.ts +225 -0
  1198. package/src/shared/git-failure.ts +196 -0
  1199. package/src/shared/git-internals.ts +124 -0
  1200. package/src/shared/git.contract.ts +170 -0
  1201. package/src/shared/http-errors.ts +36 -0
  1202. package/src/shared/logger.contract.ts +141 -0
  1203. package/src/shared/logging.ts +96 -0
  1204. package/src/shared/path-containment.ts +34 -0
  1205. package/src/shared/pending-proposals.ts +161 -0
  1206. package/src/shared/printable.ts +68 -0
  1207. package/src/shared/redact-secret.ts +118 -0
  1208. package/src/shared/rename-no-replace.ts +386 -0
  1209. package/src/shared/ssrf.ts +44 -16
  1210. package/src/version.ts +10 -6
  1211. package/src/modules/diff/diff-paths.ts +0 -9
  1212. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +0 -151
  1213. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +0 -226
  1214. package/src/modules/mcp/mcp-session-store.ts +0 -162
  1215. package/src/shared/__tests__/fs-walk.test.ts +0 -63
  1216. package/src/shared/__tests__/kb-walk.test.ts +0 -107
  1217. package/src/shared/fs-errors.ts +0 -10
  1218. package/src/shared/fs-walk.ts +0 -45
  1219. package/src/shared/hash-email.ts +0 -14
  1220. package/src/shared/kb-walk.ts +0 -95
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import nodeFs from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { ToolError } from '../tool-helpers/tool.contract.js';
4
5
  import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
@@ -8,11 +9,32 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
8
9
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
9
10
  // relies on) — not a workflow service, so this stays inside the module boundary.
10
11
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
11
- import { assertInsideRepo } from '../kb-fs/repo-path.js';
12
+ import { assertInsideRepo, assertRepoRootNameFreeArgs, normalizePathArgs } from '../kb-fs/repo-path.js';
13
+ import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
14
+ import { assertNoGitInternalsSegment, hasGitInternalsSegment } from '../../shared/git-internals.js';
15
+ import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
16
+ import { isAbsence } from '../../shared/fs.contract.js';
17
+ import { accessRoster, resolveAccessView } from '../access/access-view.js';
18
+ import { accessMdPathForFolder, fileCarriesAccessRules, governingFolderOf } from '../access/access-mutation.service.js';
12
19
  import { toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
20
+ import { displayPath } from './file-readers/file-reader.js';
21
+ import { fileTypeOf, needsContent } from './file-readers/content-mode.js';
13
22
  import { createFileReaderRegistry } from './file-readers/file-reader.registry.js';
14
23
  import { DocumentReader } from './file-readers/document-reader.js';
15
24
  import { mcpImageResult } from '@bevel-software/platform-mcp-core';
25
+ import { AGENTS_FILE, LEGACY_AGENTS_FILE, folderPlaceholderPath, isFolderPlaceholder, isPlatformFile, isPlatformFolder, isProtectedBranch, onKbLayoutApplied, platformFileCreationRefusal, platformFileNames, platformFileRefusal, platformFolderRefusal, entryExistsMessage, } from '@bevel-software/platform-shared';
26
+ import { AccessDeniedError } from '../access-model/access-errors.js';
27
+ import { removeEmptyDirs } from './empty-dirs.js';
28
+ import { PROPOSAL_ROUTE_NOTE, rethrowAsWriteDenial } from './write-denial.js';
29
+ import { notFound, orDeclaredNotFound, orNotFound } from './not-found.js';
30
+ import { logger } from '../../shared/logging.js';
31
+ import { printable } from '../../shared/printable.js';
32
+ import { DestinationTakenError, inspectDestination } from '../../shared/rename-no-replace.js';
33
+ const log = logger('workspace-tools');
34
+ /** How many files `file_stat` counts under a folder before it stops and says so. */
35
+ const DESCENDANTS_CAP = 10_000;
36
+ /** How many of a folder's files a `delete_folder` answer names. */
37
+ const LISTED_FILES_CAP = 100;
16
38
  /** Throw a 403 ToolError if the gate denies reading `wsPath` (a KB node). */
17
39
  async function assertCanRead(gate, wsPath) {
18
40
  const rel = toKbRelative(wsPath, gate.kbDirName);
@@ -49,32 +71,136 @@ async function filterReadableEntries(gate, dir, entries) {
49
71
  });
50
72
  }
51
73
  /**
52
- * Appended (centrally, in `mount`) to EVERY workspace tool description. A KB
53
- * author can drop an `AGENTS.md` at the workspace root to document conventions
54
- * for that knowledge base; agents (ours and external) should consult it before
55
- * touching files. It rides on every entrypoint — reads (grep/list_files/
56
- * file_stat) included — because any of them can be a session's first touch.
74
+ * Drop the empty-folder placeholder from a directory listing: it keeps a
75
+ * folder alive in git and is never content (see `placeholder.ts`).
76
+ */
77
+ function withoutPlaceholder(entries) {
78
+ return entries.filter((e) => e.type === 'directory' || !isFolderPlaceholder(e.name));
79
+ }
80
+ /**
81
+ * Keep the folder a removal just emptied. A folder exists until it is deleted
82
+ * explicitly, so when deleting or moving out its last entry leaves it empty it
83
+ * gets the placeholder, written through the same filesystem (the agent's
84
+ * lock-aware one commits it) within the same tool call. Only folders inside
85
+ * the repository qualify, never the clone folder itself.
86
+ *
87
+ * It runs in the folder's turn, which the explicit folder delete also takes,
88
+ * and looks inside it: a folder that is gone by then was deleted explicitly
89
+ * and stays gone. A failure fails the call — the removal landed, but the
90
+ * folder would vanish on the next clone, and the agent must hear that.
91
+ */
92
+ async function keepFolderOf(fs, ctx, branch, removedPath, kbDirName) {
93
+ const trimmed = removedPath.replace(/^\/+/, '').replace(/\/+$/, '');
94
+ const dir = trimmed.includes('/') ? trimmed.slice(0, trimmed.lastIndexOf('/')) : '';
95
+ if (!dir.startsWith(`${kbDirName}/`))
96
+ return;
97
+ try {
98
+ await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), dir, async () => {
99
+ let entries;
100
+ try {
101
+ entries = await fs.readdir(dir);
102
+ }
103
+ catch (err) {
104
+ if (isAbsence(err))
105
+ return;
106
+ throw err;
107
+ }
108
+ if (entries.length > 0)
109
+ return;
110
+ await fs.writeFile(folderPlaceholderPath(dir), '');
111
+ });
112
+ }
113
+ catch (err) {
114
+ const reason = err instanceof Error ? err.message : String(err);
115
+ log.error(`could not keep the folder ${printable(dir)} after removing ${printable(removedPath)}: ${printable(reason)}`);
116
+ const message = `"${removedPath}" was removed, but its folder "${dir}" could not be kept: ${reason}`;
117
+ // The original error keeps its status (a lock held elsewhere stays a 409).
118
+ if (!(err instanceof Error))
119
+ throw new ToolError(message, 500);
120
+ err.message = message;
121
+ throw err;
122
+ }
123
+ }
124
+ /**
125
+ * Appended (centrally, in `mount`) to EVERY workspace tool description. The
126
+ * platform's managed agent guide sits at the workspace root and documents the
127
+ * conventions of that knowledge base; agents (ours and external) should consult
128
+ * it before touching files. It rides on every entrypoint — reads (grep/
129
+ * list_files/file_stat) included — because any of them can be a session's first
130
+ * touch.
57
131
  *
58
132
  * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
59
133
  * rename still carry one, and the seeder never deletes a file it did not
60
134
  * expect. Naming both means an agent finds the conventions either way, instead
61
135
  * of reading none because it looked for the newer name and stopped.
136
+ *
137
+ * WHEN THE GUIDE HAS BEEN RENAMED the sentence names two files, ours first. The
138
+ * second is the organisation's OWN `AGENTS.md`, which on such a deployment is
139
+ * ordinary content the platform never touches — and which no harness reads for a
140
+ * remote agent, because a remote agent has no checkout. Telling it to read both
141
+ * is the only way the conventions the customer actually wrote reach the agent
142
+ * working in their knowledge base. Under the default name the wording collapses
143
+ * to the one file it has always named.
144
+ *
145
+ * A FUNCTION, called at mount time: the name is a deployment setting applied at
146
+ * boot, and a module-scope string would snapshot the default.
62
147
  */
63
- const KB_CONVENTIONS_NOTE = ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
148
+ function kbConventionsNote() {
149
+ if (AGENTS_FILE === LEGACY_AGENTS_FILE) {
150
+ return ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
151
+ }
152
+ return (` Before your first read or change in a workspace, read \`${AGENTS_FILE}\` at the KB root, then ` +
153
+ '`AGENTS.md` if it also exists (the organisation\'s own conventions) — or `CLAUDE.md` on a knowledge base seeded before it was renamed: together they hold the conventions for this knowledge base, and you should follow them.');
154
+ }
155
+ /** The platform files as a tool description lists them — the guide under its own name. */
156
+ function platformFileList() {
157
+ return platformFileNames()
158
+ .map((name) => `\`${name}\``)
159
+ .join(', ');
160
+ }
64
161
  const int = (description) => ({ type: 'integer', description });
65
162
  const str = (description) => ({ type: 'string', description });
66
163
  /**
67
- * A path input that names the clone folder. The tools are rooted at the
68
- * WORKSPACE dir, one level above the git clone, so a path only reaches git
69
- * when it starts with that folder; an agent that reads `KnowledgeBase/Foo.md`
70
- * in a URL or a doc and passes it verbatim would otherwise write beside the
71
- * repository. Saying so in the input itself, not only in prose, is what the
72
- * agent actually sees when it fills the argument. `refused` is false for the
73
- * inputs that may legitimately name a stray (a source to rescue, a file to
74
- * remove).
164
+ * Where pictures go, on the two tools that write pages. An agent in core cannot
165
+ * upload bytes yet (TODOS.md), but it can write the page with the link a person
166
+ * will satisfy, and this sentence is what keeps every page it writes on the
167
+ * README's convention: images beside the page, linked relatively.
75
168
  */
76
- const wsPath = (kbDirName, what, refused = true) => str(`${what}: starts with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`).` +
77
- (refused ? ' A path without that prefix is outside the repository and is refused.' : ''));
169
+ const IMAGE_CONVENTION_NOTE = ' Images: keep them in an `assets/` folder next to the page that uses them and link them with a relative path, e.g. `![Approval screen](./assets/approval-screen.png)`; the page renders them inline.';
170
+ /**
171
+ * A path input that names the clone folder, and says what happens when it does
172
+ * not. The tools are rooted at the WORKSPACE dir, one level above the git clone,
173
+ * so a path reaches git only when it starts with that folder — and a path that
174
+ * does not name it is PLACED under it now rather than refused, by the one
175
+ * normaliser every route and tool goes through. An agent that reads
176
+ * `KnowledgeBase/Foo.md` in a URL or a doc and passes it verbatim gets the page
177
+ * of that name in the repository, which is what it meant; it no longer gets a
178
+ * refusal, and it never again gets a file written beside the repository where
179
+ * nothing commits it. Saying so in the input itself, not only in prose, is what
180
+ * the agent actually sees when it fills the argument.
181
+ *
182
+ * ONE sentence for every input, where there used to be a shorter second form for
183
+ * the ones that could legitimately name a stray (a source to rescue, a file to
184
+ * remove). Nothing can name a stray any more. Traversal (`.`/`..`), backslashes
185
+ * and absolute paths are still refused, everywhere.
186
+ */
187
+ const wsPath = (kbDirName, what) => str(`${what}: under \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), with or without a leading slash (\`/${kbDirName}/…\` is the same path). ` +
188
+ `A path without that prefix is placed under \`${kbDirName}/\`, so \`KnowledgeBase/Foo.md\` means \`${kbDirName}/KnowledgeBase/Foo.md\`; \`.\` or \`..\` segments, backslashes and absolute paths are refused.`);
189
+ /**
190
+ * The inputs each tool CREATES at, for the reserved-root-name rule (see
191
+ * `assertRepoRootNameFreeArgs`). Destinations only: `src` of a copy or a move,
192
+ * and the path of a delete, are left out on purpose, so an existing reserved
193
+ * folder can be moved out of or removed. A tool absent here creates nothing.
194
+ */
195
+ const RESERVED_ROOT_NAME_TARGETS = {
196
+ write_file: ['path'],
197
+ write_files: ['files'],
198
+ edit_file: ['path'],
199
+ mkdir: ['path'],
200
+ copy_file: ['dest'],
201
+ move_file: ['dest'],
202
+ unzip: ['destination'],
203
+ };
78
204
  function asText(content) {
79
205
  return typeof content === 'string' ? content : content.toString('utf8');
80
206
  }
@@ -92,22 +218,51 @@ function asBytes(content) {
92
218
  */
93
219
  const UNCACHED_DOCS_PER_GREP = 20;
94
220
  /**
95
- * The write-refusal for office documents/PDFs on the agent TEXT-editing tools
96
- * (write_file / write_files / edit_file). `read_file` returns an EXTRACTION
97
- * for these types — their reader declares `textEditable: false` — so an agent
98
- * that "read" one and writes text back would
99
- * silently destroy the real document. Uploads and the plain HTTP write routes
100
- * are untouched — humans replacing a document is exactly the right move — and
101
- * `unzip` stays the raw-access escape hatch.
221
+ * THE binary capability contract, stated once and appended (in `mount`) to
222
+ * every file tool's description — which is also what `tools_info` returns.
223
+ * The split it states is enforced by the reader registry: the text tools
224
+ * refuse what their reader marks not `textEditable` (and binary content under
225
+ * any name) with a `binary_not_writable` refusal; the byte tools never look.
226
+ */
227
+ export const CONTENT_RULE = ' Content rule (the same on every file tool): read_file returns text for text files and extracted text for documents (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf, .eml/.msg); write_file, write_files and edit_file accept TEXT only — they refuse documents, images, archives and other binary files (legacy .doc/.ppt/.xls included) with kind `binary_not_writable`, naming the file\'s kind and the tool to use instead; copy_file, move_file, delete_file and unzip act on bytes of any kind; new binary content arrives through upload (`request_upload_token` + `apply_upload` where offered, otherwise Upload in the app). file_stat reports `contentMode` (`text` | `document` | `binary`) so you can decide before acting.';
228
+ /** What a `binary_not_writable` refusal points to, in the order to try them. */
229
+ const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'];
230
+ /**
231
+ * The ONE refusal the text tools give for content they must not write: status
232
+ * 415, kind `binary_not_writable`, the file's kind, and the tools to use
233
+ * instead — upload for new bytes, copy_file/move_file for bytes already in
234
+ * the workspace. `explanation` is the format-specific why.
235
+ */
236
+ function binaryNotWritable(fileKind, explanation) {
237
+ return new ToolError(`${explanation} [binary_not_writable: this file's kind is ${fileKind}; write_file, write_files and edit_file accept text only. ` +
238
+ 'Use upload for new bytes (`request_upload_token` + `apply_upload` where offered, otherwise Upload in the app), ' +
239
+ 'or copy_file / move_file to place bytes that are already in the workspace.]', 415, { kind: 'binary_not_writable', fileKind, useInstead: [...BINARY_USE_INSTEAD] });
240
+ }
241
+ /** The generic why, for a reader without format-specific refusal copy. */
242
+ const KIND_EXPLANATION = {
243
+ text: (p) => `"${p}" cannot be written as text.`,
244
+ document: (p) => `"${p}" is an office document/PDF. read_file returns EXTRACTED text for it — not the file's real ` +
245
+ 'content — so text written back cannot round-trip and would corrupt the document. To change it, ' +
246
+ 'replace the document by uploading a new version.',
247
+ image: (p) => `"${p}" is an image. Its content is bytes, and text written to it could only produce a broken picture.`,
248
+ archive: (p) => `"${p}" is an archive. Its content is bytes, and text written to it could only produce a broken archive.`,
249
+ binary: (p) => `"${p}" is a binary file. Its content is bytes, and text written to it could only produce a broken file.`,
250
+ };
251
+ /**
252
+ * The write-refusal by FORMAT on the agent TEXT-editing tools (write_file /
253
+ * write_files / edit_file): documents (read_file returns an EXTRACTION, so
254
+ * text written back would silently destroy the real document), images,
255
+ * archives and other binary formats — every reader that declares
256
+ * `textEditable: false`. Uploads and the plain HTTP write routes are
257
+ * untouched — humans replacing a file is exactly the right move — and the
258
+ * byte tools (copy/move/delete/unzip) never ask.
102
259
  */
103
260
  function assertNotDocumentEdit(readers, path) {
104
261
  const reader = readers.readerFor(path);
105
262
  if (reader.textEditable)
106
263
  return;
107
- throw new ToolError(reader.editRefusal?.(path) ??
108
- `"${path}" is an office document/PDF. read_file returns EXTRACTED text for it — not the file's real ` +
109
- 'content — so text written back cannot round-trip and would corrupt the document. To change it, ' +
110
- 'replace the document by uploading a new version.', 400);
264
+ const shown = displayPath(path);
265
+ throw binaryNotWritable(reader.fileKind, reader.editRefusal?.(shown) ?? KIND_EXPLANATION[reader.fileKind](shown));
111
266
  }
112
267
  /**
113
268
  * The write-refusal for what a path ALREADY holds, as opposed to what its
@@ -132,18 +287,147 @@ async function assertNotBinaryOverwrite(readers, path, fs) {
132
287
  }
133
288
  catch (err) {
134
289
  // Only a MISSING file is a create (both raw Node errors and Mastra's
135
- // FileNotFoundError carry code 'ENOENT'). Any other failure — permissions,
136
- // I/O — means the existing content could not be inspected: propagate it
137
- // rather than let the write destroy bytes the gate never saw.
138
- if (err?.code === 'ENOENT')
290
+ // FileNotFoundError carry the disk's absence codes). Any other failure —
291
+ // permissions, I/O — means the existing content could not be inspected:
292
+ // propagate it rather than let the write destroy bytes the gate never saw.
293
+ if (isAbsence(err))
139
294
  return undefined; // nothing there yet
140
295
  throw err;
141
296
  }
142
297
  const refusal = reader.editRefusalForExisting(existing, path);
143
298
  if (refusal !== null)
144
- throw new ToolError(refusal, 400);
299
+ throw binaryNotWritable('binary', refusal);
145
300
  return existing;
146
301
  }
302
+ /**
303
+ * The `mode` input, on both write tools. Stated on the input itself and not
304
+ * only in the description, because the argument is where an agent decides:
305
+ * the default refuses to replace anything, so "write this here" can no longer
306
+ * destroy a page the agent never read.
307
+ */
308
+ const WRITE_MODE_INPUT = {
309
+ type: 'string',
310
+ enum: ['create', 'overwrite', 'update'],
311
+ description: 'What the write may do at the path, default `create`: `create` writes a NEW file and refuses (`exists`) a path that already ' +
312
+ 'holds something; `overwrite` replaces what is there, and creates the file when there is nothing; `update` replaces an ' +
313
+ 'EXISTING file and refuses (`missing`) a path that holds nothing.',
314
+ };
315
+ /** The same three modes, said once, for both tool descriptions. */
316
+ const WRITE_MODE_NOTE = ' `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` writes a new file and refuses a path that ' +
317
+ 'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
318
+ '(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
319
+ 'A refused path is left exactly as it was.';
320
+ /** The refusal `create` gives on a path that already holds something. */
321
+ function pathExists(path) {
322
+ return new ToolError(`"${displayPath(path)}" already exists — pass mode: overwrite to replace it, or write to a different path.`, 409, { code: 'exists', path });
323
+ }
324
+ /** The refusal `update` gives on a path that holds nothing. */
325
+ function pathMissing(path) {
326
+ return new ToolError(`"${displayPath(path)}" does not exist — pass mode: create to create it.`, 404, { code: 'missing', path });
327
+ }
328
+ /**
329
+ * The mode gate for ONE path: refuses the write the mode does not allow, or
330
+ * names what it is about to do. `create` on something that exists and `update`
331
+ * on something that does not are the two refusals; everything else writes, and
332
+ * the outcome distinguishes a file that was there from one that was not.
333
+ */
334
+ function decideWrite(mode, path, exists) {
335
+ if (mode === 'create' && exists)
336
+ throw pathExists(path);
337
+ if (mode === 'update' && !exists)
338
+ throw pathMissing(path);
339
+ if (mode === 'update')
340
+ return 'updated';
341
+ return exists ? 'replaced' : 'created';
342
+ }
343
+ /** The three modes, as a set the handler can check a raw argument against. */
344
+ const WRITE_MODES = ['create', 'overwrite', 'update'];
345
+ /**
346
+ * The call's mode — `create` when it says nothing, which is the whole point of
347
+ * the default. A mode that is not one of the three is REFUSED rather than
348
+ * treated as the nearest thing: a tool that quietly read `replace` as
349
+ * "overwrite" would put the silent overwrite back, by a different door.
350
+ */
351
+ function modeOf(a) {
352
+ if (a.mode === undefined || a.mode === null)
353
+ return 'create';
354
+ if (typeof a.mode === 'string' && WRITE_MODES.includes(a.mode))
355
+ return a.mode;
356
+ throw new ToolError(`"${String(a.mode)}" is not a write mode: use ${WRITE_MODES.map((m) => `\`${m}\``).join(', ')} (default \`create\`).`, 400, { code: 'bad_mode' });
357
+ }
358
+ /**
359
+ * Search ONE file and append its matches to `out`. Shared by the directory
360
+ * walk and by a grep whose `path` names a file, so both produce the same match
361
+ * shape (that path, 1-based line numbers, 300-char text) under the same cap.
362
+ *
363
+ * What is searched is the file's reader's business: text content for the text
364
+ * reader (null on NUL bytes / invalid UTF-8 — binary is not searchable), the
365
+ * EXTRACTION for a document reader (marker line included, so the
366
+ * `[slide N]`/`[sheet: …]`/`[page N]` lines are themselves searchable and line
367
+ * numbers match what read_file returns), nothing for images.
368
+ *
369
+ * Throws whatever reading the file throws — the caller decides whether that is
370
+ * a file to skip (the walk) or an error to surface (a single-file grep).
371
+ */
372
+ async function grepOneFile(fs, path, re, out, max, docs) {
373
+ const reader = docs.readers.readerFor(path);
374
+ const bytes = asBytes(await fs.readFile(path));
375
+ let content = reader.greppableText ? await reader.greppableText(bytes, path) : null;
376
+ if (content === null && reader instanceof DocumentReader) {
377
+ // Cold document (extraction not yet cached): cached ones above are free,
378
+ // extracting draws on the per-walk budget (see UNCACHED_DOCS_PER_GREP) —
379
+ // beyond it the search skips the document and counts it.
380
+ if (docs.uncachedBudget <= 0) {
381
+ docs.skippedUncached++;
382
+ return 'budget-skipped';
383
+ }
384
+ docs.uncachedBudget--;
385
+ const res = await reader.read(bytes, path);
386
+ // A non-text outcome is a corrupt document — nothing searchable.
387
+ content = res.kind === 'text' ? res.text : null;
388
+ }
389
+ if (content === null)
390
+ return 'no-text';
391
+ const lines = content.split('\n');
392
+ for (let i = 0; i < lines.length && out.length < max; i++) {
393
+ re.lastIndex = 0;
394
+ if (re.test(lines[i]))
395
+ out.push({ path, line: i + 1, text: lines[i].slice(0, 300) });
396
+ }
397
+ return 'searched';
398
+ }
399
+ /**
400
+ * What a grep's `path` actually names. The walk starts with `readdir`, which
401
+ * fails on a file and on a path with nothing at it alike — both would end as a
402
+ * silent empty result — so the search root is resolved FIRST and each case
403
+ * gets its own honest answer.
404
+ *
405
+ * This helper never raises an error of its own. Only a DIRECTORY answer earns
406
+ * the walk; everything else takes the single-file route, where the read gate
407
+ * speaks first and the answer is then produced by the very `fs.readFile` that
408
+ * `read_file` calls. That is what makes grep tell read_file's story for an odd
409
+ * path by CONSTRUCTION rather than by coincidence.
410
+ */
411
+ async function searchRootKind(fs, path) {
412
+ try {
413
+ return (await fs.stat(path)).type === 'directory' ? 'directory' : 'file';
414
+ }
415
+ catch (err) {
416
+ // "Nothing there" is the disk's own definition of absence: plain ENOENT,
417
+ // and ENOTDIR for a path whose parent is an existing FILE
418
+ // (`notes.md/deeper`) — nothing can live there either, so it earns the
419
+ // same honest 404 rather than a raw failure. (Mastra's
420
+ // FileNotFoundError carries these codes, as do raw Node errors.)
421
+ if (isAbsence(err))
422
+ return 'missing';
423
+ // Any OTHER stat failure — permissions, I/O, a symlink loop — is not
424
+ // absence and is not this helper's to report. Calling it a file sends the
425
+ // path down the ordinary single-file route: the gate answers 403 if the
426
+ // caller may not read it, and otherwise the read itself fails exactly as
427
+ // `read_file`'s does. Nothing is invented, and nothing extra is disclosed.
428
+ return 'file';
429
+ }
430
+ }
147
431
  /** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
148
432
  async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead, docs) {
149
433
  if (out.length >= max || depth > 12)
@@ -161,7 +445,9 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
161
445
  for (const e of entries) {
162
446
  if (out.length >= max)
163
447
  return;
164
- if (e.name === '.git' || e.name === 'node_modules')
448
+ if (hasGitInternalsSegment(e.name) || e.name === 'node_modules')
449
+ continue;
450
+ if (e.type !== 'directory' && isFolderPlaceholder(e.name))
165
451
  continue;
166
452
  const p = dir ? `${dir}/${e.name}` : e.name;
167
453
  if (e.type === 'directory') {
@@ -172,44 +458,35 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
172
458
  // for a root-level grep that resolves to a neutral root. Record it so a
173
459
  // cross-ontology grep poisons later writes (closes the read-leak).
174
460
  await recordOntologyRead(p);
175
- // What grep searches is the file's reader's business: text content for
176
- // the text reader (null on NUL bytes — binary is not searchable), the
177
- // EXTRACTION for a document reader (marker line included, so the
178
- // [slide N]/[sheet: …]/[page N] lines are themselves searchable and
179
- // line numbers match what read_file returns), nothing for images.
180
- const reader = docs.readers.readerFor(p);
181
- let content;
461
+ // A file the walk cannot read is silently skipped: one unreadable entry
462
+ // must not fail a search over the whole tree.
182
463
  try {
183
- const bytes = asBytes(await fs.readFile(p));
184
- content = reader.greppableText ? await reader.greppableText(bytes, p) : null;
185
- if (content === null && reader instanceof DocumentReader) {
186
- // Cold document (extraction not yet cached): cached ones above are
187
- // free, extracting draws on the per-walk budget (see
188
- // UNCACHED_DOCS_PER_GREP) — beyond it the walk skips and counts.
189
- if (docs.uncachedBudget <= 0) {
190
- docs.skippedUncached++;
191
- continue;
192
- }
193
- docs.uncachedBudget--;
194
- const res = await reader.read(bytes, p);
195
- // A non-text outcome is a corrupt document — nothing searchable.
196
- content = res.kind === 'text' ? res.text : null;
197
- }
464
+ await grepOneFile(fs, p, re, out, max, docs);
198
465
  }
199
466
  catch {
200
467
  continue;
201
468
  }
202
- if (content === null)
203
- continue;
204
- const lines = content.split('\n');
205
- for (let i = 0; i < lines.length && out.length < max; i++) {
206
- re.lastIndex = 0;
207
- if (re.test(lines[i]))
208
- out.push({ path: p, line: i + 1, text: lines[i].slice(0, 300) });
209
- }
210
469
  }
211
470
  }
212
471
  }
472
+ const SAVE_WARNINGS_OUTPUT = {
473
+ type: 'array',
474
+ description: 'Present only when the file is a SKILL.md whose `allowed-tools` names platform tools you cannot use: ' +
475
+ 'each `{ entry, message, suggestion? }`. The write still happened.',
476
+ items: { type: 'object' },
477
+ };
478
+ /**
479
+ * The batch form of {@link SAVE_WARNINGS_OUTPUT}. A batch may save several
480
+ * skills at once, so each warning also carries the `path` it is about —
481
+ * without it the caller cannot tell which SKILL.md a warning names.
482
+ */
483
+ const BATCH_SAVE_WARNINGS_OUTPUT = {
484
+ type: 'array',
485
+ description: 'Present only when the batch wrote a SKILL.md whose `allowed-tools` names platform tools you cannot use: ' +
486
+ 'each `{ path, entry, message, suggestion? }`, where `path` is the written file the warning is about. ' +
487
+ 'The writes still happened.',
488
+ items: { type: 'object' },
489
+ };
213
490
  /**
214
491
  * Workspace domain tools: the file primitives (replacing Mastra's auto-injected
215
492
  * Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
@@ -219,7 +496,20 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
219
496
  * method), so they're implemented here. File ops are `both`; `execute_command`
220
497
  * is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
221
498
  */
222
- export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kbDirName, sessionOntologyGate, writePolicy, sessionSink) {
499
+ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kbDirName, sessionOntologyGate, writePolicy, sessionSink,
500
+ /**
501
+ * Save-time skill check (see `AllowedToolsChecker`): a write to a SKILL.md
502
+ * returns `warnings` for `allowed-tools` entries naming no visible tool.
503
+ * Advisory only — it never refuses the write.
504
+ */
505
+ skillSaveCheck,
506
+ /**
507
+ * Read-before-write, for the `write-denied` answer's "may you propose this
508
+ * instead?" — the same verdict the lock applies on the draft the proposal
509
+ * would be made on. Optional so tool harnesses need not wire it; the read
510
+ * verdict alone then decides, which differs only at a root.
511
+ */
512
+ changeGate) {
223
513
  /**
224
514
  * The one extension→reader registry every read-shaped decision routes
225
515
  * through: read_file dispatches on it, grep asks it for searchable text,
@@ -227,6 +517,13 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
227
517
  * around the shared extraction cache.
228
518
  */
229
519
  const readers = createFileReaderRegistry(docExtract);
520
+ /** `{ warnings }` when a saved skill names tools nobody can resolve, else `{}` — spread into a write's result. */
521
+ const saveWarnings = async (ctx, path, content) => {
522
+ if (!skillSaveCheck)
523
+ return {};
524
+ const warnings = await skillSaveCheck.checkSave(ctx.user.email, path, content);
525
+ return warnings.length > 0 ? { warnings } : {};
526
+ };
230
527
  /** Build the per-call read gate from the tool's branch input + caller identity. */
231
528
  const readGateFor = (branch, ctx) => ({
232
529
  accessControl,
@@ -234,14 +531,397 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
234
531
  workspaceId: workspaceIdForBranch(branch),
235
532
  userEmail: ctx.user.email,
236
533
  });
534
+ /** A repo-relative path in the workspace-relative form every tool speaks. */
535
+ const toWs = (rel) => (rel ? `${kbDirName}/${rel}` : kbDirName);
536
+ const sourceToWs = (s) => (s ? { ...s, path: toWs(s.path) } : null);
537
+ /**
538
+ * What `file_stat` adds to its `access` verdicts when asked to explain them:
539
+ * `why` each verdict holds, from the resolver the gates use, and — only for
540
+ * someone who may write the path's access rules, the same gate the Manage
541
+ * access dialog's grant route applies — the roster that dialog lists. A file
542
+ * that cannot carry rules of its own is governed by its folder's
543
+ * `access.md`, so that is the file the gate asks about.
544
+ */
545
+ const explainAccessAt = async (branch, ctx, p, kind) => {
546
+ const norm = p.replace(/^\/+/, '').replace(/\/+$/, '');
547
+ const rel = norm === kbDirName ? '' : toKbRelative(norm, kbDirName);
548
+ if (rel === null) {
549
+ return { why: null, roster: null, rosterReason: `"${p}" is outside the \`${kbDirName}/\` repository, so no access rules apply to it.` };
550
+ }
551
+ if (!accessControl.explainAccess)
552
+ throw new ToolError('Access explanation is not available on this server.', 501);
553
+ const workspaceId = workspaceIdForBranch(branch);
554
+ const email = ctx.user.email;
555
+ const explained = await accessControl.explainAccess(workspaceId, email, kind, rel);
556
+ const why = Object.fromEntries(Object.entries(explained).map(([verb, { source, via, principal }]) => [verb, { source: sourceToWs(source), via, principal }]));
557
+ const rulesPath = kind === 'folder'
558
+ ? accessMdPathForFolder(rel)
559
+ : fileCarriesAccessRules(rel)
560
+ ? rel
561
+ : accessMdPathForFolder(governingFolderOf(rel));
562
+ if (!(await accessControl.canWrite(workspaceId, email, rulesPath))) {
563
+ return {
564
+ why,
565
+ roster: null,
566
+ rosterReason: `You cannot change who has access here (no write on ${toWs(rulesPath)}), so only your own access is shown.`,
567
+ };
568
+ }
569
+ const roster = accessRoster(await resolveAccessView(accessControl, workspaceId, rel, email, kind), kind, rel);
570
+ const rosterWs = Object.fromEntries(Object.entries(roster).map(([verb, entries]) => [
571
+ verb,
572
+ entries.map((e) => ({ ...e, sources: e.sources.map((s) => ({ ...s, path: toWs(s.path) })) })),
573
+ ]));
574
+ return { why, roster: rosterWs };
575
+ };
576
+ /**
577
+ * Refuse a file tool call that names the repository's git folder, in any
578
+ * input, before any gate, lock or read runs (see `shared/git-internals.ts`).
579
+ * Every spelling first, then the resolved form against the branch's
580
+ * workspace, so a link into the folder is refused the same way. The resolved
581
+ * check only runs on a branch that is already cloned: bootstrapping a clone
582
+ * here would happen before the handler's access and ontology gates. A branch
583
+ * not cloned yet (or that does not resolve) is left to the handler; the
584
+ * filesystem refuses again underneath regardless.
585
+ */
586
+ const assertToolPathsNotGitInternals = async (args, ctx) => {
587
+ const paths = [];
588
+ for (const key of ['path', 'src', 'dest', 'destination']) {
589
+ if (typeof args[key] === 'string')
590
+ paths.push(args[key]);
591
+ }
592
+ if (Array.isArray(args.files)) {
593
+ for (const f of args.files) {
594
+ const fp = f && typeof f === 'object' ? f.path : undefined;
595
+ if (typeof fp === 'string')
596
+ paths.push(fp);
597
+ }
598
+ }
599
+ for (const p of paths)
600
+ assertNoGitInternalsSegment(p);
601
+ const onDisk = paths.filter((p) => !spillStore.isSpillRef(p));
602
+ if (onDisk.length === 0 || typeof args.branch !== 'string' || args.branch === '')
603
+ return;
604
+ let fs;
605
+ try {
606
+ if (!(await ctx.workspaceService.hasBootstrappedWorkspace(workspaceIdForBranch(args.branch))))
607
+ return;
608
+ fs = await ctx.getFilesystem(args.branch);
609
+ }
610
+ catch {
611
+ return;
612
+ }
613
+ if (!(fs instanceof GitGuardedFilesystem))
614
+ return;
615
+ for (const p of onDisk)
616
+ await fs.assertNotGitInternals(p);
617
+ };
618
+ // ── preflight for moves and deletes ─────────────────────────────────────
619
+ // What an agent is told before (and instead of) a destructive operation:
620
+ // whether the item is the platform's own, what the caller may do with it,
621
+ // how much a folder takes with it, and — for a move — whether the caller's
622
+ // access changes on the way. The same verdicts gate the execution, so a
623
+ // dry run and the real call never disagree.
624
+ /** The caller's verdicts on a KB path. A path outside the repository carries no rules. */
625
+ const accessAt = async (branch, ctx, path) => {
626
+ const rel = toKbRelative(path, kbDirName);
627
+ if (rel === null)
628
+ return { read: true, write: true, download: true, owner: false };
629
+ const wid = workspaceIdForBranch(branch);
630
+ const email = ctx.user.email;
631
+ const [read, write, download, owner] = await Promise.all([
632
+ accessControl.canRead(wid, email, rel),
633
+ accessControl.canWrite(wid, email, rel),
634
+ accessControl.canDownload(wid, email, rel),
635
+ accessControl.canOwner(wid, email, rel),
636
+ ]);
637
+ return { read, write, download, owner };
638
+ };
639
+ /**
640
+ * The paths among `paths` the caller may NOT write, judged exactly as the
641
+ * lock gate judges them (`WorkflowService.acquireLock`): on a protected
642
+ * branch only, against the access tree at HEAD, with no rules at HEAD
643
+ * meaning allow. Empty on a draft branch — changes there reach a protected
644
+ * branch only through a change request.
645
+ */
646
+ const writeBlocked = async (branch, ctx, paths) => {
647
+ if (!isProtectedBranch(branch))
648
+ return [];
649
+ const byRel = new Map();
650
+ for (const p of paths) {
651
+ const rel = toKbRelative(p, kbDirName);
652
+ if (rel !== null)
653
+ byRel.set(rel, p);
654
+ }
655
+ if (byRel.size === 0)
656
+ return [];
657
+ const verdicts = await accessControl.canWriteBatchAtRef(workspaceIdForBranch(branch), 'HEAD', ctx.user.email, [...byRel.keys()]);
658
+ if (!verdicts)
659
+ return [];
660
+ return [...byRel].filter(([rel]) => verdicts.get(rel) !== true).map(([, p]) => p);
661
+ };
662
+ /**
663
+ * The refusal the lock gate itself would raise for a path this tool's own
664
+ * preflight already found unwritable — same `AccessDeniedError`, same
665
+ * "Eligible: …" reading, from the same `eligibleWritersAtRef`.
666
+ *
667
+ * Raised as that error rather than as a finished body on purpose: every
668
+ * proposable tool's handler is wrapped in ONE mapping
669
+ * (`rethrowAsWriteDenial`), which turns an access refusal into the
670
+ * `write-denied` answer with whether and how to propose instead. Going
671
+ * through it means a refusal the preflight found and a refusal the gate
672
+ * found are the same answer in the same shape, and there is one place that
673
+ * decides what that shape is.
674
+ */
675
+ const writeRefusal = async (branch, path,
676
+ /** What `path` is — a folder the move or delete was judged on, or a file. See `AccessDeniedDetails.targetKind`. */
677
+ targetKind = 'file') => {
678
+ const rel = toKbRelative(path, kbDirName);
679
+ const eligible = rel === null
680
+ ? null
681
+ : await accessControl.eligibleWritersAtRef(workspaceIdForBranch(branch), 'HEAD', rel);
682
+ return new AccessDeniedError({
683
+ path,
684
+ eligibleRoles: eligible?.roles ?? [],
685
+ eligibleUsers: eligible?.users ?? [],
686
+ targetKind,
687
+ });
688
+ };
689
+ /** Whether a workspace-relative path is, or lies inside, a repository's `.git` metadata. */
690
+ const isGitMetadata = (path) => path.split('/').includes('.git');
691
+ /**
692
+ * Why the item at `path` is the platform's own and may not be moved or
693
+ * deleted — a platform file, the root or a reserved root folder, or git
694
+ * metadata — or undefined when it is content. Judged on `path`'s on-disk
695
+ * spelling (see `onDiskSpelling`), so an alternate casing on a
696
+ * case-insensitive disk is judged as the item it opens.
697
+ */
698
+ const managedReason = (path, kind) => {
699
+ const norm = path.replace(/^\.?\/+/, '').replace(/\/+$/, '');
700
+ if (isGitMetadata(norm))
701
+ return `"${norm}" is git metadata and cannot be moved or deleted.`;
702
+ if (kind === 'file') {
703
+ const rel = toKbRelative(norm, kbDirName);
704
+ return rel !== null && isPlatformFile(rel) ? platformFileRefusal(rel) : undefined;
705
+ }
706
+ if (norm === '' || norm === kbDirName)
707
+ return platformFolderRefusal('');
708
+ const rel = toKbRelative(norm, kbDirName);
709
+ return rel !== null && isPlatformFolder(rel) ? platformFolderRefusal(rel) : undefined;
710
+ };
711
+ /** The workspace root on disk for `branch`. */
712
+ const workspaceRoot = (branch, ctx) => ctx.workspaceService.getWorkspacePath(workspaceIdForBranch(branch));
713
+ /**
714
+ * `path` spelled as it is on disk: each segment that is not there verbatim
715
+ * but matches exactly one entry case-insensitively takes that entry's name.
716
+ * On a case-sensitive disk an existing path comes back unchanged; on a
717
+ * case-insensitive one `knowledge-base/skills` comes back as the
718
+ * `knowledge-base/Skills` it opens, so the platform checks see the real item.
719
+ */
720
+ const onDiskSpelling = async (root, path) => {
721
+ const segments = path.replace(/^\.?\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
722
+ const out = [];
723
+ for (const segment of segments) {
724
+ let names;
725
+ try {
726
+ names = await nodeFs.readdir(join(root, ...out));
727
+ }
728
+ catch {
729
+ return [...out, ...segments.slice(out.length)].join('/');
730
+ }
731
+ if (!names.includes(segment)) {
732
+ const matches = names.filter((n) => n.toLowerCase() === segment.toLowerCase());
733
+ out.push(matches.length === 1 ? matches[0] : segment);
734
+ }
735
+ else {
736
+ out.push(segment);
737
+ }
738
+ }
739
+ return out.join('/');
740
+ };
741
+ /**
742
+ * Refuse a path with a `.` or `..` segment or a backslash. Moves and deletes
743
+ * judge the path as written — its access rules, its platform status, its
744
+ * links — so it must be the path of the item that is changed:
745
+ * `knowledge-base/Public/../Locked/x.md` would be judged under `Public/`
746
+ * while removing `Locked/x.md`, or leave the clone altogether.
747
+ */
748
+ const assertPlainPath = (path) => {
749
+ const segments = path.replace(/^\.?\/+/, '').replace(/\/+$/, '').split('/');
750
+ if (path.includes('\\') || segments.some((seg) => seg === '.' || seg === '..')) {
751
+ throw new ToolError(`"${path}" may not contain "." or ".." segments or backslashes; name the item by its own path.`, 400);
752
+ }
753
+ };
754
+ /**
755
+ * The first part of `path` inside the repository that is a symbolic link —
756
+ * the last part too, unless `allowLast` — or undefined. Never follows one.
757
+ * The workspace root and the clone folder itself are the operator's and are
758
+ * not judged.
759
+ */
760
+ const symlinkOnPath = async (root, path, allowLast = false) => {
761
+ const segments = path.replace(/^\.?\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
762
+ if (segments[0] !== kbDirName)
763
+ return undefined;
764
+ const last = allowLast ? segments.length - 1 : segments.length;
765
+ for (let i = 2; i <= last; i++) {
766
+ const prefix = segments.slice(0, i).join('/');
767
+ try {
768
+ if ((await nodeFs.lstat(join(root, prefix))).isSymbolicLink())
769
+ return prefix;
770
+ }
771
+ catch (err) {
772
+ if (isAbsence(err))
773
+ return undefined;
774
+ throw err;
775
+ }
776
+ }
777
+ return undefined;
778
+ };
779
+ /**
780
+ * Refuse `path` when it is not plain (see `assertPlainPath`) or any part of
781
+ * it inside the repository is a symbolic link — the last part too, unless
782
+ * `allowLast` (a link removed on its own is just the link). A link on the way
783
+ * would carry the write somewhere else — beside the clone, where git never
784
+ * sees it, or into another folder whose rules were never consulted — so none
785
+ * is followed.
786
+ */
787
+ const assertNoSymlinkOnPath = async (root, path, allowLast = false) => {
788
+ assertPlainPath(path);
789
+ const link = await symlinkOnPath(root, path, allowLast);
790
+ if (link !== undefined) {
791
+ throw new ToolError(`"${path}" goes through the symbolic link "${link}"; moves and deletes never follow links.`, 400);
792
+ }
793
+ };
794
+ /**
795
+ * What is already at `dest` — `file` or `folder` — or null when the name is
796
+ * free. The kind is what the refusal names, so the sentence says "A folder
797
+ * named …" of a folder.
798
+ *
799
+ * With `src`, the one case that is not a clash is the destination BEING the
800
+ * source — `deal.md` → `Deal.md` on a case-insensitive disk, where the two
801
+ * spellings are one entry. `inspectDestination` decides that, by the same
802
+ * reading the move itself uses, so the preflight and the move cannot answer
803
+ * differently: one inode is not enough (two hard links are one inode under
804
+ * two names a user sees separately), the parent has to list one entry for
805
+ * the two spellings. Without `src` — a copy, which creates a second entry
806
+ * rather than moving the first — anything at `dest` is a clash, the source's
807
+ * own alternate spelling included.
808
+ */
809
+ const existingAt = async (root, dest, src) => {
810
+ if (src !== undefined) {
811
+ const verdict = await inspectDestination(join(root, src), join(root, dest));
812
+ return verdict.state === 'taken' ? verdict.kind : null;
813
+ }
814
+ let destStat;
815
+ try {
816
+ destStat = await nodeFs.lstat(join(root, dest));
817
+ }
818
+ catch (err) {
819
+ if (isAbsence(err))
820
+ return null;
821
+ throw err;
822
+ }
823
+ return destStat.isDirectory() ? 'folder' : 'file';
824
+ };
825
+ /**
826
+ * Run a move or a copy and answer a lost race the way the look before it
827
+ * would have: 409 with the one sentence. The filesystem refuses a taken
828
+ * destination atomically (see `shared/rename-no-replace.ts`), which is what
829
+ * makes the refusal true even when the name is claimed after the look; this
830
+ * only carries that refusal out as a tool error rather than a 500.
831
+ */
832
+ const asEntryExists = async (run) => {
833
+ try {
834
+ return await run();
835
+ }
836
+ catch (err) {
837
+ if (err instanceof DestinationTakenError)
838
+ throw new ToolError(err.message, 409);
839
+ throw err;
840
+ }
841
+ };
842
+ const kindOf = async (fs, path) => {
843
+ try {
844
+ return (await fs.stat(path)).type === 'directory' ? 'folder' : 'file';
845
+ }
846
+ catch (err) {
847
+ if (isAbsence(err) || err.name === 'FileNotFoundError')
848
+ return null;
849
+ throw err;
850
+ }
851
+ };
852
+ /**
853
+ * Every file under `dir`, at any depth, as workspace-relative paths; counts to
854
+ * `cap` (the walk itself goes on, for `links`). A symbolic link is listed as the entry it is, never followed, so the
855
+ * walk cannot wander into a folder elsewhere, and is ALSO named in `links`:
856
+ * the per-file delete cannot remove a link (it stats through it, so a dangling
857
+ * link or a link to a folder fails), so a folder holding one is refused before
858
+ * anything is deleted. The git folder is skipped, in any spelling of its name.
859
+ */
860
+ const filesUnder = async (fs, dir, cap = Infinity) => {
861
+ const files = [];
862
+ const links = [];
863
+ let truncated = false;
864
+ // The cap STOPS the walk, so a caller that passes one does bounded work on
865
+ // a folder of any size. It is the deciding caller's job to make a
866
+ // truncated answer a refusal rather than a guess: `file_stat`, the only
867
+ // capped caller, answers `movable`/`deletable` false once `truncated` is
868
+ // set, which covers the link it may not have reached. The tools that act —
869
+ // `delete_folder`, `move_file` — pass no cap and see every file and every
870
+ // link, because they must judge all of them.
871
+ const walk = async (d) => {
872
+ for (const e of (await fs.readdir(d))) {
873
+ if (truncated)
874
+ return;
875
+ const child = `${d.replace(/\/+$/, '')}/${e.name}`;
876
+ if (hasGitInternalsSegment(e.name))
877
+ continue;
878
+ if (e.type === 'directory' && !e.isSymlink) {
879
+ await walk(child);
880
+ }
881
+ else {
882
+ if (e.isSymlink)
883
+ links.push(child);
884
+ if (files.length < cap)
885
+ files.push(child);
886
+ else {
887
+ truncated = true;
888
+ return;
889
+ }
890
+ }
891
+ }
892
+ };
893
+ await walk(dir);
894
+ return { files, links, truncated };
895
+ };
896
+ /** Whether `path` itself is a symbolic link (never followed). */
897
+ const isSymlinkAt = async (root, path) => {
898
+ try {
899
+ return (await nodeFs.lstat(join(root, path))).isSymbolicLink();
900
+ }
901
+ catch (err) {
902
+ if (isAbsence(err))
903
+ return false;
904
+ throw err;
905
+ }
906
+ };
907
+ /** The refusal for a folder that holds symbolic links: named, with the way out. */
908
+ const linksRefusal = (path, links) => `"${path}" holds ${links.length === 1 ? 'the symbolic link' : `${links.length} symbolic links, e.g.`} "${links[0]}"; ` +
909
+ 'the agent tools never follow or remove links, so the folder cannot be deleted through them. Remove the link outside the agent tools first.';
910
+ /** The refusal for a path that is itself a symbolic link. */
911
+ const linkRefusal = (path) => `"${path}" is a symbolic link; the agent tools never follow or remove links.`;
237
912
  const mount = (spec) => {
238
913
  const path = `/api/agent/tools/${spec.name}`;
914
+ // Every workspace entrypoint carries the agent-guide reminder, every file
915
+ // tool the one content rule, and every tool a permission can refuse the
916
+ // proposal route — appended once here so no tool (especially the
917
+ // read-only ones a session hits first) can miss them.
918
+ const describe = () => (typeof spec.description === 'function' ? spec.description() : spec.description) +
919
+ (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
920
+ (spec.fileTool === false ? '' : CONTENT_RULE) +
921
+ kbConventionsNote();
239
922
  const def = toolDef({
240
923
  name: spec.name,
241
- // Every workspace entrypoint carries the AGENTS.md reminder, appended once
242
- // here so no tool (especially the read-only ones a session hits first) can
243
- // miss it.
244
- description: spec.description + KB_CONVENTIONS_NOTE,
924
+ description: describe(),
245
925
  path,
246
926
  inputs: spec.inputs,
247
927
  outputs: spec.outputs,
@@ -250,10 +930,57 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
250
930
  registry.registerInternalTool(def);
251
931
  if (!spec.internalOnly)
252
932
  registry.registerExternalTool(def);
933
+ // The catalog FOLLOWS the layout. The conventions reminder above names the
934
+ // guide, and several descriptions name it again as a platform file, so the
935
+ // save that completes first-run setup — which applies the names the admin
936
+ // just chose, in that same request, without a restart — must be able to
937
+ // move the text with them. Rewritten in place: the registry holds this
938
+ // object, both surfaces hold the same one, and re-registering would be a
939
+ // duplicate name.
940
+ onKbLayoutApplied(() => {
941
+ def.description = describe();
942
+ });
253
943
  // Internal-only tools (e.g. `execute_command`) keep their route mounted —
254
944
  // our agent calls it over the same loopback — but gate it to internal-source
255
945
  // callers so an external connection key can't invoke it by name.
256
- router.post(path.slice('/api'.length), toolAuth, ...(spec.internalOnly ? [requireInternalSource] : []), toolHandler(spec.handler, { write: spec.write }));
946
+ router.post(path.slice('/api'.length), toolAuth, ...(spec.internalOnly ? [requireInternalSource] : []),
947
+ // EVERY path input becomes a repository path here, once, before any
948
+ // handler runs: the root-anchored `/<kbDirName>/…` form Copy path gives
949
+ // names the same workspace path, and a path with no prefix at all is
950
+ // placed under `<kbDirName>/` instead of being refused.
951
+ //
952
+ // The one exception is `read_file`'s `__tool_chain_spill__/…` ref, which
953
+ // belongs to no workspace and is left exactly as it came — and it is
954
+ // `read_file`'s ALONE. `read_file` is the only tool that consumes a
955
+ // spill ref; for any other, `__tool_chain_spill__/x` is an ordinary
956
+ // path, and exempting it there would be a workspace-relative path that
957
+ // never reached the repository — the whole bug, spelled with a prefix.
958
+ toolHandler(async (args, ctx) => {
959
+ const normalized = normalizePathArgs(args, kbDirName, spec.name === 'read_file' ? (v) => spillStore.isSpillRef(v) : undefined);
960
+ // The checkout's own name is reserved at the repository root on this
961
+ // surface too. The routes meet that rule inside `WorkspaceService`;
962
+ // these tools write through the locking filesystem, which never
963
+ // enters it, so the rule is applied here — on the inputs a tool
964
+ // CREATES at, never on a source, so an existing reserved folder can
965
+ // still be moved out of or deleted.
966
+ const creates = RESERVED_ROOT_NAME_TARGETS[spec.name];
967
+ if (creates)
968
+ assertRepoRootNameFreeArgs(normalized, kbDirName, creates);
969
+ // The git folder is refused before the handler — and so before the
970
+ // write-denial wrapper below, which would otherwise offer to propose
971
+ // a change to it.
972
+ if (spec.fileTool !== false)
973
+ await assertToolPathsNotGitInternals(normalized, ctx);
974
+ if (!spec.proposable)
975
+ return spec.handler(normalized, ctx);
976
+ try {
977
+ // Awaited here so a refusal is caught; proposable tools never stream.
978
+ return await spec.handler(normalized, ctx);
979
+ }
980
+ catch (err) {
981
+ return rethrowAsWriteDenial(err, { tool: spec.name, branch: args.branch, userEmail: ctx.user.email, userId: ctx.user.id }, accessControl, kbDirName, changeGate);
982
+ }
983
+ }, { write: spec.write }));
257
984
  };
258
985
  // ── session bootstrap (external agents) ─────────────────────────────────
259
986
  // Every read/write tool below scopes the ontology-session boundary off a
@@ -272,9 +999,18 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
272
999
  // ontology and then having `ask` write into another. In a core-only
273
1000
  // deployment (no chat/ask) the default sink mints a bare id, which is all
274
1001
  // the ontology gate needs.
1002
+ //
1003
+ // The description tells the caller that retrying is safe, and that is a
1004
+ // property of the sink rather than a promise this route makes on its own:
1005
+ // minting leaves nothing half-made. A call that failed created no session
1006
+ // (the core sink is a random id and does no I/O at all; a chat thread the
1007
+ // enterprise sink failed to create does not exist), and two calls that both
1008
+ // succeed leave two unrelated ids, neither of which invalidates the other.
1009
+ // Saying so matters because the alternative is a caller that reads a
1010
+ // transport hiccup on its first call as an unrecoverable start.
275
1011
  const startSessionDef = toolDef({
276
1012
  name: 'start_session',
277
- description: 'Mint the KnowledgeBase session id this run needs to read or write the knowledge ontologies. Call this ONCE, before any other KnowledgeBase tool, and only once per run — every gated tool needs the `sessionId` it returns to enforce the one-ontology-per-conversation boundary, and minting a new id mid-run resets that boundary. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: reads and ask then share one ontology boundary. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). Returns `{ sessionId }`.',
1013
+ description: 'Mint the KnowledgeBase session id this run needs to read or write the knowledge ontologies. Call this ONCE, before any other KnowledgeBase tool, and only once per run — every gated tool needs the `sessionId` it returns to enforce the one-ontology-per-conversation boundary, and minting a new id mid-run resets that boundary. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: reads and ask then share one ontology boundary. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). RETRYING IS SAFE: a call that fails created nothing, so retry it — there is no half-made session to clean up. If a retry lands after a success you simply hold two independent ids, which is harmless: keep passing the one id you have already used for the rest of the run and ignore the other. Returns `{ sessionId }`.',
278
1014
  path: '/api/agent/tools/start_session',
279
1015
  inputs: { type: 'object', properties: {}, additionalProperties: false },
280
1016
  outputs: {
@@ -307,7 +1043,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
307
1043
  type: 'object',
308
1044
  properties: {
309
1045
  branch: BRANCH_INPUT,
310
- path: str(`Path to read, starting with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), or a \`__tool_chain_spill__/…\` ref from a truncated \`call_tool_chain\`.`),
1046
+ path: str(`Path to read, under \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), with or without a leading slash — a path without that prefix is placed under \`${kbDirName}/\` — or a \`__tool_chain_spill__/…\` ref from a truncated \`call_tool_chain\`.`),
311
1047
  offset: int('Start character index (default 0).'),
312
1048
  limit: int('Max characters to return from `offset`.'),
313
1049
  sessionId: SESSION_ID_INPUT,
@@ -336,7 +1072,8 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
336
1072
  // read is still a KB read. ONE registry dispatch picks the reader by
337
1073
  // extension; everything below just maps its ReadResult onto the tool's
338
1074
  // result shape.
339
- const result = await readers.readerFor(p).read(asBytes(await fs.readFile(p)), p);
1075
+ const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
1076
+ const result = await readers.readerFor(p).read(bytes, p);
340
1077
  // Images return the picture itself as an MCP image content block, so a
341
1078
  // multimodal model SEES it. The handler returns the `McpImageResult`
342
1079
  // sentinel; the MCP result shaping (`toCallToolResult` in
@@ -364,13 +1101,13 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
364
1101
  });
365
1102
  mount({
366
1103
  name: 'list_files',
367
- description: `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path starts with it (e.g. \`${kbDirName}/KnowledgeBase\`).` +
1104
+ description: `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.` +
368
1105
  ONTOLOGY_BOUNDARY_NOTE,
369
1106
  inputs: {
370
1107
  type: 'object',
371
1108
  properties: {
372
1109
  branch: BRANCH_INPUT,
373
- path: str(`Directory to list, starting with \`${kbDirName}/\` (default: the workspace root, where the repository is the \`${kbDirName}/\` folder).`),
1110
+ path: str(`Directory to list, under \`${kbDirName}/\`, with or without a leading slash — a path without that prefix is placed under \`${kbDirName}/\` (default: the workspace root, where the repository is the \`${kbDirName}/\` folder).`),
374
1111
  sessionId: SESSION_ID_INPUT,
375
1112
  },
376
1113
  required: ['branch'],
@@ -397,20 +1134,29 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
397
1134
  const dir = a.path || '';
398
1135
  await recordOntologyRead(sessionOntologyGate, ctx, dir);
399
1136
  const fs = await ctx.getFilesystem(a.branch);
400
- const entries = (await fs.readdir(dir || '.'));
1137
+ const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
401
1138
  const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
402
1139
  return { path: a.path ?? '', entries: filtered };
403
1140
  },
404
1141
  });
405
1142
  mount({
406
1143
  name: 'file_stat',
407
- description: 'Get a file/directory\'s metadata (name, type, size, …) without reading content.' +
1144
+ description: () => 'Get a file/directory\'s metadata (name, type, size, …) without returning content. A file also reports `contentMode`: `text` (read, write and edit it as text), `document` (read returns an extraction; replace it by upload) or `binary` (bytes: copy, move, delete, or replace by upload), plus `kind` (`text` | `document` | `image` | `binary`), `mime`, `mimeSource` and `textEditable` — decided by the same file readers read_file, grep and the write tools use, so an extensionless text file is `text/plain`.' +
1145
+ ' Every entry also reports what you may DO with it. ' +
1146
+ `\`managed\` is true for a platform item — a platform file (${platformFileList()}) or a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`); managed items are never movable or deletable through these tools. ` +
1147
+ '`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to learn why, and who else holds each verb. `movable` and `deletable` say whether `move_file` / `delete_file` / `delete_folder` would be allowed for you, judged like their dry runs: not managed, no symbolic link, and on a protected branch you hold write on the item AND on every file under a folder (on a draft branch writes are not gated). `movable` judges the source side only; the destination is judged by a `move_file` dry run. ' +
1148
+ 'For a folder, `descendants` is the number of files under it at any depth; counting stops at 10000 and `descendantsTruncated` says so, and past that point `movable` and `deletable` are false because a folder that large was not judged in full — run the `move_file` or `delete_folder` dry run for the real verdict. ' +
1149
+ 'Call this before a move or delete to see what it would touch.' +
408
1150
  ONTOLOGY_BOUNDARY_NOTE,
409
1151
  inputs: {
410
1152
  type: 'object',
411
1153
  properties: {
412
1154
  branch: BRANCH_INPUT,
413
1155
  path: wsPath(kbDirName, 'Path'),
1156
+ explainAccess: {
1157
+ type: 'boolean',
1158
+ description: 'Also explain `access` (default false): `access.why` says what decided each of your verdicts — the folder rules or file frontmatter and whether that is inherited; `access.roster` lists who holds each verb and where each grant is written, given only when you can manage this path\'s access (otherwise null, with `access.rosterReason`).',
1159
+ },
414
1160
  sessionId: SESSION_ID_INPUT,
415
1161
  },
416
1162
  required: ['branch', 'path'],
@@ -419,27 +1165,171 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
419
1165
  outputs: {
420
1166
  type: 'object',
421
1167
  description: "The filesystem entry's metadata.",
422
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes.') },
1168
+ properties: {
1169
+ name: str('Entry name.'),
1170
+ type: str('`file` or `directory`.'),
1171
+ size: int('Size in bytes.'),
1172
+ managed: { type: 'boolean', description: 'True for a platform file or platform folder.' },
1173
+ movable: { type: 'boolean', description: 'Whether `move_file` would be allowed for you.' },
1174
+ deletable: { type: 'boolean', description: 'Whether `delete_file` (a file) or `delete_folder` (a folder) would be allowed for you.' },
1175
+ access: {
1176
+ type: 'object',
1177
+ description: 'Your verdict per access verb on this path.',
1178
+ properties: {
1179
+ read: { type: 'boolean', description: 'You may read it.' },
1180
+ write: { type: 'boolean', description: 'You may write it.' },
1181
+ download: { type: 'boolean', description: 'You may download it.' },
1182
+ owner: { type: 'boolean', description: 'You own it.' },
1183
+ why: {
1184
+ type: ['object', 'null'],
1185
+ description: 'Only with `explainAccess: true`. `why.<verb>` is `{ source, via, principal }`: `source` is `{ kind: folder | frontmatter, path, inherited }` (null when no rule decided it — default-deny, admin rescue, a machine-owned file, or Admin\'s write at a root with no rules); `via` is `person`, `group`, `role`, `plugin`, `everyone`, `admin-rescue`, `admin-floor` (Admin always keeps write at the repository root), `machine-owned` or `default-deny`. Null for a path outside the repository.',
1186
+ },
1187
+ roster: {
1188
+ type: ['object', 'null'],
1189
+ description: 'Only with `explainAccess: true`. `roster.<verb>` lists `{ kind: group | role | plugin | person, name, email?, sources }` as the Manage access dialog does; null with `rosterReason` when you cannot manage this path\'s access. Paths start with the repository folder.',
1190
+ },
1191
+ rosterReason: str('Present when `roster` is null: why only your own access is shown.'),
1192
+ },
1193
+ required: ['read', 'write', 'download', 'owner'],
1194
+ },
1195
+ descendants: int('Folders only: files under it at any depth.'),
1196
+ descendantsTruncated: { type: 'boolean', description: 'Folders only: true when counting stopped at the cap.' },
1197
+ contentMode: {
1198
+ type: 'string',
1199
+ enum: ['text', 'document', 'binary'],
1200
+ description: 'Files only: what the file tools can do with the content — `text` (read/write/edit as text), `document` (read extracts; replace by upload), `binary` (bytes: copy/move/delete; replace by upload).',
1201
+ },
1202
+ kind: {
1203
+ type: 'string',
1204
+ enum: ['text', 'document', 'image', 'binary'],
1205
+ description: 'Files only: what the file is, as read_file treats it (archives are `binary`; `mime` names them).',
1206
+ },
1207
+ mime: str('Files only: the MIME type — named by the extension, `text/plain` for text content, else `application/octet-stream`.'),
1208
+ mimeSource: {
1209
+ type: 'string',
1210
+ enum: ['extension', 'sniff', 'fallback'],
1211
+ description: 'Files only: where `mime` came from. `fallback` means no type was detected.',
1212
+ },
1213
+ textEditable: { type: 'boolean', description: 'Files only: whether write_file/write_files/edit_file accept this file as it is now.' },
1214
+ mimeNote: str('Present when `mimeSource` is `fallback`: says the MIME type is a fallback, not a detected type.'),
1215
+ },
1216
+ required: ['managed', 'movable', 'deletable', 'access'],
423
1217
  additionalProperties: true,
424
1218
  },
425
1219
  write: false,
426
1220
  handler: async (a, ctx) => {
427
1221
  const p = a.path;
1222
+ const branch = a.branch;
428
1223
  await recordOntologyRead(sessionOntologyGate, ctx, p);
429
- await assertCanRead(readGateFor(a.branch, ctx), p);
430
- return (await ctx.getFilesystem(a.branch)).stat(p);
1224
+ await assertCanRead(readGateFor(branch, ctx), p);
1225
+ // Nothing there is a 404, and the placeholder — never content — gets
1226
+ // exactly that answer: the one every file tool gives (see not-found.ts).
1227
+ if (isFolderPlaceholder(p))
1228
+ throw notFound(p);
1229
+ const fs = await ctx.getFilesystem(branch);
1230
+ const root = await workspaceRoot(branch, ctx);
1231
+ // Judged before `stat`, which follows links: a link anywhere on the path
1232
+ // (or a path move_file and delete_file would refuse as not plain) is
1233
+ // never movable or deletable, and a dangling one is named, not a 404.
1234
+ const segments = p.replace(/^\.?\/+/, '').replace(/\/+$/, '').split('/');
1235
+ const plain = !p.includes('\\') && !segments.some((seg) => seg === '.' || seg === '..');
1236
+ const viaLink = plain ? await symlinkOnPath(root, p) : undefined;
1237
+ let stat;
1238
+ try {
1239
+ stat = await fs.stat(p);
1240
+ }
1241
+ catch (err) {
1242
+ if (viaLink !== undefined) {
1243
+ throw new ToolError(`"${p}" goes through the symbolic link "${viaLink}", which leads nowhere; the agent tools never follow links.`, 400);
1244
+ }
1245
+ if (isAbsence(err))
1246
+ throw notFound(p);
1247
+ throw err;
1248
+ }
1249
+ // The filesystem's own `mimeType` comes from a second extension table
1250
+ // (octet-stream for an extensionless text file) and would contradict
1251
+ // `mime` below, so it is never passed through.
1252
+ delete stat.mimeType;
1253
+ const kind = stat.type === 'directory' ? 'folder' : 'file';
1254
+ const managed = managedReason(await onDiskSpelling(root, p), kind) !== undefined;
1255
+ const verdicts = await accessAt(branch, ctx, p);
1256
+ const access = a.explainAccess === true ? { ...verdicts, ...(await explainAccessAt(branch, ctx, p, kind)) } : verdicts;
1257
+ const link = !plain || viaLink !== undefined;
1258
+ // Judged on the same paths move_file and delete_folder judge: a folder
1259
+ // move or delete touches every file under it, so a file its own rules
1260
+ // deny you makes the folder neither movable nor deletable, however
1261
+ // writable the folder is.
1262
+ //
1263
+ // Two bounds, because this is a READ tool the description tells agents
1264
+ // to call before every move and delete, and the folder it is asked about
1265
+ // may be the repository root:
1266
+ // - a platform item or a path through a link is already not movable
1267
+ // and not deletable, so no access verdict is asked for any file
1268
+ // under it (the count below is a plain directory walk);
1269
+ // - the walk stops at the cap, and a truncated walk answers
1270
+ // `movable`/`deletable` false rather than judging part of a folder
1271
+ // and calling it the whole (`delete_folder`'s dry run, which walks
1272
+ // uncapped, remains the authority for a folder that large).
1273
+ const decided = managed || link;
1274
+ const { files, links, truncated } = kind === 'folder'
1275
+ ? await filesUnder(fs, p, DESCENDANTS_CAP)
1276
+ : { files: [p], links: [], truncated: false };
1277
+ const judged = kind === 'folder' ? [p, ...files] : [p];
1278
+ const writable = decided ? false : (await writeBlocked(branch, ctx, judged)).length === 0;
1279
+ // A restricted run (see IRoutineWritePolicy) is refused per file by both tools.
1280
+ const policyAllows = !decided &&
1281
+ files.every((file) => {
1282
+ try {
1283
+ writePolicy.assertPathWritable(ctx.sessionId, file);
1284
+ return true;
1285
+ }
1286
+ catch {
1287
+ return false;
1288
+ }
1289
+ });
1290
+ const open = !decided && !truncated && writable && policyAllows;
1291
+ const out = {
1292
+ ...stat,
1293
+ managed,
1294
+ movable: open,
1295
+ // delete_folder also refuses a folder holding a link.
1296
+ deletable: open && links.length === 0,
1297
+ access,
1298
+ };
1299
+ if (kind === 'folder') {
1300
+ // The placeholder is never content: a folder holding only it has none.
1301
+ out.descendants = files.filter((f) => !isFolderPlaceholder(f)).length;
1302
+ if (truncated)
1303
+ out.descendantsTruncated = true;
1304
+ return out;
1305
+ }
1306
+ // A FILE also reports what the file tools can do with its content. The
1307
+ // mode is decided by the same registry the write gates consult, so what
1308
+ // stat reports is what write_file will do. Only a reader whose answer
1309
+ // depends on the bytes (the text fallback) costs a read — one full read,
1310
+ // the same one write_file/edit_file already pay on the same file. A
1311
+ // head-only sniff would be cheaper but wrong: invalid UTF-8 or a NUL
1312
+ // anywhere makes the write gate refuse, so stat must judge the same
1313
+ // bytes or it would report `text` for a file the write then refuses.
1314
+ // `kind` and `mime` come from that same reader too, so stat never calls
1315
+ // a file binary that read_file returns as text.
1316
+ const reader = readers.readerFor(p);
1317
+ const bytes = needsContent(reader)
1318
+ ? await orNotFound(p, async () => asBytes(await fs.readFile(p)))
1319
+ : undefined;
1320
+ return { ...out, ...fileTypeOf(reader, p, bytes) };
431
1321
  },
432
1322
  });
433
1323
  mount({
434
1324
  name: 'grep',
435
- description: 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).' +
1325
+ description: 'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).' +
436
1326
  ONTOLOGY_BOUNDARY_NOTE,
437
1327
  inputs: {
438
1328
  type: 'object',
439
1329
  properties: {
440
1330
  branch: BRANCH_INPUT,
441
1331
  pattern: str('JavaScript regular expression.'),
442
- path: str('Subtree to search (default: whole workspace).'),
1332
+ path: str(`Subtree to search, or a single file to search on its own, with or without a leading slash — a path without the \`${kbDirName}/\` prefix is placed under \`${kbDirName}/\` (default: the whole repository, \`${kbDirName}/\`).`),
443
1333
  ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
444
1334
  max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
445
1335
  sessionId: SESSION_ID_INPUT,
@@ -460,7 +1350,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
460
1350
  },
461
1351
  },
462
1352
  truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
463
- note: str('Present when some documents (office/PDF/email files) were not searched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover them.'),
1353
+ note: str('Present when the empty/partial result needs explaining: a `path` naming a file with no searchable text (image, binary, corrupt document), or documents (office/PDF/email files) left unsearched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover those.'),
464
1354
  },
465
1355
  required: ['matches', 'truncated'],
466
1356
  },
@@ -473,33 +1363,82 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
473
1363
  catch (err) {
474
1364
  throw new ToolError(`Invalid regex: ${err.message}`, 400);
475
1365
  }
476
- const searchRoot = typeof a.path === 'string' ? a.path : '';
1366
+ // No path means the whole REPOSITORY, not the workspace directory above
1367
+ // it. The workspace root also holds whatever an older build left beside
1368
+ // the checkout, and the read gate has no rules for a path outside the
1369
+ // repository — it answers "readable" — so a walk from there would hand
1370
+ // any caller the contents of every stray, including documents that were
1371
+ // uploaded to a restricted folder and landed beside it instead. The
1372
+ // boot note names those for an operator; no tool reads them.
1373
+ // An EMPTY string is the same absence: the normaliser leaves it alone
1374
+ // (an empty path is the handler's to explain), and here it would
1375
+ // otherwise name the workspace directory by another spelling.
1376
+ const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
477
1377
  // The search root itself is checked here (fail-closed for an agent grep on
478
1378
  // a named subtree with no sessionId); each file the walk actually opens is
479
1379
  // recorded per-file below, so a root-level grep that reaches into multiple
480
1380
  // ontologies still records each one (and can poison later writes).
481
1381
  await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
1382
+ const fs = await ctx.getFilesystem(a.branch);
1383
+ const gate = readGateFor(a.branch, ctx);
482
1384
  const out = [];
483
1385
  const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
484
1386
  const docs = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
485
- await grepWalk(await ctx.getFilesystem(a.branch), searchRoot, re, out, max, 0, readGateFor(a.branch, ctx), (p) => recordOntologyRead(sessionOntologyGate, ctx, p), docs);
1387
+ // The empty root is the workspace itself — always a directory, and never
1388
+ // worth a stat.
1389
+ // A placeholder named on its own is searched as what it is to every
1390
+ // other tool: nothing.
1391
+ const kind = searchRoot === ''
1392
+ ? 'directory'
1393
+ : isFolderPlaceholder(searchRoot)
1394
+ ? 'missing'
1395
+ : await searchRootKind(fs, searchRoot);
1396
+ /** Why a single-file search found nothing, when "no matches" would be a lie. */
1397
+ let fileNote;
1398
+ if (kind === 'directory') {
1399
+ await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => recordOntologyRead(sessionOntologyGate, ctx, p), docs);
1400
+ }
1401
+ else {
1402
+ // Not a directory: the permission verdict comes BEFORE every other
1403
+ // one, and it is `read_file`'s own gate on the same path — so grep
1404
+ // answers a path the caller may not read exactly as read_file does,
1405
+ // and can never confirm the existence of one read_file would hide.
1406
+ // That ordering holds even when the stat itself failed: a denied path
1407
+ // gets the 403, never the filesystem's complaint about it.
1408
+ await assertCanRead(gate, searchRoot);
1409
+ if (kind === 'missing')
1410
+ throw notFound(searchRoot, 'Nothing to search');
1411
+ // A named FILE is searched directly: routing it through the walk would
1412
+ // fail its readdir and answer an empty match list, which the caller
1413
+ // cannot tell from "the pattern is not in this file".
1414
+ const outcome = await orNotFound(searchRoot, () => grepOneFile(fs, searchRoot, re, out, max, docs), 'Nothing to search');
1415
+ if (outcome === 'no-text') {
1416
+ fileNote =
1417
+ `"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
1418
+ 'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
1419
+ 'because the pattern is absent.';
1420
+ }
1421
+ }
1422
+ const note = fileNote ??
1423
+ (docs.skippedUncached > 0
1424
+ ? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
1425
+ `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
1426
+ 'same grep to extract and search the next batch.'
1427
+ : undefined);
486
1428
  return {
487
1429
  matches: out,
488
1430
  truncated: out.length >= max,
489
- ...(docs.skippedUncached > 0
490
- ? {
491
- note: `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
492
- `extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
493
- 'same grep to extract and search the next batch.',
494
- }
495
- : {}),
1431
+ ...(note !== undefined ? { note } : {}),
496
1432
  };
497
1433
  },
498
1434
  });
499
1435
  // ── writes (through the lock/commit pipeline) ───────────────────────────
500
1436
  mount({
501
1437
  name: 'write_file',
502
- description: 'Write (create or overwrite) a workspace file. The change is committed + pushed as you. Returns `{ path, bytes }`. Refuses document formats: Office/OpenDocument files and PDFs (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf) and email files (.eml/.msg), whose reads are text EXTRACTIONS that cannot round-trip, and legacy binary Office files (.doc/.ppt/.xls), which cannot be extracted at all; replace such a file by uploading a new version instead.' +
1438
+ description: 'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
1439
+ '`created`, `replaced` or `updated`.' +
1440
+ WRITE_MODE_NOTE +
1441
+ IMAGE_CONVENTION_NOTE +
503
1442
  ONTOLOGY_BOUNDARY_NOTE,
504
1443
  inputs: {
505
1444
  type: 'object',
@@ -507,6 +1446,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
507
1446
  branch: BRANCH_INPUT,
508
1447
  path: wsPath(kbDirName, 'Path'),
509
1448
  content: str('Full file content.'),
1449
+ mode: WRITE_MODE_INPUT,
510
1450
  sessionId: SESSION_ID_INPUT,
511
1451
  },
512
1452
  required: ['branch', 'path', 'content'],
@@ -514,10 +1454,20 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
514
1454
  },
515
1455
  outputs: {
516
1456
  type: 'object',
517
- properties: { path: str('The path written (echoes the input).'), bytes: int('Number of bytes written.') },
518
- required: ['path', 'bytes'],
1457
+ properties: {
1458
+ path: str('The path written (echoes the input).'),
1459
+ bytes: int('Number of bytes written.'),
1460
+ outcome: {
1461
+ type: 'string',
1462
+ enum: ['created', 'replaced', 'updated'],
1463
+ description: 'What the write did: `created` (nothing was there), `replaced` (`mode: overwrite` over an existing file), `updated` (`mode: update`).',
1464
+ },
1465
+ warnings: SAVE_WARNINGS_OUTPUT,
1466
+ },
1467
+ required: ['path', 'bytes', 'outcome'],
519
1468
  },
520
1469
  write: true,
1470
+ proposable: true,
521
1471
  handler: async (a, ctx) => {
522
1472
  assertNotDocumentEdit(readers, a.path);
523
1473
  // NB: this is a no-op for chat + `ontology_ingest` — it only bites when a
@@ -526,20 +1476,48 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
526
1476
  // through (see `assertPathWritable`), so it does not limit other agents.
527
1477
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
528
1478
  await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
1479
+ const mode = modeOf(a);
529
1480
  const fs = await ctx.getFilesystem(a.branch);
530
1481
  await assertNotBinaryOverwrite(readers, a.path, fs);
531
- await fs.writeFile(a.path, a.content);
532
- return { path: a.path, bytes: Buffer.byteLength(a.content, 'utf8') };
1482
+ // The mode is judged AFTER the content gates, so a file the tools may
1483
+ // not write as text is still answered with the capability refusal that
1484
+ // names the tool to use instead — not with "it already exists".
1485
+ const decide = async () => decideWrite(mode, a.path, (await kindOf(fs, a.path)) !== null);
1486
+ // Judged twice, on purpose. This first verdict is the cheap one, taken
1487
+ // before any lock so an ordinary refusal never contends for one — but it
1488
+ // is a verdict about a path anyone may still change. The one the answer
1489
+ // carries is taken again inside `writeFile`, with the path's lock HELD
1490
+ // (`write: true` guarantees the locking filesystem, as the batch cast
1491
+ // below does): only there can `create` be sure it is not about to
1492
+ // replace a file a human editor saved a moment ago, and `update` sure it
1493
+ // is not recreating one somebody just deleted. A filesystem without the
1494
+ // hook runs no second verdict, so the preflight one stands.
1495
+ const preflight = await decide();
1496
+ let locked = null;
1497
+ const locking = fs;
1498
+ await locking.writeFile(a.path, a.content, undefined, async () => {
1499
+ locked = await decide();
1500
+ });
1501
+ return {
1502
+ path: a.path,
1503
+ bytes: Buffer.byteLength(a.content, 'utf8'),
1504
+ outcome: (locked ?? preflight),
1505
+ ...(await saveWarnings(ctx, a.path, a.content)),
1506
+ };
533
1507
  },
534
1508
  });
535
1509
  mount({
536
1510
  name: 'write_files',
537
1511
  description: 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
538
- 'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`; all ' +
539
- 'are created/overwritten and committed + pushed together as you. Prefer this over many write_file ' +
540
- 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Refuses document formats: ' +
541
- 'Office/OpenDocument files and PDFs (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf) and email files (.eml/.msg), whose reads are text EXTRACTIONS that cannot ' +
542
- 'round-trip, and legacy binary Office files (.doc/.ppt/.xls); replace such a file by uploading a new version instead. Returns `{ count }`.' +
1512
+ 'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
1513
+ 'writes are committed + pushed together as you. Prefer this over many write_file ' +
1514
+ 'calls. All files must be in the SAME ontology (the boundary below applies to the batch). Text files only. ' +
1515
+ 'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
1516
+ '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
1517
+ 'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
1518
+ 'does not stop the others; read `files` to see what landed.' +
1519
+ WRITE_MODE_NOTE +
1520
+ IMAGE_CONVENTION_NOTE +
543
1521
  ONTOLOGY_BOUNDARY_NOTE,
544
1522
  inputs: {
545
1523
  type: 'object',
@@ -547,7 +1525,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
547
1525
  branch: BRANCH_INPUT,
548
1526
  files: {
549
1527
  type: 'array',
550
- description: 'Files to write; each created or overwritten.',
1528
+ description: 'Files to write; `mode` decides what each one may do at its path.',
551
1529
  items: {
552
1530
  type: 'object',
553
1531
  properties: { path: wsPath(kbDirName, 'Path'), content: str('Full file content.') },
@@ -555,6 +1533,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
555
1533
  additionalProperties: false,
556
1534
  },
557
1535
  },
1536
+ mode: WRITE_MODE_INPUT,
558
1537
  sessionId: SESSION_ID_INPUT,
559
1538
  },
560
1539
  required: ['branch', 'files'],
@@ -562,34 +1541,134 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
562
1541
  },
563
1542
  outputs: {
564
1543
  type: 'object',
565
- properties: { count: int('Number of files written.') },
566
- required: ['count'],
1544
+ properties: {
1545
+ count: int('Number of files written — the entries in `files` whose `outcome` is not `refused`.'),
1546
+ files: {
1547
+ type: 'array',
1548
+ description: 'One entry per REQUESTED path, in input order.',
1549
+ items: {
1550
+ type: 'object',
1551
+ properties: {
1552
+ path: str('The requested path (echoes the input).'),
1553
+ outcome: {
1554
+ type: 'string',
1555
+ enum: ['created', 'replaced', 'updated', 'refused'],
1556
+ description: 'What happened at this path. `refused` means nothing was written there and the file is untouched.',
1557
+ },
1558
+ error: str('Present when `outcome` is `refused`: the refusal code — `exists`, `missing` or `binary_not_writable`.'),
1559
+ message: str('Present when `outcome` is `refused`: the full refusal, the same one write_file would have given.'),
1560
+ },
1561
+ required: ['path', 'outcome'],
1562
+ },
1563
+ },
1564
+ warnings: BATCH_SAVE_WARNINGS_OUTPUT,
1565
+ },
1566
+ required: ['count', 'files'],
567
1567
  },
568
1568
  write: true,
1569
+ proposable: true,
569
1570
  handler: async (a, ctx) => {
570
1571
  const files = a.files ?? [];
571
1572
  if (files.length === 0)
572
- return { count: 0 };
573
- // Gate every path first (records ontology touches; a cross-ontology batch
574
- // is blocked exactly like the per-file write tools).
575
- for (const f of files)
576
- assertNotDocumentEdit(readers, f.path);
1573
+ return { count: 0, files: [] };
1574
+ const mode = modeOf(a);
1575
+ // The POLICY gates still judge the whole batch: a restricted run or a
1576
+ // cross-ontology batch is a call that should not have been made at all,
1577
+ // not a per-path outcome, and the ontology gate must see every path
1578
+ // before anything lands. What a single FILE is (not text) or what its
1579
+ // path already holds (the mode) is decided per path, below.
577
1580
  for (const f of files)
578
1581
  writePolicy.assertPathWritable(ctx.sessionId, f.path);
579
1582
  for (const f of files)
580
1583
  await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
1584
+ const fs = await ctx.getFilesystem(a.branch);
581
1585
  // `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
582
- // whole batch as one commit. Structural cast avoids a workflow-internal import.
583
- const fs = (await ctx.getFilesystem(a.branch));
584
- for (const f of files)
585
- await assertNotBinaryOverwrite(readers, f.path, fs);
586
- await fs.writeFiles(files.map((f) => ({ path: f.path, content: f.content })), `Write ${files.length} file(s)`);
587
- return { count: files.length };
1586
+ // batch as one commit. Structural cast avoids a workflow-internal import.
1587
+ const batching = fs;
1588
+ const writes = [];
1589
+ const outcomes = [];
1590
+ /** The `files` entry for `writes[i]`, so the re-judgement can revise it. */
1591
+ const entryOf = [];
1592
+ /** Record on `entry` that this path was refused, as write_file would say it. */
1593
+ const refuse = (entry, err) => {
1594
+ if (!(err instanceof ToolError))
1595
+ throw err;
1596
+ const details = (err.details ?? {});
1597
+ entry.outcome = 'refused';
1598
+ entry.error = details.code ?? details.kind ?? 'refused';
1599
+ entry.message = err.message;
1600
+ };
1601
+ for (const f of files) {
1602
+ const entry = { path: f.path };
1603
+ outcomes.push(entry);
1604
+ try {
1605
+ assertNotDocumentEdit(readers, f.path);
1606
+ await assertNotBinaryOverwrite(readers, f.path, fs);
1607
+ // An earlier entry in this same batch counts as existing: two `create`
1608
+ // entries for one path are a mistake the commit would otherwise hide.
1609
+ const exists = writes.some((w) => w.path === f.path) || (await kindOf(fs, f.path)) !== null;
1610
+ entry.outcome = decideWrite(mode, f.path, exists);
1611
+ writes.push({ path: f.path, content: f.content });
1612
+ entryOf.push(entry);
1613
+ }
1614
+ catch (err) {
1615
+ refuse(entry, err);
1616
+ }
1617
+ }
1618
+ // The same mode gate again, run by `writeFiles` once EVERY path's lock is
1619
+ // held — the verdict the answer carries, for the reason write_file states
1620
+ // above. `pending` is this batch's writes in the order they were handed
1621
+ // over, so `pending[i]` is `writes[i]` and `entryOf[i]` is its `files`
1622
+ // entry. A path whose verdict changed under the lock is dropped from the
1623
+ // batch and reported refused, leaving the rest of the batch to land.
1624
+ const recheck = async (pending) => {
1625
+ const kept = [];
1626
+ for (let i = 0; i < pending.length; i++) {
1627
+ const entry = entryOf[i];
1628
+ try {
1629
+ const exists = kept.some((k) => k.path === pending[i].path) || (await kindOf(fs, pending[i].path)) !== null;
1630
+ entry.outcome = decideWrite(mode, pending[i].path, exists);
1631
+ kept.push(pending[i]);
1632
+ }
1633
+ catch (err) {
1634
+ refuse(entry, err);
1635
+ }
1636
+ }
1637
+ return kept;
1638
+ };
1639
+ if (writes.length > 0) {
1640
+ await batching.writeFiles(writes, `Write ${writes.length} file(s)`, [], recheck);
1641
+ }
1642
+ // Only the content that actually landed is checked: a refused entry wrote
1643
+ // nothing, and a path the batch names twice (which `overwrite` allows) is
1644
+ // judged by its LAST landed entry — the earlier one is not in the branch,
1645
+ // so warning about it would describe text nobody can find. `outcomes[i]`
1646
+ // is the entry for `files[i]`. One catalog read for the whole batch.
1647
+ const landed = [];
1648
+ for (let i = 0; i < files.length; i++) {
1649
+ if (outcomes[i].outcome === 'refused')
1650
+ continue;
1651
+ if (files.some((f, j) => j > i && f.path === files[i].path && outcomes[j].outcome !== 'refused'))
1652
+ continue;
1653
+ landed.push(i);
1654
+ }
1655
+ const warnings = [];
1656
+ if (skillSaveCheck && landed.length > 0) {
1657
+ const perFile = await skillSaveCheck.checkSaves(ctx.user.email, landed.map((i) => files[i]));
1658
+ landed.forEach((i, k) => {
1659
+ warnings.push(...(perFile[k] ?? []).map((w) => ({ path: files[i].path, ...w })));
1660
+ });
1661
+ }
1662
+ return {
1663
+ count: outcomes.filter((o) => o.outcome !== 'refused').length,
1664
+ files: outcomes,
1665
+ ...(warnings.length > 0 ? { warnings } : {}),
1666
+ };
588
1667
  },
589
1668
  });
590
1669
  mount({
591
1670
  name: 'edit_file',
592
- description: 'Replace an exact string in a workspace file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you. Refuses document formats: Office/OpenDocument files and PDFs (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf) and email files (.eml/.msg), whose reads are text EXTRACTIONS that cannot round-trip, and legacy binary Office files (.doc/.ppt/.xls), which cannot be extracted at all; replace such a file by uploading a new version instead.' +
1671
+ description: 'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
593
1672
  ONTOLOGY_BOUNDARY_NOTE,
594
1673
  inputs: {
595
1674
  type: 'object',
@@ -606,10 +1685,11 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
606
1685
  },
607
1686
  outputs: {
608
1687
  type: 'object',
609
- properties: { path: str('The path edited (echoes the input).'), replaced: int('Number of occurrences replaced.') },
1688
+ properties: { path: str('The path edited (echoes the input).'), replaced: int('Number of occurrences replaced.'), warnings: SAVE_WARNINGS_OUTPUT },
610
1689
  required: ['path', 'replaced'],
611
1690
  },
612
1691
  write: true,
1692
+ proposable: true,
613
1693
  handler: async (a, ctx) => {
614
1694
  assertNotDocumentEdit(readers, a.path);
615
1695
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
@@ -620,8 +1700,10 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
620
1700
  const newStr = a.new_string;
621
1701
  // The overwrite gate already read the file when its reader asked the
622
1702
  // binary question — reuse those bytes instead of reading twice.
623
- const existing = await assertNotBinaryOverwrite(readers, path, fs);
624
- const content = asText(existing ?? (await fs.readFile(path)));
1703
+ const content = await orNotFound(path, async () => {
1704
+ const existing = await assertNotBinaryOverwrite(readers, path, fs);
1705
+ return asText(existing ?? (await fs.readFile(path)));
1706
+ });
625
1707
  const count = oldStr ? content.split(oldStr).length - 1 : 0;
626
1708
  if (count === 0)
627
1709
  throw new ToolError('old_string not found in the file.', 400);
@@ -630,17 +1712,19 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
630
1712
  }
631
1713
  const updated = a.replace_all === true ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
632
1714
  await fs.writeFile(path, updated);
633
- return { path, replaced: a.replace_all === true ? count : 1 };
1715
+ return { path, replaced: a.replace_all === true ? count : 1, ...(await saveWarnings(ctx, path, updated)) };
634
1716
  },
635
1717
  });
636
1718
  mount({
637
1719
  name: 'delete_file',
638
- description: 'Delete a workspace file. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
1720
+ description: () => 'Delete ONE workspace file (a symbolic link is refused: links are never followed or removed). Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`. ' +
1721
+ `A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${AGENTS_FILE}\` at the repository root) and git metadata are refused.` +
1722
+ ONTOLOGY_BOUNDARY_NOTE,
639
1723
  inputs: {
640
1724
  type: 'object',
641
1725
  properties: {
642
1726
  branch: BRANCH_INPUT,
643
- path: wsPath(kbDirName, 'Path to the file', false),
1727
+ path: wsPath(kbDirName, 'Path to the file'),
644
1728
  sessionId: SESSION_ID_INPUT,
645
1729
  },
646
1730
  required: ['branch', 'path'],
@@ -652,20 +1736,179 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
652
1736
  required: ['path', 'deleted'],
653
1737
  },
654
1738
  write: true,
1739
+ proposable: true,
655
1740
  handler: async (a, ctx) => {
656
1741
  // A delete propagates no cross-ontology information (it removes a node, it
657
1742
  // doesn't carry bytes from elsewhere), so it is NOT ontology-write-gated — it
658
1743
  // only records the ontology it touched, like a read. The extension policy
659
1744
  // DOES apply though: a dashboard-only run must not delete graph `.md` nodes.
660
- writePolicy.assertPathWritable(ctx.sessionId, a.path);
661
- await recordOntologyRead(sessionOntologyGate, ctx, a.path);
662
- await (await ctx.getFilesystem(a.branch)).deleteFile(a.path);
663
- return { path: a.path, deleted: true };
1745
+ const path = a.path;
1746
+ const branch = a.branch;
1747
+ writePolicy.assertPathWritable(ctx.sessionId, path);
1748
+ await recordOntologyRead(sessionOntologyGate, ctx, path);
1749
+ const fs = await ctx.getFilesystem(branch);
1750
+ assertPlainPath(path);
1751
+ const root = await workspaceRoot(branch, ctx);
1752
+ if (await isSymlinkAt(root, path))
1753
+ throw new ToolError(linkRefusal(path), 400);
1754
+ if ((await kindOf(fs, path)) === 'folder') {
1755
+ throw new ToolError(`"${path}" is a folder, not a file — use delete_folder to delete it and the files under it.`, 400);
1756
+ }
1757
+ const onDisk = await onDiskSpelling(root, path);
1758
+ if (isGitMetadata(onDisk))
1759
+ throw new ToolError(managedReason(onDisk, 'file'), 400);
1760
+ if (managedReason(onDisk, 'file') !== undefined) {
1761
+ throw new ToolError(`${onDisk.slice(onDisk.lastIndexOf('/') + 1)} is a platform file and cannot be deleted through the agent tools.`, 400);
1762
+ }
1763
+ await assertNoSymlinkOnPath(root, path, true);
1764
+ if ((await writeBlocked(branch, ctx, [path])).length > 0)
1765
+ throw await writeRefusal(branch, path);
1766
+ await orNotFound(path, () => fs.deleteFile(path), 'Nothing to delete');
1767
+ // Deleting content is not deleting structure: an emptied folder stays.
1768
+ await keepFolderOf(fs, ctx, branch, path, kbDirName);
1769
+ return { path, deleted: true };
1770
+ },
1771
+ });
1772
+ mount({
1773
+ name: 'delete_folder',
1774
+ description: 'Delete a workspace FOLDER and every file under it, at any depth; the whole folder lands as ONE committed + pushed change as you — all of it or none of it — then the empty folder is removed. This is the one way a folder goes away: the folder that held it stays, even if this was all it had, and a folder holding nothing but its empty-folder placeholder counts as empty. ' +
1775
+ 'Preflight first: `dryRun: true` changes nothing and answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is the file count, `files` names up to 100 of them. ' +
1776
+ 'A non-empty folder is deleted only with `confirm: true`; without it the call deletes nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm. ' +
1777
+ 'Refused (in a dry run as `allowed: false` with the `reason`): a platform folder (the repository root or a reserved root folder such as `KnowledgeBase/`), git metadata, a folder holding a symbolic link (links are never removed), and a folder holding any file you may not write. A path that is a file is refused with a pointer to `delete_file`, and a path through a symbolic link is refused (links are never followed). ' +
1778
+ 'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.' +
1779
+ ONTOLOGY_BOUNDARY_NOTE,
1780
+ inputs: {
1781
+ type: 'object',
1782
+ properties: {
1783
+ branch: BRANCH_INPUT,
1784
+ path: wsPath(kbDirName, 'Folder to delete'),
1785
+ dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
1786
+ confirm: { type: 'boolean', description: 'Required to delete a non-empty folder. Set it only after a dry run.' },
1787
+ sessionId: SESSION_ID_INPUT,
1788
+ },
1789
+ required: ['branch', 'path'],
1790
+ additionalProperties: false,
1791
+ },
1792
+ outputs: {
1793
+ type: 'object',
1794
+ properties: {
1795
+ path: str('The folder (echoes the input).'),
1796
+ kind: str('Always `folder`.'),
1797
+ descendants: int('Files under the folder at any depth.'),
1798
+ files: { type: 'array', items: { type: 'string' }, description: 'Up to 100 of those files.' },
1799
+ filesTruncated: { type: 'boolean', description: 'True when `files` does not name every file.' },
1800
+ allowed: { type: 'boolean', description: 'Whether the delete may run.' },
1801
+ reason: str('Why it may not, when `allowed` is false.'),
1802
+ dryRun: { type: 'boolean', description: 'True on a dry run.' },
1803
+ confirmationRequired: { type: 'boolean', description: 'True when the call stopped for want of `confirm: true`.' },
1804
+ message: str('One sentence on what happened (or did not).'),
1805
+ deleted: { type: 'boolean', description: 'True once the folder is gone.' },
1806
+ },
1807
+ required: ['path', 'kind', 'descendants', 'allowed'],
1808
+ },
1809
+ write: true,
1810
+ proposable: true,
1811
+ handler: async (a, ctx) => {
1812
+ const path = a.path.replace(/\/+$/, '');
1813
+ const branch = a.branch;
1814
+ // The normaliser has already placed the path inside the repository; this
1815
+ // is the check that it really is in there before a folder is walked.
1816
+ assertInsideRepo(path, kbDirName);
1817
+ await recordOntologyRead(sessionOntologyGate, ctx, path);
1818
+ const fs = await ctx.getFilesystem(branch);
1819
+ const kind = await kindOf(fs, path);
1820
+ if (kind === null)
1821
+ throw new ToolError(`"${path}" does not exist.`, 404);
1822
+ if (kind === 'file') {
1823
+ throw new ToolError(`"${path}" is a file, not a folder — use delete_file to delete it.`, 400);
1824
+ }
1825
+ const root = await workspaceRoot(branch, ctx);
1826
+ await assertNoSymlinkOnPath(root, path);
1827
+ /**
1828
+ * Everything the delete is judged on. `files` is every file that goes,
1829
+ * the empty-folder placeholders included; `content` leaves those out,
1830
+ * because a placeholder is never content — a folder holding nothing but
1831
+ * its placeholder is EMPTY, needs no confirmation, and reports no files.
1832
+ */
1833
+ const judge = async () => {
1834
+ const { files, links } = await filesUnder(fs, path);
1835
+ const content = files.filter((f) => !isFolderPlaceholder(f));
1836
+ // A restricted run is judged on what it would actually delete: the files.
1837
+ for (const f of files)
1838
+ writePolicy.assertPathWritable(ctx.sessionId, f);
1839
+ const managed = managedReason(await onDiskSpelling(root, path), 'folder');
1840
+ const blocked = managed !== undefined ? [] : await writeBlocked(branch, ctx, [path, ...files]);
1841
+ const linked = managed === undefined && links.length > 0 ? linksRefusal(path, links) : undefined;
1842
+ const reason = managed ?? linked ?? (blocked.length > 0
1843
+ ? `You may not write ${blocked.length === 1 ? `"${blocked[0]}"` : `${blocked.length} of the paths, e.g. "${blocked[0]}"`}, so the folder cannot be deleted.`
1844
+ : undefined);
1845
+ const impact = {
1846
+ path,
1847
+ kind: 'folder',
1848
+ descendants: content.length,
1849
+ files: content.slice(0, LISTED_FILES_CAP),
1850
+ filesTruncated: content.length > LISTED_FILES_CAP,
1851
+ allowed: reason === undefined,
1852
+ ...(reason !== undefined ? { reason } : {}),
1853
+ };
1854
+ return { files, content, managed, linked, blocked, impact };
1855
+ };
1856
+ if (a.dryRun === true)
1857
+ return { ...(await judge()).impact, dryRun: true };
1858
+ // The real delete runs in the folder's TURN, judged again inside it: an
1859
+ // emptied folder being kept (`keepFolderOf`, which takes the turn of
1860
+ // the folder it keeps) can never write its placeholder into this folder
1861
+ // after the files below were enumerated, and so bring it back.
1862
+ const outcome = await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), path, async () => {
1863
+ const { files, content, managed, linked, blocked, impact } = await judge();
1864
+ if (managed !== undefined)
1865
+ throw new ToolError(managed, 400);
1866
+ if (linked !== undefined)
1867
+ throw new ToolError(linked, 400);
1868
+ if (blocked.length > 0)
1869
+ throw await writeRefusal(branch, blocked[0], blocked[0] === path ? 'dir' : 'file');
1870
+ if (content.length > 0 && a.confirm !== true) {
1871
+ return {
1872
+ ...impact,
1873
+ confirmationRequired: true,
1874
+ deleted: false,
1875
+ message: `Nothing was deleted: "${path}" holds ${content.length} ${content.length === 1 ? 'file' : 'files'}, so deleting it requires confirm: true.`,
1876
+ };
1877
+ }
1878
+ // ONE commit for the whole folder. `write: true` guarantees a
1879
+ // LockingFilesystem here, and its `writeFiles` takes every path's lock
1880
+ // BEFORE touching disk, deletes inside those locks and commits the set
1881
+ // as a single change (fail-closed: a refusal commits nothing). A folder
1882
+ // therefore never half-disappears, and its own `access.md` needs no
1883
+ // ordering trick to keep the rest governed on the way — nothing lands
1884
+ // until all of it does. The placeholders go too: this is the one
1885
+ // operation that removes a folder. Structural cast, as `write_files`
1886
+ // does, to avoid importing the workflow-internal class here.
1887
+ if (files.length > 0) {
1888
+ const batch = fs;
1889
+ await batch.writeFiles([], `Delete ${path} and its ${content.length} file(s)`, files);
1890
+ }
1891
+ // Git tracks no folders: once the files are gone, the shells left on
1892
+ // disk are swept so the folder stops appearing in listings. Only empty
1893
+ // folders go, so a file a concurrent writer just dropped in survives.
1894
+ await removeEmptyDirs(join(root, path));
1895
+ return {
1896
+ ...impact,
1897
+ deleted: true,
1898
+ message: `Deleted "${path}" and its ${content.length} ${content.length === 1 ? 'file' : 'files'}.`,
1899
+ };
1900
+ });
1901
+ // The folder that HELD this one is not being deleted: if this was all it
1902
+ // had, it stays, with its placeholder. Outside the turn above — the
1903
+ // parent's turn overlaps it, and a folder turn is never nested.
1904
+ if (outcome.deleted)
1905
+ await keepFolderOf(fs, ctx, branch, path, kbDirName);
1906
+ return outcome;
664
1907
  },
665
1908
  });
666
1909
  mount({
667
1910
  name: 'mkdir',
668
- description: 'Create a directory (recursive). An empty dir gets a `.gitkeep` so it persists in git.' + ONTOLOGY_BOUNDARY_NOTE,
1911
+ description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.' + ONTOLOGY_BOUNDARY_NOTE,
669
1912
  inputs: {
670
1913
  type: 'object',
671
1914
  properties: {
@@ -682,6 +1925,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
682
1925
  required: ['path', 'created'],
683
1926
  },
684
1927
  write: true,
1928
+ proposable: true,
685
1929
  handler: async (a, ctx) => {
686
1930
  writePolicy.assertPathWritable(ctx.sessionId, a.path);
687
1931
  await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.path);
@@ -691,13 +1935,19 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
691
1935
  });
692
1936
  mount({
693
1937
  name: 'move_file',
694
- description: 'Move/rename a workspace file. Lands as a delete + create. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
1938
+ description: () => 'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Lands as a delete + create, committed + pushed as you. ' +
1939
+ `Rules: the destination must not exist — a move never overwrites a file or merges into a folder; a platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${AGENTS_FILE}\` at the repository root) is refused with "<name> is a platform file and stays in its folder." — a folder that moves takes its own platform files along, still in their folder; a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`) and git metadata are refused; a move cannot create a platform file or folder at \`dest\` either (renaming a note to \`access.md\` is refused); a path through a symbolic link is refused, since links are never followed; on a protected branch you must be able to write both ends — for a folder, every file under it at its old and its new path. ` +
1940
+ 'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination. ' +
1941
+ 'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.' +
1942
+ ONTOLOGY_BOUNDARY_NOTE,
695
1943
  inputs: {
696
1944
  type: 'object',
697
1945
  properties: {
698
1946
  branch: BRANCH_INPUT,
699
- src: wsPath(kbDirName, 'Source path', false),
700
- dest: wsPath(kbDirName, 'Destination path'),
1947
+ src: wsPath(kbDirName, 'Source path (file or folder)'),
1948
+ dest: wsPath(kbDirName, 'Destination path — the full new path; must not exist yet'),
1949
+ dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
1950
+ confirm: { type: 'boolean', description: 'Required when the move changes your access. Set it only after a dry run.' },
701
1951
  sessionId: SESSION_ID_INPUT,
702
1952
  },
703
1953
  required: ['branch', 'src', 'dest'],
@@ -705,32 +1955,156 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
705
1955
  },
706
1956
  outputs: {
707
1957
  type: 'object',
708
- properties: { src: str('Source path (echoes the input).'), dest: str('Destination path (echoes the input).'), moved: { type: 'boolean', description: 'Always true on success.' } },
1958
+ properties: {
1959
+ src: str('Source path (echoes the input).'),
1960
+ dest: str('Destination path (echoes the input).'),
1961
+ kind: str('`file` or `folder`.'),
1962
+ descendants: int('Files that move: 1 for a file, the file count under a folder.'),
1963
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
1964
+ accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between source and destination.' },
1965
+ allowed: { type: 'boolean', description: 'Whether the move may run.' },
1966
+ reason: str('Why it may not, when `allowed` is false.'),
1967
+ dryRun: { type: 'boolean', description: 'True on a dry run.' },
1968
+ confirmationRequired: { type: 'boolean', description: 'True when the call stopped for want of `confirm: true`.' },
1969
+ message: str('One sentence on what happened (or did not).'),
1970
+ moved: { type: 'boolean', description: 'True once the move landed.' },
1971
+ },
709
1972
  required: ['src', 'dest', 'moved'],
710
1973
  },
711
1974
  write: true,
1975
+ proposable: true,
712
1976
  handler: async (a, ctx) => {
1977
+ // Without trailing slashes: every path under a folder is derived from
1978
+ // these by prefix, and `filesUnder` names children without the slash.
1979
+ const src = a.src.replace(/\/+$/, '');
1980
+ const dest = a.dest.replace(/\/+$/, '');
1981
+ const branch = a.branch;
713
1982
  // A move CARRIES the source content into the destination — a genuine
714
1983
  // cross-ontology flow if the two differ — so BOTH endpoints are write-gated
715
1984
  // (unlike a plain delete, which moves no content). Check both BEFORE
716
1985
  // touching disk so a blocked endpoint can't leave the source already deleted.
717
- writePolicy.assertPathWritable(ctx.sessionId, a.src);
718
- writePolicy.assertPathWritable(ctx.sessionId, a.dest);
719
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src);
720
- await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest);
721
- await (await ctx.getFilesystem(a.branch)).moveFile(a.src, a.dest);
722
- return { src: a.src, dest: a.dest, moved: true };
1986
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, src);
1987
+ await assertOntologyWriteAllowed(sessionOntologyGate, ctx, dest);
1988
+ const fs = await ctx.getFilesystem(branch);
1989
+ const root = await workspaceRoot(branch, ctx);
1990
+ // Before the kind check, which stats THROUGH a link: a dangling link at
1991
+ // `src` would otherwise be answered "does not exist".
1992
+ await assertNoSymlinkOnPath(root, src);
1993
+ await assertNoSymlinkOnPath(root, dest);
1994
+ const kind = await kindOf(fs, src);
1995
+ if (kind === null)
1996
+ throw notFound(src, 'Nothing to move');
1997
+ const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
1998
+ // A restricted run is judged on what it would actually write: each file
1999
+ // at its old and its new path, not an extensionless folder path.
2000
+ for (const f of srcFiles) {
2001
+ writePolicy.assertPathWritable(ctx.sessionId, f);
2002
+ writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
2003
+ }
2004
+ const [before, after] = await Promise.all([accessAt(branch, ctx, src), accessAt(branch, ctx, dest)]);
2005
+ const accessChanges = Object.keys(before).some((v) => before[v] !== after[v]);
2006
+ // The placeholder moves with its folder, but it is never content.
2007
+ const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
2008
+ // Neither end may be the platform's own: a move neither takes a platform
2009
+ // item away nor makes one (a note renamed to `access.md` would start
2010
+ // governing its folder). The source end is judged here; the destination
2011
+ // end waits for the write verdict below, because reading it at all is
2012
+ // what the caller has to have earned.
2013
+ const srcManaged = managedReason(await onDiskSpelling(root, src), kind);
2014
+ // A folder move deletes every file under `src` and creates it again under
2015
+ // `dest`, so every one of them is judged at both paths — a file its own
2016
+ // rules deny you is not carried off because its folder is writable. The
2017
+ // lock gate locks only the two folder paths, so this is the check.
2018
+ const destFiles = srcFiles.map((f) => dest + f.slice(src.length));
2019
+ // The write verdict comes FIRST, before anything that looks at the
2020
+ // destination. "A file named Notes.md already exists in Sales." is a
2021
+ // fact about a folder, and on a protected branch a caller who may not
2022
+ // write there must not learn it from a refusal — answering existence
2023
+ // first would make this tool an existence oracle for folders whose
2024
+ // contents the caller cannot otherwise see. A source the platform owns
2025
+ // is refused on the source alone and needs no destination at all.
2026
+ const blocked = srcManaged !== undefined
2027
+ ? []
2028
+ : await writeBlocked(branch, ctx, [src, dest, ...srcFiles, ...destFiles]);
2029
+ const mayReadDestination = blocked.length === 0;
2030
+ const destOnDisk = mayReadDestination ? await onDiskSpelling(root, dest) : dest;
2031
+ const destManaged = mayReadDestination ? managedReason(destOnDisk, kind) : undefined;
2032
+ const occupiedBy = srcManaged === undefined && mayReadDestination
2033
+ ? await existingAt(root, dest, src)
2034
+ : null;
2035
+ const collision = occupiedBy !== null;
2036
+ // Checked after the collision: onto an existing platform file, "already
2037
+ // exists" is the plainer answer.
2038
+ const createsManaged = srcManaged !== undefined || collision || destManaged === undefined
2039
+ ? undefined
2040
+ : isGitMetadata(destOnDisk)
2041
+ ? destManaged
2042
+ : kind === 'file'
2043
+ ? platformFileCreationRefusal(destOnDisk)
2044
+ : `"${destOnDisk}" is a platform folder; a move cannot create one.`;
2045
+ const managedWhy = srcManaged ?? createsManaged;
2046
+ const managed = managedWhy !== undefined;
2047
+ // Same order as the checks above: the write refusal outranks every
2048
+ // answer that had to look at the destination to be written.
2049
+ const reason = managed
2050
+ ? managedWhy
2051
+ : blocked.length > 0
2052
+ ? `You may not write ${blocked.length === 1 ? `"${blocked[0]}"` : `${blocked.length} of the paths, e.g. "${blocked[0]}"`}, so the move cannot run.`
2053
+ : occupiedBy !== null
2054
+ ? entryExistsMessage(occupiedBy, dest)
2055
+ : undefined;
2056
+ const impact = {
2057
+ src,
2058
+ dest,
2059
+ kind,
2060
+ descendants,
2061
+ access: { before, after },
2062
+ accessChanges,
2063
+ allowed: reason === undefined,
2064
+ ...(reason !== undefined ? { reason } : {}),
2065
+ };
2066
+ if (a.dryRun === true)
2067
+ return { ...impact, dryRun: true, moved: false };
2068
+ if (managed)
2069
+ throw new ToolError(reason, 400);
2070
+ // A folder move is judged on its two folder paths and every file under
2071
+ // them: a refusal on one of the folder paths says so, because a folder
2072
+ // directly under a root is proposable where a file there is not.
2073
+ if (blocked.length > 0) {
2074
+ const folderPath = kind === 'folder' && (blocked[0] === src || blocked[0] === dest);
2075
+ throw await writeRefusal(branch, blocked[0], folderPath ? 'dir' : 'file');
2076
+ }
2077
+ if (collision)
2078
+ throw new ToolError(reason, 409);
2079
+ if (accessChanges && a.confirm !== true) {
2080
+ return {
2081
+ ...impact,
2082
+ confirmationRequired: true,
2083
+ moved: false,
2084
+ message: `Nothing was moved: your access at "${dest}" differs from "${src}", so this move requires confirm: true.`,
2085
+ };
2086
+ }
2087
+ // The look above is what produces the sentence; the filesystem's own
2088
+ // no-replace move is what guarantees it. A destination created between
2089
+ // the two — by another agent, or by the sidebar, which moves without
2090
+ // taking this lock — comes back here as a refusal, not an overwrite.
2091
+ await asEntryExists(() => fs.moveFile(src, dest));
2092
+ // Moving the last file — or a whole folder — out leaves the folder it
2093
+ // came from in place, like a delete.
2094
+ await keepFolderOf(fs, ctx, branch, src, kbDirName);
2095
+ return { ...impact, moved: true };
723
2096
  },
724
2097
  });
725
2098
  mount({
726
2099
  name: 'copy_file',
727
- description: 'Copy a workspace file to a new path. Committed + pushed as you.' + ONTOLOGY_BOUNDARY_NOTE,
2100
+ description: 'Copy a workspace file to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you.'
2101
+ + ONTOLOGY_BOUNDARY_NOTE,
728
2102
  inputs: {
729
2103
  type: 'object',
730
2104
  properties: {
731
2105
  branch: BRANCH_INPUT,
732
- src: wsPath(kbDirName, 'Source path', false),
733
- dest: wsPath(kbDirName, 'Destination path'),
2106
+ src: wsPath(kbDirName, 'Source path'),
2107
+ dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
734
2108
  sessionId: SESSION_ID_INPUT,
735
2109
  },
736
2110
  required: ['branch', 'src', 'dest'],
@@ -742,6 +2116,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
742
2116
  required: ['src', 'dest', 'copied'],
743
2117
  },
744
2118
  write: true,
2119
+ proposable: true,
745
2120
  handler: async (a, ctx) => {
746
2121
  // A copy CARRIES the source content into the destination — a genuine
747
2122
  // cross-ontology flow if the two differ — so BOTH endpoints are write-gated.
@@ -750,8 +2125,66 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
750
2125
  writePolicy.assertPathWritable(ctx.sessionId, a.dest);
751
2126
  await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src);
752
2127
  await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest);
753
- await (await ctx.getFilesystem(a.branch)).copyFile(a.src, a.dest);
754
- return { src: a.src, dest: a.dest, copied: true };
2128
+ const branch = a.branch;
2129
+ const src = a.src;
2130
+ const dest = a.dest;
2131
+ // A copy lands bytes at a name of its own, so it is refused by the same
2132
+ // rule a move is: nothing already at `dest` is replaced. To put new
2133
+ // content into a file that exists, write it.
2134
+ //
2135
+ // The same plain-path rule a move applies to both its ends, applied
2136
+ // here because the look at the destination comes before the filesystem's
2137
+ // own containment check: a path with a `..` segment must not reach
2138
+ // `lstat` outside the workspace, even to be told a name is taken.
2139
+ assertPlainPath(dest);
2140
+ // The write verdict comes FIRST, for the reason `move_file` gives at
2141
+ // length: "already exists" is a fact about the destination folder, and a
2142
+ // caller who may not write there must not be told it. The lock gate
2143
+ // refuses this copy anyway — but only after the copy had already
2144
+ // answered, which is exactly the oracle.
2145
+ const blockedDest = await writeBlocked(branch, ctx, [dest]);
2146
+ if (blockedDest.length > 0)
2147
+ throw await writeRefusal(branch, blockedDest[0]);
2148
+ const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
2149
+ if (occupiedBy !== null)
2150
+ throw new ToolError(entryExistsMessage(occupiedBy, dest), 409);
2151
+ // The copy itself lands exclusively (`COPYFILE_EXCL`, under the
2152
+ // destination's lock), so a name taken between the look and the landing
2153
+ // is refused with the same sentence rather than overwritten.
2154
+ //
2155
+ // Absence is only asked about once the copy has FAILED, and after the
2156
+ // write verdict above: a caller who may not write here gets the same
2157
+ // `write-denied` whether the source is there or not, exactly as from
2158
+ // write_file, edit_file, move_file and delete_file. Probing the source up
2159
+ // front would put a 404 in front of that 403 and make the refusal report
2160
+ // whether a path the caller could not copy from exists.
2161
+ //
2162
+ // WHICH end the absence belongs to is then decided by probing the
2163
+ // SOURCE, as move_file probes its own. A copy has exactly two ends, and
2164
+ // the filesystem blames the source for both: `LocalFilesystem.copyFile`
2165
+ // re-throws every ENOENT as `FileNotFoundError(src)`, and a destination
2166
+ // segment that is a file escapes raw as ENOTDIR from the parent mkdir
2167
+ // (`existingAt` above reads that as "nothing there" and lets the copy
2168
+ // go on to say so). So a source that is really gone gets the 404 naming
2169
+ // the source; a source sitting right there means the absence was the
2170
+ // DESTINATION's, and it gets the same 404 naming the destination.
2171
+ // Neither may escape as a 500 — that is the answer whose message carries
2172
+ // the server's own absolute path. Anything that is not absence travels
2173
+ // on as it always did.
2174
+ const fs = await ctx.getFilesystem(branch);
2175
+ try {
2176
+ await asEntryExists(() => fs.copyFile(src, dest));
2177
+ }
2178
+ catch (err) {
2179
+ const missing = isAbsence(err) || err.name === 'FileNotFoundError';
2180
+ if (missing) {
2181
+ throw (await kindOf(fs, src)) === null
2182
+ ? notFound(src, 'Nothing to copy')
2183
+ : notFound(dest, 'Nowhere to copy to');
2184
+ }
2185
+ throw err;
2186
+ }
2187
+ return { src, dest, copied: true };
755
2188
  },
756
2189
  });
757
2190
  mount({
@@ -762,7 +2195,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
762
2195
  type: 'object',
763
2196
  properties: {
764
2197
  branch: BRANCH_INPUT,
765
- path: wsPath(kbDirName, 'Path to the .zip archive', false),
2198
+ path: wsPath(kbDirName, 'Path to the .zip archive'),
766
2199
  destination: wsPath(kbDirName, "Directory to extract into (default: the zip's parent)"),
767
2200
  sessionId: SESSION_ID_INPUT,
768
2201
  },
@@ -792,7 +2225,12 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
792
2225
  // Reading the source archive pins/records the source ontology, so a session
793
2226
  // can't unzip from ontology A into ontology B without the A read counting.
794
2227
  await recordOntologyRead(sessionOntologyGate, ctx, zipPath);
795
- return ctx.workspaceService.unzipFile(workspaceIdForBranch(a.branch), zipPath, typeof a.destination === 'string' ? a.destination : undefined,
2228
+ // A .zip that is not there is a missing PATH, not an unreadable archive:
2229
+ // the service now says so (PathNotFoundError) and the helper turns it
2230
+ // into the same 404 every other file tool answers. Only that declared
2231
+ // answer maps — a failure part-way through an extraction is not the
2232
+ // archive going missing.
2233
+ return orDeclaredNotFound(() => ctx.workspaceService.unzipFile(workspaceIdForBranch(a.branch), zipPath, typeof a.destination === 'string' ? a.destination : undefined,
796
2234
  // Each extracted file is a write: a cross-ontology or write-blocked entry
797
2235
  // is skipped (not extracted), so an archive can't bypass the boundary — the
798
2236
  // extension policy applies per entry too, so a restricted run can't unzip a
@@ -801,9 +2239,14 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
801
2239
  // An entry that would land beside the repository is skipped with the
802
2240
  // corrected-path reason, like any other refused entry.
803
2241
  assertInsideRepo(wsRelPath, kbDirName);
2242
+ // Extraction writes straight to disk, past the filesystem's roles.yaml
2243
+ // gate — so an archive may not carry one at all.
2244
+ if (isRolesYamlPath(wsRelPath, kbDirName)) {
2245
+ throw new ToolError('roles.yaml is never extracted from an archive — change it with edit_file or write_file, where the change is checked.', 422);
2246
+ }
804
2247
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
805
2248
  return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
806
- });
2249
+ }), 'Nothing to extract');
807
2250
  },
808
2251
  });
809
2252
  // ── shell (internal-only) ───────────────────────────────────────────────
@@ -812,6 +2255,7 @@ export function registerWorkspaceTools(registry, router, toolAuth, toolHandler,
812
2255
  description: 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.' +
813
2256
  ONTOLOGY_BOUNDARY_NOTE,
814
2257
  internalOnly: true,
2258
+ fileTool: false,
815
2259
  inputs: {
816
2260
  type: 'object',
817
2261
  properties: {