@bevel-software/platform-core-backend 0.22.0 → 0.24.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 (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -3,6 +3,7 @@ import { logger } from '../../shared/logging.js';
3
3
 
4
4
  const log = logger('skills');
5
5
  import fs from 'node:fs/promises';
6
+ import { createHash } from 'node:crypto';
6
7
  import { parseDocument } from 'yaml';
7
8
  import type { WorkspaceService } from '../workspace/workspace.service.js';
8
9
  import type { KbContext } from '../../shared/kb-context.js';
@@ -13,6 +14,7 @@ import { TtlCache } from '../../shared/ttl-cache.js';
13
14
  import type {
14
15
  GetSkillOptions,
15
16
  ISkillService,
17
+ ListSkillsOptions,
16
18
  GetSkillResult,
17
19
  Skill,
18
20
  SkillHistorySource,
@@ -37,12 +39,43 @@ interface ParsedSkill {
37
39
  }
38
40
 
39
41
  /**
40
- * Reads skills from the DEFAULT-branch workspace (never the caller's branch), so
41
- * the catalog is one global, released set. Results are cached briefly and on a
42
- * merge to default the cache should be dropped via `invalidate()`.
42
+ * One branch's skills and where they were read from. The workspace rides along
43
+ * because every later question — may this caller read the skill, what does its
44
+ * folder contain, what is in its history — has to be asked of the SAME clone
45
+ * the skills were scanned out of, never of whichever branch happens to be the
46
+ * default.
47
+ */
48
+ interface Catalog {
49
+ wsId: string;
50
+ /** Absolute path of that workspace's KB clone; empty when there was none to read. */
51
+ kbRoot: string;
52
+ skills: ParsedSkill[];
53
+ /**
54
+ * Folder digests computed out of THIS catalog's clone, by skill folder —
55
+ * filled lazily by `digestOf`, so it lives exactly as long as the catalog
56
+ * does. On the released catalog that is the cache's TTL (and a merge to
57
+ * default drops both together); on a branch catalog, which is never cached,
58
+ * it is one call.
59
+ */
60
+ digests: Map<string, Promise<string | null>>;
61
+ }
62
+
63
+ /**
64
+ * Reads skills from the DEFAULT-branch workspace, so the catalog is one global,
65
+ * released set. Results are cached briefly and on a merge to default the cache
66
+ * should be dropped via `invalidate()`.
67
+ *
68
+ * A caller may instead NAME a branch (`branch` on either method) and read the
69
+ * skills as they are on that draft — what an agent needs to try a skill it has
70
+ * just written, before anyone has approved it. That read is deliberately
71
+ * austere: no cache (the draft changes under the agent's own hands), access
72
+ * judged in that branch's own clone with that branch's rules, a 404 for a
73
+ * branch nobody pushed, and everything the default branch does not already
74
+ * serve marked `unmerged` — with one line at the top of a body saying it is
75
+ * not approved.
43
76
  */
44
77
  export class SkillService implements ISkillService {
45
- private readonly cache: TtlCache<ParsedSkill[]>;
78
+ private readonly cache: TtlCache<Catalog>;
46
79
  /**
47
80
  * Where a `version` is read from — git, attached by the composition root
48
81
  * once the git service exists (it is built after this one). Without it a
@@ -73,16 +106,21 @@ export class SkillService implements ISkillService {
73
106
  this.cache.invalidate();
74
107
  }
75
108
 
76
- async listSkills(userEmail?: string): Promise<SkillSummary[]> {
77
- const skills = await this.scan();
78
- const summaries = skills.map((s) => s.summary);
79
- if (!userEmail) return summaries;
80
- const allowed = await this.readable(userEmail, summaries.map((s) => s.path));
81
- // Fail closed: keep a skill only on an explicit `true` verdict — a missing
82
- // entry counts as denied, matching the KB's default-deny read model (and the
83
- // tool-manuals catalog). `!== false` would silently EXPOSE a skill any time
84
- // the checker skips a path.
85
- return summaries.filter((s) => allowed.get(`${s.path}/SKILL.md`) === true);
109
+ async listSkills(userEmail?: string, options: ListSkillsOptions = {}): Promise<SkillSummary[]> {
110
+ const { catalog, branch } = await this.catalogFor(options.branch);
111
+ const visible = userEmail ? await this.readableSkills(catalog, userEmail) : catalog.skills;
112
+ if (branch === undefined) return visible.map((s) => s.summary);
113
+ // Marking reads both copies of a folder, so it happens only here, on a
114
+ // branch read that asked for it — the released catalog every other surface
115
+ // takes never pays for it. The released catalog is resolved ONCE for the
116
+ // whole listing: per skill, a cold cache would start one scan of the
117
+ // default branch per skill.
118
+ const released = await this.defaultCatalog();
119
+ return Promise.all(
120
+ visible.map(async (s) =>
121
+ (await this.differs(released, catalog, s)) ? { ...s.summary, unmerged: true as const, branch } : s.summary,
122
+ ),
123
+ );
86
124
  }
87
125
 
88
126
  async getSkill(
@@ -92,10 +130,12 @@ export class SkillService implements ISkillService {
92
130
  options: GetSkillOptions = {},
93
131
  ): Promise<GetSkillResult> {
94
132
  if (!isSafeSkillName(name)) return { ok: false, error: 'not_found' };
95
- const found = (await this.scan()).find((s) => s.summary.name === name);
133
+ const { catalog, branch } = await this.catalogFor(options.branch);
134
+ const found = catalog.skills.find((s) => s.summary.name === name);
135
+ // A skill the branch deleted is not a skill as far as this answer goes:
136
+ // the same `not_found` a name nobody ever used gets.
96
137
  if (!found) return { ok: false, error: 'not_found' };
97
138
 
98
- const wsId = this.kb.defaultWorkspaceId();
99
139
  // Through `readable`, not a bare `canRead`: the two gates must never
100
140
  // disagree. `canRead` reads a file's own frontmatter rules off disk on
101
141
  // every call while `canReadBatch` resolves them through a per-workspace
@@ -109,19 +149,51 @@ export class SkillService implements ISkillService {
109
149
  // rules would be authorized against the previous verdict until the memo
110
150
  // expires. It is not: `registerCatalogCacheInvalidation` drops the gate on
111
151
  // the same default-branch signal that drops this catalog, so the listing
112
- // and the load are refreshed by one event or by neither.
113
- const allowed = await this.readable(userEmail, [found.summary.path]);
152
+ // and the load are refreshed by one event or by neither. A branch read
153
+ // caches no catalog of its own, so the same pair of gates still answers
154
+ // for it — in that branch's workspace, where its own rules live.
155
+ const allowed = await this.readable(catalog.wsId, userEmail, [found.summary.path]);
114
156
  if (allowed.get(`${found.summary.path}/SKILL.md`) !== true) {
115
- return { ok: false, error: 'forbidden' };
157
+ // On the default branch: `forbidden` — the skill is released, everyone
158
+ // knows the catalog has it, and naming the refusal is what sends the
159
+ // caller to ask for access.
160
+ //
161
+ // On a draft: the same answer a name nobody ever used gets. A skill
162
+ // being written on a branch the caller cannot read there is one they
163
+ // must not learn exists either, and the listing already hides it — a
164
+ // load that answered `forbidden` would confirm it by the back door.
165
+ return { ok: false, error: branch === undefined ? 'forbidden' : 'not_found' };
116
166
  }
117
167
 
168
+ const result = await this.serve(catalog, found, name, file, options.version);
169
+ if (branch === undefined || !result.ok) return result;
170
+ // The mark and the line say "nobody approved this", so they go on exactly
171
+ // what the default branch does not already serve. A skill the branch never
172
+ // touched IS the released one — read through a draft's clone, but the same
173
+ // bytes — and claiming otherwise would teach agents to ignore the warning.
174
+ if (!(await this.differs(await this.defaultCatalog(), catalog, found))) return result;
175
+ return asUnmerged(result, branch);
176
+ }
177
+
178
+ /**
179
+ * The skill (or the one bundled file, or the copy a `version` names) out of
180
+ * the catalog it was found in. Everything the caller is allowed to see has
181
+ * been settled by `getSkill`; this only reads.
182
+ */
183
+ private async serve(
184
+ catalog: Catalog,
185
+ found: ParsedSkill,
186
+ name: string,
187
+ file: string | undefined,
188
+ version: string | undefined,
189
+ ): Promise<GetSkillResult> {
118
190
  // A version other than the one on disk now is read out of history —
119
191
  // after the access check above, which is the caller's access to the skill
120
192
  // as it is now. The version on disk IS the latest, whatever it is called,
121
193
  // so asking for it by name answers from disk like asking for nothing.
122
- const wanted = options.version?.trim();
194
+ const wanted = version?.trim();
123
195
  if (wanted !== undefined && wanted.length > 0 && wanted !== found.summary.version) {
124
- return this.getSkillAtVersion(wsId, found, name, wanted, file);
196
+ return this.getSkillAtVersion(catalog.wsId, found, name, wanted, file);
125
197
  }
126
198
 
127
199
  if (file !== undefined) {
@@ -131,7 +203,7 @@ export class SkillService implements ISkillService {
131
203
  // the ignore rules, so a path that is not in it is one they hid.
132
204
  if (!found.files.includes(repoPath)) return { ok: false, error: 'not_found' };
133
205
  try {
134
- const content = await this.workspaceService.readFile(wsId, `${this.kbDirName}/${repoPath}`);
206
+ const content = await this.workspaceService.readFile(catalog.wsId, `${this.kbDirName}/${repoPath}`);
135
207
  return { ok: true, kind: 'file', file: { name, file, path: repoPath, content } };
136
208
  } catch {
137
209
  return { ok: false, error: 'not_found' };
@@ -150,8 +222,9 @@ export class SkillService implements ISkillService {
150
222
  // --- internal ---------------------------------------------------------------
151
223
 
152
224
  /**
153
- * The skill as it was at the most recent default-branch commit whose
154
- * `SKILL.md` declared `wanted` — walking that file's history newest first,
225
+ * The skill as it was at the most recent commit of `wsId`'s clone whose
226
+ * `SKILL.md` declared `wanted` — the default branch's history, or a named
227
+ * branch's own — walking that file's history newest first,
155
228
  * so a version that was published, superseded and republished answers with
156
229
  * its latest copy. Every version the walk met is collected on the way, so
157
230
  * a miss can say what there is to ask for.
@@ -213,40 +286,150 @@ export class SkillService implements ISkillService {
213
286
  * The ONE read gate both surfaces resolve through, keyed by each skill's
214
287
  * `SKILL.md`. Shared so `listSkills` and `getSkill` can only ever give the
215
288
  * same verdict about the same skill — see the note at the `getSkill` call.
289
+ *
290
+ * `wsId` is the workspace the skills were READ from, so a branch's answer is
291
+ * judged by the `roles.yaml`, groups and `access.md` tree that branch has —
292
+ * which is the point: a draft may be where a skill's access rules are being
293
+ * changed, and judging it by the default branch's rules would answer about a
294
+ * tree the caller is not reading.
216
295
  */
217
- private async readable(userEmail: string, skillFolders: string[]): Promise<Map<string, boolean>> {
218
- return this.accessControl.canReadBatch(
219
- this.kb.defaultWorkspaceId(),
220
- userEmail,
221
- skillFolders.map((p) => `${p}/SKILL.md`),
222
- );
296
+ private async readable(
297
+ wsId: string,
298
+ userEmail: string,
299
+ skillFolders: string[],
300
+ ): Promise<Map<string, boolean>> {
301
+ return this.accessControl.canReadBatch(wsId, userEmail, skillFolders.map((p) => `${p}/SKILL.md`));
302
+ }
303
+
304
+ /** The skills of `catalog` this caller may read, by the gate above. */
305
+ private async readableSkills(catalog: Catalog, userEmail: string): Promise<ParsedSkill[]> {
306
+ const allowed = await this.readable(catalog.wsId, userEmail, catalog.skills.map((s) => s.summary.path));
307
+ // Fail closed: keep a skill only on an explicit `true` verdict — a missing
308
+ // entry counts as denied, matching the KB's default-deny read model (and the
309
+ // tool-manuals catalog). `!== false` would silently EXPOSE a skill any time
310
+ // the checker skips a path.
311
+ return catalog.skills.filter((s) => allowed.get(`${s.summary.path}/SKILL.md`) === true);
223
312
  }
224
313
 
225
- private async scan(): Promise<ParsedSkill[]> {
314
+ /**
315
+ * The catalog an answer comes out of, and the branch to MARK it with —
316
+ * `branch` undefined when the answer is the released one, so every surface
317
+ * that names no branch is answered exactly as it was before this input
318
+ * existed.
319
+ */
320
+ private async catalogFor(branch?: string): Promise<{ catalog: Catalog; branch?: string }> {
321
+ const named = branch?.trim();
322
+ // A blank branch is a missing one, and the safe reading of a missing
323
+ // branch is the released catalog: it serves only approved skills. A name
324
+ // that is REAL but unknown is not reinterpreted that way — it is the 404
325
+ // `readBranch` lets through. Naming the default branch is the same answer
326
+ // as naming nothing, mark included: there is nothing unmerged about it.
327
+ if (!named || named === this.kb.defaultBranch) return { catalog: await this.defaultCatalog() };
328
+ return { catalog: await this.readBranch(named), branch: named };
329
+ }
330
+
331
+ private async defaultCatalog(): Promise<Catalog> {
226
332
  const cached = this.cache.get();
227
333
  if (cached) return cached;
228
- // Token first: a merge's `invalidate()` can land while `scanDisk` is still
334
+ // Token first: a merge's `invalidate()` can land while the scan is still
229
335
  // reading the pre-merge tree, and storing that read afterwards would undo
230
336
  // the drop for a full TTL.
231
337
  const token = this.cache.begin();
232
- const skills = await this.scanDisk();
233
- this.cache.set(skills, token);
234
- return skills;
338
+ const catalog = await this.readDefault();
339
+ this.cache.set(catalog, token);
340
+ return catalog;
235
341
  }
236
342
 
237
- private async scanDisk(): Promise<ParsedSkill[]> {
238
- // Ensure the default-branch clone exists, then scan its Skills/ and
239
- // Plugins/ roots. Any failure (no workspace, no such dir) degrades to an
240
- // empty catalog — the manual/tools must never break because skills can't
241
- // be read.
343
+ /**
344
+ * The default branch's catalog. A workspace that cannot be resolved degrades
345
+ * to an empty one — the manual/tools must never break because skills can't
346
+ * be read. A NAMED branch does not degrade: see `readBranch`.
347
+ */
348
+ private async readDefault(): Promise<Catalog> {
242
349
  let wsId: string;
243
350
  try {
244
351
  wsId = (await this.workspaceService.getOrCreateForBranch(this.kb.defaultBranch)).id;
245
352
  } catch {
246
- return [];
353
+ return { wsId: this.kb.defaultWorkspaceId(), kbRoot: '', skills: [], digests: new Map() };
247
354
  }
248
355
  const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
356
+ return { wsId, kbRoot, skills: await this.scanTree(kbRoot), digests: new Map() };
357
+ }
358
+
359
+ /**
360
+ * One branch's clone, scanned. Deliberately NOT cached and deliberately NOT
361
+ * degraded:
362
+ * - no cache, because the caller of a branch read is usually the agent that
363
+ * just wrote the skill, and a minute-old answer would hide its own work;
364
+ * - no degrading, because a branch nobody ever pushed must answer the 404
365
+ * the file tools answer (`BranchNotFoundError`, naming the branch) — an
366
+ * empty list would read as "that branch has no skills", which is a
367
+ * different and untrue statement.
368
+ *
369
+ * Resolving the workspace clones the branch on first use, so a call naming a
370
+ * branch for the first time waits for a clone.
371
+ */
372
+ private async readBranch(branch: string): Promise<Catalog> {
373
+ const wsId = (await this.workspaceService.getOrCreateForBranch(branch)).id;
374
+ const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
375
+ return { wsId, kbRoot, skills: await this.scanTree(kbRoot), digests: new Map() };
376
+ }
377
+
378
+ /**
379
+ * Does this skill, as the branch has it, differ from the `released` copy —
380
+ * by the content of its FOLDER? It is absent from the released catalog (or
381
+ * listed there at another path), or one of the files the two catalogs list
382
+ * for it differs byte for byte.
383
+ *
384
+ * Reading both folders is the price of saying something true, and it is paid
385
+ * only on a branch read: a skill folder is instructions plus a few assets.
386
+ * A file that cannot be read on either side counts as a difference — unable
387
+ * to prove the branch's copy is the released one, the honest answer is that
388
+ * it is not approved.
389
+ *
390
+ * Both sides go through `digestOf`, so the RELEASED side is read once per
391
+ * cached catalog rather than once per skill per call: a listing of N skills
392
+ * used to re-hash all N released folders on every call, and each `getSkill`
393
+ * re-hashed one of them again.
394
+ */
395
+ private async differs(released: Catalog, branchCatalog: Catalog, skill: ParsedSkill): Promise<boolean> {
396
+ const mirror = released.skills.find((s) => s.summary.name === skill.summary.name);
397
+ if (!mirror || mirror.summary.path !== skill.summary.path) return true;
398
+ const [onBranch, onDefault] = await Promise.all([
399
+ this.digestOf(branchCatalog, skill),
400
+ this.digestOf(released, mirror),
401
+ ]);
402
+ return onBranch === null || onDefault === null || onBranch !== onDefault;
403
+ }
404
+
405
+ /**
406
+ * One skill folder's digest in one catalog's clone, computed at most once
407
+ * for that catalog. The folder path is a unique key within a catalog — a
408
+ * folder holds one `SKILL.md`, so it yields one skill — and the file list
409
+ * the digest covers comes from that same catalog's scan.
410
+ *
411
+ * Memoizing means a released folder's ASSETS are now as stale as the rest
412
+ * of the catalog that names them: both are refreshed by the TTL and dropped
413
+ * together by `invalidate()` on a merge to the default branch. A branch
414
+ * catalog is built per call and so is its memo, which is what a draft read
415
+ * needs — the agent's own last write must show.
416
+ */
417
+ private digestOf(catalog: Catalog, skill: ParsedSkill): Promise<string | null> {
418
+ const key = skill.summary.path;
419
+ const memo = catalog.digests.get(key);
420
+ if (memo) return memo;
421
+ // The promise, not the value: skills asked for at once share one read.
422
+ // A rejection is dropped rather than kept — `folderDigest` answers `null`
423
+ // for a file it cannot read, so a throw here is a defect, and holding it
424
+ // would answer with it for the catalog's whole life.
425
+ const pending = folderDigest(catalog.kbRoot, skill);
426
+ catalog.digests.set(key, pending);
427
+ pending.catch(() => catalog.digests.delete(key));
428
+ return pending;
429
+ }
249
430
 
431
+ /** Scan one clone's `Skills/` and `Plugins/` roots into a catalog's skills. */
432
+ private async scanTree(kbRoot: string): Promise<ParsedSkill[]> {
250
433
  // Skills may be grouped in category subfolders (each carrying its own
251
434
  // access.md), so a SKILL.md can live at any depth under either root. Walk
252
435
  // the tree and treat every folder that directly contains a SKILL.md as a
@@ -344,6 +527,60 @@ export class SkillService implements ISkillService {
344
527
 
345
528
  // --- helpers ------------------------------------------------------------------
346
529
 
530
+ /**
531
+ * The one line a skill read from an unmerged branch begins with. Exported
532
+ * because the tool surface documents it and the tests assert it: there is one
533
+ * sentence, in one place, and an agent that has read it once recognises it.
534
+ */
535
+ export function unmergedSkillNotice(branch: string): string {
536
+ return `This skill is read from the unmerged branch "${branch}" and is not approved.`;
537
+ }
538
+
539
+ /**
540
+ * Say, on the answer itself, that it came off a branch that changed this skill.
541
+ *
542
+ * The body gets the line because the body is what an agent reads and acts on;
543
+ * a bundled file gets the flags beside its content, never a sentence inside it
544
+ * (prepending prose to a script is a syntax error, not a warning).
545
+ */
546
+ function asUnmerged(result: Extract<GetSkillResult, { ok: true }>, branch: string): GetSkillResult {
547
+ if (result.kind === 'file') {
548
+ return { ...result, file: { ...result.file, unmerged: true, branch } };
549
+ }
550
+ return {
551
+ ...result,
552
+ skill: {
553
+ ...result.skill,
554
+ unmerged: true,
555
+ branch,
556
+ body: `${unmergedSkillNotice(branch)}\n\n${result.skill.body}`,
557
+ },
558
+ };
559
+ }
560
+
561
+ /**
562
+ * A digest of a skill folder as its own catalog lists it: `SKILL.md` plus
563
+ * every bundled file, each hashed under its relative name so a renamed,
564
+ * added or dropped asset is a difference too — and so a file one branch's
565
+ * ignore rules hide is one as well. `null` when a listed file cannot be read,
566
+ * which the caller treats as "cannot prove it is the released copy".
567
+ */
568
+ async function folderDigest(kbRoot: string, skill: ParsedSkill): Promise<string | null> {
569
+ if (!kbRoot) return null;
570
+ const folder = skill.summary.path;
571
+ const rels = ['SKILL.md', ...skill.files.map((f) => f.slice(folder.length + 1))].sort();
572
+ const hash = createHash('sha256');
573
+ for (const rel of rels) {
574
+ hash.update(`${rel}\0`);
575
+ try {
576
+ hash.update(await fs.readFile(path.join(kbRoot, folder, ...rel.split('/'))));
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+ return hash.digest('hex');
582
+ }
583
+
347
584
  /**
348
585
  * Skill names are folder names; reject anything that could escape the folder.
349
586
  *
@@ -1,11 +1,25 @@
1
1
  import type { Router, RequestHandler } from 'express';
2
2
  import type { IToolRegistry, UtcpTool } from '../tool-registry/tool.contract.js';
3
- import type { ToolContext } from '../tool-helpers/tool.contract.js';
3
+ import { ToolError, type ToolContext } from '../tool-helpers/tool.contract.js';
4
4
  import { toolDef } from '../tool-helpers/tool-def.js';
5
5
  import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
6
6
  import type { ISkillService } from './skills.contract.js';
7
7
  import type { IAllowedToolsChecker } from './allowed-tools-check.js';
8
8
 
9
+ /**
10
+ * The optional `branch` both skill tools declare. Optional on purpose, unlike
11
+ * the required `branch` of the KB file tools: a skill read is answered by the
12
+ * released catalog by default, and naming a branch is the caller asking for
13
+ * something nobody has approved yet.
14
+ */
15
+ const BRANCH_SKILLS_INPUT = {
16
+ type: 'string' as const,
17
+ description:
18
+ 'Optional: a draft branch to read the skills from instead of the released (default) branch — ' +
19
+ 'the branch you are working on. You must be able to read the branch and the skill on it; ' +
20
+ 'a branch that does not exist answers 404 naming it. Omit it for the approved skills.',
21
+ };
22
+
9
23
  /**
10
24
  * Registers the two skill tools (both surfaces) and hosts their endpoints.
11
25
  *
@@ -14,6 +28,13 @@ import type { IAllowedToolsChecker } from './allowed-tools-check.js';
14
28
  * what exists right in the tool catalog, no `list_skills` round-trip needed, and
15
29
  * it auto-updates as the default-branch catalog changes. The hosted routes are
16
30
  * static; only the description text is dynamic.
31
+ *
32
+ * Both tools take an optional `branch`. Without it they answer from the
33
+ * released (default-branch) catalog — what the descriptions name, what the
34
+ * browser menu lists and what the MCP prompt surface serves. With it they read
35
+ * the skills as they are on that draft and say which of them nobody has
36
+ * approved, so an agent can try the skill it just wrote without waiting for a
37
+ * merge.
17
38
  */
18
39
  export function registerSkillsTools(
19
40
  registry: IToolRegistry,
@@ -32,8 +53,8 @@ export function registerSkillsTools(
32
53
  router.post(
33
54
  '/agent/tools/list_skills',
34
55
  toolAuth,
35
- toolHandler(async (_args, ctx: ToolContext) => ({
36
- skills: await skillService.listSkills(ctx.user.email),
56
+ toolHandler(async (args, ctx: ToolContext) => ({
57
+ skills: await skillService.listSkills(ctx.user.email, { branch: branchArg(args) }),
37
58
  })),
38
59
  );
39
60
 
@@ -44,14 +65,38 @@ export function registerSkillsTools(
44
65
  const name = typeof args.name === 'string' ? args.name : '';
45
66
  const file = typeof args.file === 'string' ? args.file : undefined;
46
67
  const version = typeof args.version === 'string' ? args.version : undefined;
68
+ const branch = branchArg(args);
47
69
  if (!name) return { error: 'missing_name' };
48
- const result = await skillService.getSkill(ctx.user.email, name, file, { version });
70
+ // Refused rather than ranked: a `version` is a point in the released
71
+ // skill's history, a `branch` is a draft nobody has released — asked for
72
+ // together, neither answer is the one the caller meant, and guessing
73
+ // would serve instructions under a label that does not describe them.
74
+ if (branch !== undefined && version !== undefined && version.trim().length > 0) {
75
+ throw new ToolError(
76
+ 'get_skill takes `branch` or `version`, not both: `branch` loads the skill as that draft has it ' +
77
+ 'now, `version` loads a version the released skill declared. Pass one of them.',
78
+ 400,
79
+ );
80
+ }
81
+ const result = await skillService.getSkill(ctx.user.email, name, file, { version, branch });
49
82
  if (!allowedTools || !result.ok || result.kind !== 'skill') return result;
50
83
  return { ...result, warnings: await allowedTools.check(ctx.user.email, result.skill.allowedTools) };
51
84
  }),
52
85
  );
53
86
  }
54
87
 
88
+ /**
89
+ * The `branch` a tool call named, or undefined when it named none (the
90
+ * released catalog). A non-string or blank value is NOT a branch: it is taken
91
+ * as absent, which answers from the default branch — the approved one. The
92
+ * resolution of a real name, and the 404 for one the platform never heard of,
93
+ * belong to the workspace layer, exactly as on the file tools.
94
+ */
95
+ function branchArg(args: Record<string, unknown>): string | undefined {
96
+ const branch = args.branch;
97
+ return typeof branch === 'string' && branch.trim().length > 0 ? branch : undefined;
98
+ }
99
+
55
100
  /** "Currently available skills: `a`, `b`." (or a no-skills note), filtered to what the caller may read. */
56
101
  async function availableSkillsLine(skillService: ISkillService, userEmail?: string): Promise<string> {
57
102
  const skills = await skillService.listSkills(userEmail);
@@ -67,9 +112,16 @@ async function buildListSkillsDef(skillService: ISkillService, userEmail?: strin
67
112
  'for a skill that declares one, its current `version` (its SKILL.md `metadata.version`, else a ' +
68
113
  'top-level `version`, else `lifecycle.version`). ' +
69
114
  'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
115
+ 'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
116
+ 'what you need to try a skill you just wrote there; each skill that differs from the released ' +
117
+ 'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ' +
70
118
  (await availableSkillsLine(skillService, userEmail)),
71
119
  path: '/api/agent/tools/list_skills',
72
- inputs: { type: 'object', properties: {}, additionalProperties: false },
120
+ inputs: {
121
+ type: 'object',
122
+ properties: { branch: BRANCH_SKILLS_INPUT },
123
+ additionalProperties: false,
124
+ },
73
125
  outputs: {
74
126
  type: 'object',
75
127
  properties: {
@@ -88,6 +140,13 @@ async function buildListSkillsDef(skillService: ISkillService, userEmail?: strin
88
140
  'else `lifecycle.version` in its SKILL.md); absent when it declares none.',
89
141
  },
90
142
  path: { type: 'string' },
143
+ unmerged: {
144
+ type: 'boolean',
145
+ description:
146
+ 'Only when `branch` was passed, and only on a skill that differs from the released one ' +
147
+ '(it exists only on that branch, or was changed there): nobody has approved what it says.',
148
+ },
149
+ branch: { type: 'string', description: 'With `unmerged`: the branch the skill was read from.' },
91
150
  },
92
151
  },
93
152
  },
@@ -104,7 +163,7 @@ async function buildGetSkillDef(skillService: ISkillService, userEmail?: string)
104
163
  'Load a skill by name: returns its full instructions (SKILL.md body) to follow, plus the skill ' +
105
164
  'folder path and the list of bundled files. Pass `file` to fetch a bundled file’s content ' +
106
165
  '(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
107
- 'one the skill declared. ' +
166
+ 'one the skill declared, or `branch` names a draft to load it from. ' +
108
167
  (await availableSkillsLine(skillService, userEmail)),
109
168
  path: '/api/agent/tools/get_skill',
110
169
  inputs: {
@@ -126,6 +185,7 @@ async function buildGetSkillDef(skillService: ISkillService, userEmail?: string)
126
185
  'recent commit that declared that version; a version the skill never declared answers ' +
127
186
  '`version_not_found` with the versions it did declare.',
128
187
  },
188
+ branch: BRANCH_SKILLS_INPUT,
129
189
  },
130
190
  required: ['name'],
131
191
  additionalProperties: false,
@@ -134,8 +194,20 @@ async function buildGetSkillDef(skillService: ISkillService, userEmail?: string)
134
194
  type: 'object',
135
195
  description: 'On success carries `skill` (or `file` when `file` was passed); on failure carries `error`.',
136
196
  properties: {
137
- skill: { type: 'object', description: 'The loaded skill: name, description, body, files, ….' },
138
- file: { type: 'object', description: 'A bundled file: name, file, path, content.' },
197
+ skill: {
198
+ type: 'object',
199
+ description:
200
+ 'The loaded skill: name, description, body, files, …. Read from a `branch` that changed it, ' +
201
+ 'it also carries `unmerged: true` and that branch, and its `body` BEGINS with one line ' +
202
+ 'saying the skill comes from that unmerged branch and is not approved — treat it as a ' +
203
+ 'proposal you are testing, not as approved instructions.',
204
+ },
205
+ file: {
206
+ type: 'object',
207
+ description:
208
+ 'A bundled file: name, file, path, content — plus `unmerged` and `branch` when it came off ' +
209
+ 'a branch that changed the skill (the note stays beside the content, never inside it).',
210
+ },
139
211
  warnings: {
140
212
  type: 'array',
141
213
  description:
@@ -8,6 +8,7 @@ import { InternalTokenService } from '../internal-token.service.js';
8
8
  import { createManualAuthMiddleware } from '../tool-auth.middleware.js';
9
9
  import type { IExternalApiKeyService } from '../external-api-key.interface.js';
10
10
  import type { AuthService } from '../../auth/auth.service.js';
11
+ import { AuthBackendError } from '../../auth/account-admission.js';
11
12
 
12
13
  /**
13
14
  * The manual endpoints (`/agent/utcp`, `/agent/internal/utcp`) accept a browser
@@ -17,6 +18,8 @@ import type { AuthService } from '../../auth/auth.service.js';
17
18
  */
18
19
  const KEY = 'bevel_validkey';
19
20
  const JWT = 'browser-jwt';
21
+ /** A valid session, presented while its account cannot be looked up. */
22
+ const JWT_DURING_OUTAGE = 'browser-jwt-during-outage';
20
23
 
21
24
  const fakeExternalApiKeys = {
22
25
  looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
@@ -25,10 +28,12 @@ const fakeExternalApiKeys = {
25
28
  } as unknown as IExternalApiKeyService;
26
29
 
27
30
  const fakeAuth = {
28
- verifyToken: (t: string) => {
31
+ resolveSession: async (t: string) => {
29
32
  if (t === JWT) return { userId: 'u-jwt', email: 'jwt@x' };
33
+ if (t === JWT_DURING_OUTAGE) throw new AuthBackendError(new Error('the database is down'));
30
34
  throw new Error('bad jwt');
31
35
  },
36
+ isActive: async () => true,
32
37
  } as unknown as AuthService;
33
38
 
34
39
  let server: HttpServer | undefined;
@@ -75,6 +80,14 @@ describe('createManualAuthMiddleware (read-only manual endpoints)', () => {
75
80
  expect((await get(base, '/api/agent/utcp', 'garbage')).status).toBe(401);
76
81
  });
77
82
 
83
+ it('answers 500, not 401, when the session’s account cannot be looked up', async () => {
84
+ const { base } = await start();
85
+ const r = await get(base, '/api/agent/utcp', JWT_DURING_OUTAGE);
86
+ // A 401 would sign a valid caller out over an outage.
87
+ expect(r.status).toBe(500);
88
+ expect(r.headers.get('www-authenticate')).toBeNull();
89
+ });
90
+
78
91
  it('refuses the INTERNAL manual to a browser JWT (403) but allows an internal token', async () => {
79
92
  const { base, intToken } = await start();
80
93
  expect((await get(base, '/api/agent/internal/utcp', JWT)).status).toBe(403);