@bevel-software/platform-core-backend 0.14.0 → 0.19.0

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