@bevel-software/platform-core-backend 0.21.0 → 0.23.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 (434) 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 +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  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-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -2,7 +2,10 @@
2
2
  * Skills are reusable specialist instructions living under `Plugins/<plugin>/<name>/SKILL.md`
3
3
  * in the KB repo. Discovery/loading is pinned to the DEFAULT branch — the catalog
4
4
  * is a single global, released set (a skill on a draft isn't discoverable until
5
- * merged). Progressive disclosure: `listSkills` = name + description (level 1),
5
+ * merged) — UNLESS the caller names a branch, which reads the skills as they are
6
+ * on that draft and marks the ones the default branch does not have yet (see
7
+ * {@link ListSkillsOptions} and `unmerged` below). Progressive disclosure:
8
+ * `listSkills` = name + description (level 1),
6
9
  * `getSkill` = full body + bundled-file paths (level 2), `getSkill(file)` = a
7
10
  * bundled file's content (level 3).
8
11
  */
@@ -38,6 +41,17 @@ export interface SkillSummary {
38
41
  * surfaces, which load skills by name and never by plugin.
39
42
  */
40
43
  plugins?: PluginMembership[];
44
+ /**
45
+ * Set only on an answer read from a branch OTHER than the default, and only
46
+ * on a skill whose folder DIFFERS there: it exists only on that branch, or
47
+ * its content was changed on it. Nobody has approved what such a skill says
48
+ * — it is not merged. A skill the branch shares byte-for-byte with the
49
+ * default branch carries no mark, because there is nothing unapproved about
50
+ * it.
51
+ */
52
+ unmerged?: true;
53
+ /** With `unmerged`: the branch the skill was read from. */
54
+ branch?: string;
41
55
  }
42
56
 
43
57
  export interface Skill extends SkillSummary {
@@ -57,6 +71,15 @@ export interface SkillFileContent {
57
71
  /** Repo-root-relative path of the file. */
58
72
  path: string;
59
73
  content: string;
74
+ /**
75
+ * As on {@link SkillSummary} — the file came off a branch whose copy of this
76
+ * skill differs from the default branch's. A note BESIDE the content, never
77
+ * inside it: a bundled file is a script or a data file, and a sentence
78
+ * prepended to one is a syntax error rather than a warning. A skill's body,
79
+ * which is prose an agent reads, carries the line instead.
80
+ */
81
+ unmerged?: true;
82
+ branch?: string;
60
83
  }
61
84
 
62
85
  export type GetSkillResult =
@@ -64,32 +87,76 @@ export type GetSkillResult =
64
87
  | { ok: true; kind: 'file'; file: SkillFileContent }
65
88
  | { ok: false; error: 'not_found' | 'forbidden' | 'invalid_file' }
66
89
  /**
67
- * A `version` was asked for that no commit of the skill's `SKILL.md` on the
68
- * default branch declared. `versions` is every version the history did
69
- * declare, newest first — the caller picks from those or asks for the
70
- * latest by leaving `version` out.
90
+ * A `version` was asked for that no commit of the skill's `SKILL.md`
91
+ * declared in the history searched — the default branch's, or `branch`'s own
92
+ * when a branch was named. `versions` is every version that history did
93
+ * declare, newest first, so it too is that branch's answer: the caller picks
94
+ * from those or asks for the latest by leaving `version` out.
71
95
  */
72
96
  | { ok: false; error: 'version_not_found'; versions: string[] };
73
97
 
98
+ /**
99
+ * What `listSkills` may be asked beyond a caller.
100
+ *
101
+ * The branch is the ONE thing that moves the catalog off the default branch.
102
+ * Omitted (or naming the default branch itself), every answer is exactly the
103
+ * released one — the browser menu and the MCP prompts take that path and are
104
+ * unaffected by this option existing.
105
+ */
106
+ export interface ListSkillsOptions {
107
+ /**
108
+ * Read the skills as they are on this branch instead of the default one: a
109
+ * skill that exists only there is listed, one changed there is listed with
110
+ * its changed description and version, one deleted there is absent. Access
111
+ * is judged on THAT branch, with that branch's access rules. A branch the
112
+ * platform has never heard of is a 404 naming it, as on the file tools.
113
+ *
114
+ * Everything listed from a non-default branch that differs from the default
115
+ * carries `unmerged: true` and this branch name: nobody approved it.
116
+ */
117
+ branch?: string;
118
+ }
119
+
74
120
  /** What `getSkill` may be asked beyond a name and a file. */
75
121
  export interface GetSkillOptions {
76
122
  /**
77
123
  * The version the skill declared in the copy to load — its
78
124
  * `metadata.version`, else a top-level `version`, else `lifecycle.version`.
79
125
  * Omitted, the skill is loaded as it is now — the latest. Given, the
80
- * default branch's history of the skill's `SKILL.md` is searched newest
81
- * first for the most recent commit that declared exactly this version, and
82
- * the skill (body, bundled files, or the one `file` asked for) is served as
83
- * it was at that commit. Read access is the caller's access to the skill as
84
- * it is now: a skill you may read, you may read the history of.
126
+ * history of the skill's `SKILL.md` is searched newest first for the most
127
+ * recent commit that declared exactly this version, and the skill (body,
128
+ * bundled files, or the one `file` asked for) is served as it was at that
129
+ * commit. Read access is the caller's access to the skill as it is now: a
130
+ * skill you may read, you may read the history of.
131
+ *
132
+ * The history searched is the one belonging to the branch being read: the
133
+ * default branch's, or — with `branch` — that branch's own, including the
134
+ * `versions` a `version_not_found` lists.
85
135
  */
86
136
  version?: string;
137
+ /**
138
+ * Load the skill as it is on this branch instead of the default one — see
139
+ * {@link ListSkillsOptions.branch}, which it mirrors exactly: same branch
140
+ * resolution, same 404, same access rules read on that branch. When the
141
+ * skill differs from the default branch's copy, the body returned BEGINS
142
+ * with one line saying so (a bundled file carries `unmerged` beside its
143
+ * content instead).
144
+ *
145
+ * With `version`, the history searched is that branch's own. The agent
146
+ * tools refuse the two together rather than make a caller reason about which
147
+ * branch a version came from; this service answers both, because the
148
+ * combination has exactly one sensible meaning.
149
+ */
150
+ branch?: string;
87
151
  }
88
152
 
89
153
  /**
90
- * The slice of git the skill service reads history through — the default
91
- * branch's own clone, never a caller's draft. Narrow on purpose: the catalog
92
- * is a disk scan and needs none of this; only a `version` asks for history.
154
+ * The slice of git the skill service reads history through. Every call names
155
+ * the workspace to read in, which is always the clone the skill itself was
156
+ * scanned out of — the default branch's, or the draft a `branch` named, so a
157
+ * version asked for on a branch is answered from that branch's history.
158
+ * Narrow on purpose: the catalog is a disk scan and needs none of this; only
159
+ * a `version` asks for history.
93
160
  */
94
161
  export interface SkillHistorySource {
95
162
  /** The commits (newest first) on `ref` that touched `repoRelativePath`, at most `limit`. */
@@ -133,17 +200,23 @@ export interface IPendingSkillService {
133
200
 
134
201
  export interface ISkillService {
135
202
  /**
136
- * The default-branch skill catalog. When `userEmail` is given, filtered to
137
- * skills that user may read (`canRead`); omit it for the global set (used to
138
- * compose the tool descriptions in the manual).
203
+ * The default-branch skill catalog — or `options.branch`'s, when one is
204
+ * named. When `userEmail` is given, filtered to skills that user may read
205
+ * (`canRead`, judged on the branch being read); omit it for the global set
206
+ * (used to compose the tool descriptions in the manual, which always
207
+ * describe the default branch).
139
208
  */
140
- listSkills(userEmail?: string): Promise<SkillSummary[]>;
209
+ listSkills(userEmail?: string, options?: ListSkillsOptions): Promise<SkillSummary[]>;
141
210
  /**
142
211
  * Load a skill's body (+ files), or a bundled file's content when `file` is
143
212
  * given — as it is now, or as it was at the commit that declared
144
213
  * `options.version` (see {@link GetSkillOptions}).
145
214
  */
146
215
  getSkill(userEmail: string, name: string, file?: string, options?: GetSkillOptions): Promise<GetSkillResult>;
147
- /** Drop the cached catalog (call after a merge to the default branch). */
216
+ /**
217
+ * Drop the cached DEFAULT-branch catalog (call after a merge to it). A
218
+ * branch read is never cached, so an agent that writes a skill on its draft
219
+ * and lists it right after sees what it just wrote.
220
+ */
148
221
  invalidate(): void;
149
222
  }
@@ -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
  *