@bevel-software/platform-core-backend 0.25.2 → 0.27.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 (733) hide show
  1. package/agent-guide/access-control.md +251 -0
  2. package/agent-guide/conventions.md +35 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/html-views.md +83 -0
  6. package/agent-guide/introduction.md +27 -0
  7. package/agent-guide/new-knowledge-base.md +24 -0
  8. package/agent-guide/skills.md +47 -0
  9. package/agent-guide/tool-manuals.md +227 -0
  10. package/agent-guide/where-a-new-file-goes.md +36 -0
  11. package/dist/assets.d.ts +26 -0
  12. package/dist/assets.d.ts.map +1 -1
  13. package/dist/assets.js +35 -2
  14. package/dist/assets.js.map +1 -1
  15. package/dist/core/catalog-revision.d.ts +19 -1
  16. package/dist/core/catalog-revision.d.ts.map +1 -1
  17. package/dist/core/catalog-revision.js +14 -3
  18. package/dist/core/catalog-revision.js.map +1 -1
  19. package/dist/core/core-ports.d.ts +11 -0
  20. package/dist/core/core-ports.d.ts.map +1 -1
  21. package/dist/core/core-ports.js.map +1 -1
  22. package/dist/core/create-core-server.d.ts +5 -0
  23. package/dist/core/create-core-server.d.ts.map +1 -1
  24. package/dist/core/create-core-server.js +65 -3
  25. package/dist/core/create-core-server.js.map +1 -1
  26. package/dist/core/create-core-services.d.ts +35 -0
  27. package/dist/core/create-core-services.d.ts.map +1 -1
  28. package/dist/core/create-core-services.js +114 -12
  29. package/dist/core/create-core-services.js.map +1 -1
  30. package/dist/core/lifecycle.d.ts +16 -1
  31. package/dist/core/lifecycle.d.ts.map +1 -1
  32. package/dist/core/lifecycle.js +18 -1
  33. package/dist/core/lifecycle.js.map +1 -1
  34. package/dist/core-config.d.ts +21 -0
  35. package/dist/core-config.d.ts.map +1 -1
  36. package/dist/core-config.js +22 -1
  37. package/dist/core-config.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/modules/access/access-control.interface.d.ts +17 -0
  43. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  44. package/dist/modules/access/access-control.service.d.ts +2 -0
  45. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  46. package/dist/modules/access/access-control.service.js +27 -0
  47. package/dist/modules/access/access-control.service.js.map +1 -1
  48. package/dist/modules/access/directory-sync-bot.js +1 -1
  49. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  50. package/dist/modules/agent-guide/agent-guide.d.ts +145 -0
  51. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  52. package/dist/modules/agent-guide/agent-guide.js +199 -0
  53. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  54. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  55. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  56. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  57. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  58. package/dist/modules/agent-guide/index.d.ts +4 -0
  59. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  60. package/dist/modules/agent-guide/index.js +4 -0
  61. package/dist/modules/agent-guide/index.js.map +1 -0
  62. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  63. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  64. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  65. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  66. package/dist/modules/agent-instructions/compose.d.ts +14 -6
  67. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  68. package/dist/modules/agent-instructions/compose.js +28 -8
  69. package/dist/modules/agent-instructions/compose.js.map +1 -1
  70. package/dist/modules/agent-instructions/index.d.ts +1 -1
  71. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  72. package/dist/modules/agent-instructions/index.js +1 -1
  73. package/dist/modules/agent-instructions/index.js.map +1 -1
  74. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  75. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  76. package/dist/modules/agent-instructions/shared-file-rules.js +51 -104
  77. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  78. package/dist/modules/audit/agent-audit.service.d.ts +21 -2
  79. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  80. package/dist/modules/audit/agent-audit.service.js +43 -2
  81. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  82. package/dist/modules/audit/agent-connection.routes.d.ts +34 -0
  83. package/dist/modules/audit/agent-connection.routes.d.ts.map +1 -0
  84. package/dist/modules/audit/agent-connection.routes.js +38 -0
  85. package/dist/modules/audit/agent-connection.routes.js.map +1 -0
  86. package/dist/modules/audit/audit.contract.d.ts +24 -0
  87. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  88. package/dist/modules/audit/audit.contract.js.map +1 -1
  89. package/dist/modules/audit/index.d.ts +1 -0
  90. package/dist/modules/audit/index.d.ts.map +1 -1
  91. package/dist/modules/audit/index.js +1 -0
  92. package/dist/modules/audit/index.js.map +1 -1
  93. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  94. package/dist/modules/auth/account.routes.js +10 -3
  95. package/dist/modules/auth/account.routes.js.map +1 -1
  96. package/dist/modules/auth/auth.middleware.d.ts +7 -0
  97. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  98. package/dist/modules/auth/auth.middleware.js +1 -1
  99. package/dist/modules/auth/auth.middleware.js.map +1 -1
  100. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  101. package/dist/modules/code-mode/code-mode.tool.js +11 -3
  102. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  103. package/dist/modules/database/core-schema.d.ts +96 -0
  104. package/dist/modules/database/core-schema.d.ts.map +1 -1
  105. package/dist/modules/database/core-schema.js +32 -3
  106. package/dist/modules/database/core-schema.js.map +1 -1
  107. package/dist/modules/embed/account-link.service.d.ts +42 -0
  108. package/dist/modules/embed/account-link.service.d.ts.map +1 -0
  109. package/dist/modules/embed/account-link.service.js +50 -0
  110. package/dist/modules/embed/account-link.service.js.map +1 -0
  111. package/dist/modules/embed/embed-link.d.ts +58 -0
  112. package/dist/modules/embed/embed-link.d.ts.map +1 -0
  113. package/dist/modules/embed/embed-link.js +160 -0
  114. package/dist/modules/embed/embed-link.js.map +1 -0
  115. package/dist/modules/embed/embed.errors.d.ts +30 -0
  116. package/dist/modules/embed/embed.errors.d.ts.map +1 -0
  117. package/dist/modules/embed/embed.errors.js +42 -0
  118. package/dist/modules/embed/embed.errors.js.map +1 -0
  119. package/dist/modules/embed/embed.interface.d.ts +204 -0
  120. package/dist/modules/embed/embed.interface.d.ts.map +1 -0
  121. package/dist/modules/embed/embed.interface.js +2 -0
  122. package/dist/modules/embed/embed.interface.js.map +1 -0
  123. package/dist/modules/embed/embed.routes.d.ts +59 -0
  124. package/dist/modules/embed/embed.routes.d.ts.map +1 -0
  125. package/dist/modules/embed/embed.routes.js +343 -0
  126. package/dist/modules/embed/embed.routes.js.map +1 -0
  127. package/dist/modules/embed/embed.service.d.ts +169 -0
  128. package/dist/modules/embed/embed.service.d.ts.map +1 -0
  129. package/dist/modules/embed/embed.service.js +627 -0
  130. package/dist/modules/embed/embed.service.js.map +1 -0
  131. package/dist/modules/embed/embed.tools.d.ts +51 -0
  132. package/dist/modules/embed/embed.tools.d.ts.map +1 -0
  133. package/dist/modules/embed/embed.tools.js +170 -0
  134. package/dist/modules/embed/embed.tools.js.map +1 -0
  135. package/dist/modules/embed/mcp-app.d.ts +64 -0
  136. package/dist/modules/embed/mcp-app.d.ts.map +1 -0
  137. package/dist/modules/embed/mcp-app.js +147 -0
  138. package/dist/modules/embed/mcp-app.js.map +1 -0
  139. package/dist/modules/embed/mcp-app.routes.d.ts +21 -0
  140. package/dist/modules/embed/mcp-app.routes.d.ts.map +1 -0
  141. package/dist/modules/embed/mcp-app.routes.js +36 -0
  142. package/dist/modules/embed/mcp-app.routes.js.map +1 -0
  143. package/dist/modules/github-app/github-app.connection.d.ts +15 -0
  144. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -1
  145. package/dist/modules/github-app/github-app.connection.js +34 -3
  146. package/dist/modules/github-app/github-app.connection.js.map +1 -1
  147. package/dist/modules/kb-fs/locking-filesystem.d.ts +65 -0
  148. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  149. package/dist/modules/kb-fs/locking-filesystem.js +300 -0
  150. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  151. package/dist/modules/kb-sync/kb-sync.interface.d.ts +5 -0
  152. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -1
  153. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -1
  154. package/dist/modules/kb-sync/kb-sync.service.js +6 -0
  155. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -1
  156. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  157. package/dist/modules/mcp/mcp-auth.middleware.js +2 -1
  158. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  159. package/dist/modules/mcp/mcp.service.d.ts +57 -3
  160. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  161. package/dist/modules/mcp/mcp.service.js +177 -24
  162. package/dist/modules/mcp/mcp.service.js.map +1 -1
  163. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +24 -0
  164. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  165. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +32 -4
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  167. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  168. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  169. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  170. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  171. package/dist/modules/onboarding/onboarding.contract.d.ts +80 -0
  172. package/dist/modules/onboarding/onboarding.contract.d.ts.map +1 -0
  173. package/dist/modules/onboarding/onboarding.contract.js +7 -0
  174. package/dist/modules/onboarding/onboarding.contract.js.map +1 -0
  175. package/dist/modules/onboarding/onboarding.routes.d.ts +18 -0
  176. package/dist/modules/onboarding/onboarding.routes.d.ts.map +1 -0
  177. package/dist/modules/onboarding/onboarding.routes.js +81 -0
  178. package/dist/modules/onboarding/onboarding.routes.js.map +1 -0
  179. package/dist/modules/onboarding/starter-pack.service.d.ts +213 -0
  180. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -0
  181. package/dist/modules/onboarding/starter-pack.service.js +492 -0
  182. package/dist/modules/onboarding/starter-pack.service.js.map +1 -0
  183. package/dist/modules/onboarding/starter-packs.d.ts +78 -0
  184. package/dist/modules/onboarding/starter-packs.d.ts.map +1 -0
  185. package/dist/modules/onboarding/starter-packs.js +156 -0
  186. package/dist/modules/onboarding/starter-packs.js.map +1 -0
  187. package/dist/modules/plugins/index.d.ts +1 -1
  188. package/dist/modules/plugins/index.d.ts.map +1 -1
  189. package/dist/modules/plugins/index.js +1 -1
  190. package/dist/modules/plugins/index.js.map +1 -1
  191. package/dist/modules/plugins/plugin-provision.service.d.ts +19 -0
  192. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  193. package/dist/modules/plugins/plugin-provision.service.js +18 -1
  194. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  195. package/dist/modules/plugins/plugins.routes.d.ts +0 -36
  196. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  197. package/dist/modules/plugins/plugins.routes.js +31 -0
  198. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  199. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  200. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  201. package/dist/modules/plugins/plugins.tools.js +71 -14
  202. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  203. package/dist/modules/settings/deployment-settings.service.d.ts +34 -7
  204. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  205. package/dist/modules/settings/deployment-settings.service.js +148 -54
  206. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  207. package/dist/modules/settings/repository-source.d.ts +1 -0
  208. package/dist/modules/settings/repository-source.d.ts.map +1 -1
  209. package/dist/modules/settings/repository-source.js +3 -3
  210. package/dist/modules/settings/repository-source.js.map +1 -1
  211. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  212. package/dist/modules/settings/setup.routes.js +3 -6
  213. package/dist/modules/settings/setup.routes.js.map +1 -1
  214. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  215. package/dist/modules/skills/skills.tools.js +103 -43
  216. package/dist/modules/skills/skills.tools.js.map +1 -1
  217. package/dist/modules/tool-auth/connection-key-rejection.d.ts +10 -1
  218. package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -1
  219. package/dist/modules/tool-auth/connection-key-rejection.js +11 -3
  220. package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -1
  221. package/dist/modules/tool-auth/external-api-key.interface.d.ts +8 -0
  222. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  223. package/dist/modules/tool-auth/external-api-key.service.d.ts +24 -2
  224. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  225. package/dist/modules/tool-auth/external-api-key.service.js +59 -2
  226. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  227. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  228. package/dist/modules/tool-auth/tool-auth.middleware.js +9 -4
  229. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  230. package/dist/modules/tool-helpers/index.d.ts +28 -1
  231. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  232. package/dist/modules/tool-helpers/index.js +28 -1
  233. package/dist/modules/tool-helpers/index.js.map +1 -1
  234. package/dist/modules/tool-helpers/route-argument-check.d.ts +10 -0
  235. package/dist/modules/tool-helpers/route-argument-check.d.ts.map +1 -0
  236. package/dist/modules/tool-helpers/route-argument-check.js +132 -0
  237. package/dist/modules/tool-helpers/route-argument-check.js.map +1 -0
  238. package/dist/modules/tool-helpers/route-tool-schemas.d.ts +89 -0
  239. package/dist/modules/tool-helpers/route-tool-schemas.d.ts.map +1 -0
  240. package/dist/modules/tool-helpers/route-tool-schemas.js +113 -0
  241. package/dist/modules/tool-helpers/route-tool-schemas.js.map +1 -0
  242. package/dist/modules/tool-helpers/tool-def.d.ts +71 -0
  243. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -1
  244. package/dist/modules/tool-helpers/tool-def.js +70 -6
  245. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  246. package/dist/modules/tool-helpers/tool-handler.d.ts +46 -6
  247. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  248. package/dist/modules/tool-helpers/tool-handler.js +157 -8
  249. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  250. package/dist/modules/tool-helpers/tool.contract.d.ts +9 -0
  251. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  252. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  253. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  254. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  255. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  256. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  257. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  258. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  259. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  260. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  261. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  262. package/dist/modules/tool-manuals/tool-manuals.tools.js +77 -38
  263. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  264. package/dist/modules/tool-registry/description-length.d.ts +20 -16
  265. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  266. package/dist/modules/tool-registry/description-length.js +50 -30
  267. package/dist/modules/tool-registry/description-length.js.map +1 -1
  268. package/dist/modules/tool-registry/guide-first.d.ts +27 -0
  269. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  270. package/dist/modules/tool-registry/guide-first.js +40 -0
  271. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  272. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  273. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  274. package/dist/modules/tool-registry/tool-registry.js +9 -2
  275. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  276. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  277. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  278. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  279. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  280. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +126 -0
  281. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  282. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +611 -0
  283. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  284. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  285. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  286. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  287. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  288. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +8 -1
  289. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  290. package/dist/modules/workflow/agent-tools/workflow.tools.js +133 -37
  291. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  292. package/dist/modules/workflow/file-lock.service.d.ts +29 -5
  293. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  294. package/dist/modules/workflow/file-lock.service.js +62 -5
  295. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  296. package/dist/modules/workflow/git/git.service.d.ts +282 -14
  297. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  298. package/dist/modules/workflow/git/git.service.js +627 -126
  299. package/dist/modules/workflow/git/git.service.js.map +1 -1
  300. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  301. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  302. package/dist/modules/workflow/git/merge-commit.js +89 -0
  303. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  304. package/dist/modules/workflow/git/node-git-runner.d.ts +15 -0
  305. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  306. package/dist/modules/workflow/git/node-git-runner.js +78 -6
  307. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  308. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  309. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  310. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  311. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  312. package/dist/modules/workflow/pending-commits.service.d.ts +15 -0
  313. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  314. package/dist/modules/workflow/pending-commits.service.js +25 -1
  315. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  316. package/dist/modules/workflow/pending-commits.worker.d.ts +6 -1
  317. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  318. package/dist/modules/workflow/pending-commits.worker.js +23 -6
  319. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  320. package/dist/modules/workflow/recovery-bot.js +1 -1
  321. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  322. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  323. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  324. package/dist/modules/workflow/review-workflow/review-workflow.service.js +180 -13
  325. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  326. package/dist/modules/workflow/sanitize-error.d.ts +16 -0
  327. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
  328. package/dist/modules/workflow/sanitize-error.js +45 -0
  329. package/dist/modules/workflow/sanitize-error.js.map +1 -1
  330. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  331. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  332. package/dist/modules/workflow/workflow.routes.js +7 -2
  333. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  334. package/dist/modules/workflow/workflow.service.d.ts +187 -15
  335. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  336. package/dist/modules/workflow/workflow.service.js +808 -106
  337. package/dist/modules/workflow/workflow.service.js.map +1 -1
  338. package/dist/modules/workspace/agent-download.builder.d.ts +105 -0
  339. package/dist/modules/workspace/agent-download.builder.d.ts.map +1 -0
  340. package/dist/modules/workspace/agent-download.builder.js +238 -0
  341. package/dist/modules/workspace/agent-download.builder.js.map +1 -0
  342. package/dist/modules/workspace/agent-download.routes.d.ts +77 -0
  343. package/dist/modules/workspace/agent-download.routes.d.ts.map +1 -0
  344. package/dist/modules/workspace/agent-download.routes.js +156 -0
  345. package/dist/modules/workspace/agent-download.routes.js.map +1 -0
  346. package/dist/modules/workspace/agent-download.store.d.ts +225 -0
  347. package/dist/modules/workspace/agent-download.store.d.ts.map +1 -0
  348. package/dist/modules/workspace/agent-download.store.js +423 -0
  349. package/dist/modules/workspace/agent-download.store.js.map +1 -0
  350. package/dist/modules/workspace/agent-upload.store.d.ts +10 -1
  351. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -1
  352. package/dist/modules/workspace/agent-upload.store.js +16 -6
  353. package/dist/modules/workspace/agent-upload.store.js.map +1 -1
  354. package/dist/modules/workspace/file-readers/raw-mime.d.ts +6 -0
  355. package/dist/modules/workspace/file-readers/raw-mime.d.ts.map +1 -0
  356. package/dist/modules/workspace/file-readers/raw-mime.js +38 -0
  357. package/dist/modules/workspace/file-readers/raw-mime.js.map +1 -0
  358. package/dist/modules/workspace/file-readers/text-reader.d.ts +5 -5
  359. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
  360. package/dist/modules/workspace/file-readers/text-reader.js +5 -8
  361. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
  362. package/dist/modules/workspace/first-run.d.ts +111 -0
  363. package/dist/modules/workspace/first-run.d.ts.map +1 -0
  364. package/dist/modules/workspace/first-run.js +181 -0
  365. package/dist/modules/workspace/first-run.js.map +1 -0
  366. package/dist/modules/workspace/move-links.d.ts +92 -0
  367. package/dist/modules/workspace/move-links.d.ts.map +1 -0
  368. package/dist/modules/workspace/move-links.js +173 -0
  369. package/dist/modules/workspace/move-links.js.map +1 -0
  370. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  371. package/dist/modules/workspace/startup/steps/seed-tree.js +24 -39
  372. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  373. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  374. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  375. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  376. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  377. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  378. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  379. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  380. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  381. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  382. package/dist/modules/workspace/workspace.routes.js +63 -22
  383. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  384. package/dist/modules/workspace/workspace.service.d.ts +75 -2
  385. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  386. package/dist/modules/workspace/workspace.service.js +197 -24
  387. package/dist/modules/workspace/workspace.service.js.map +1 -1
  388. package/dist/modules/workspace/workspace.tools.d.ts +68 -1
  389. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  390. package/dist/modules/workspace/workspace.tools.js +642 -71
  391. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  392. package/dist/modules/write-access/write-access.d.ts.map +1 -1
  393. package/dist/modules/write-access/write-access.js +9 -0
  394. package/dist/modules/write-access/write-access.js.map +1 -1
  395. package/dist/shared/domain-errors.d.ts +84 -1
  396. package/dist/shared/domain-errors.d.ts.map +1 -1
  397. package/dist/shared/domain-errors.js +100 -12
  398. package/dist/shared/domain-errors.js.map +1 -1
  399. package/dist/shared/git.contract.d.ts +10 -1
  400. package/dist/shared/git.contract.d.ts.map +1 -1
  401. package/dist/shared/git.contract.js.map +1 -1
  402. package/dist/shared/hidden-tools.d.ts +44 -0
  403. package/dist/shared/hidden-tools.d.ts.map +1 -0
  404. package/dist/shared/hidden-tools.js +13 -0
  405. package/dist/shared/hidden-tools.js.map +1 -0
  406. package/dist/shared/utf8-text.d.ts +15 -0
  407. package/dist/shared/utf8-text.d.ts.map +1 -0
  408. package/dist/shared/utf8-text.js +20 -0
  409. package/dist/shared/utf8-text.js.map +1 -0
  410. package/dist/tenancy/static-tenant-source.d.ts +2 -1
  411. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  412. package/dist/tenancy/static-tenant-source.js +9 -2
  413. package/dist/tenancy/static-tenant-source.js.map +1 -1
  414. package/kb-template/.bevelignore +0 -5
  415. package/mcp-app/page.html +349 -0
  416. package/migrations/0017_atlassian_account_links.sql +37 -0
  417. package/migrations/meta/0017_snapshot.json +2396 -0
  418. package/migrations/meta/_journal.json +8 -1
  419. package/package.json +6 -3
  420. package/src/__tests__/kb-layout-config.test.ts +10 -100
  421. package/src/__tests__/packaged-assets-ship.test.ts +66 -0
  422. package/src/assets.ts +38 -2
  423. package/src/core/__tests__/catalog-revision.test.ts +40 -0
  424. package/src/core/__tests__/gated-boot.test.ts +1 -0
  425. package/src/core/__tests__/lifecycle.test.ts +14 -0
  426. package/src/core/catalog-revision.ts +15 -2
  427. package/src/core/core-ports.ts +11 -0
  428. package/src/core/create-core-server.ts +78 -3
  429. package/src/core/create-core-services.ts +168 -10
  430. package/src/core/lifecycle.ts +28 -2
  431. package/src/core-config.ts +24 -1
  432. package/src/index.ts +2 -2
  433. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  434. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  435. package/src/modules/access/__tests__/access-control.service.test.ts +19 -0
  436. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  437. package/src/modules/access/access-control.interface.ts +28 -0
  438. package/src/modules/access/access-control.service.ts +37 -0
  439. package/src/modules/access/directory-sync-bot.ts +1 -1
  440. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +386 -0
  441. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +207 -0
  442. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  443. package/src/modules/agent-guide/agent-guide.ts +300 -0
  444. package/src/modules/agent-guide/index.ts +22 -0
  445. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +39 -118
  446. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  447. package/src/modules/agent-instructions/compose.ts +26 -8
  448. package/src/modules/agent-instructions/index.ts +0 -3
  449. package/src/modules/agent-instructions/shared-file-rules.ts +50 -112
  450. package/src/modules/audit/__tests__/agent-audit.service.test.ts +64 -4
  451. package/src/modules/audit/__tests__/agent-connection.routes.test.ts +102 -0
  452. package/src/modules/audit/agent-audit.service.ts +58 -3
  453. package/src/modules/audit/agent-connection.routes.ts +56 -0
  454. package/src/modules/audit/audit.contract.ts +26 -0
  455. package/src/modules/audit/index.ts +1 -0
  456. package/src/modules/auth/__tests__/account.routes.test.ts +5 -3
  457. package/src/modules/auth/account.routes.ts +10 -3
  458. package/src/modules/auth/auth.middleware.ts +1 -1
  459. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +18 -2
  460. package/src/modules/code-mode/code-mode.tool.ts +13 -2
  461. package/src/modules/database/core-schema.ts +33 -3
  462. package/src/modules/embed/__tests__/account-links.migration.test.ts +98 -0
  463. package/src/modules/embed/__tests__/embed-link.test.ts +122 -0
  464. package/src/modules/embed/__tests__/embed.routes.test.ts +362 -0
  465. package/src/modules/embed/__tests__/embed.service.lock.test.ts +193 -0
  466. package/src/modules/embed/__tests__/embed.service.test.ts +805 -0
  467. package/src/modules/embed/__tests__/embed.tools.test.ts +304 -0
  468. package/src/modules/embed/__tests__/mcp-app.test.ts +207 -0
  469. package/src/modules/embed/account-link.service.ts +76 -0
  470. package/src/modules/embed/embed-link.ts +180 -0
  471. package/src/modules/embed/embed.errors.ts +43 -0
  472. package/src/modules/embed/embed.interface.ts +203 -0
  473. package/src/modules/embed/embed.routes.ts +363 -0
  474. package/src/modules/embed/embed.service.ts +705 -0
  475. package/src/modules/embed/embed.tools.ts +206 -0
  476. package/src/modules/embed/mcp-app.routes.ts +41 -0
  477. package/src/modules/embed/mcp-app.ts +173 -0
  478. package/src/modules/github-app/__tests__/github-app.test.ts +76 -0
  479. package/src/modules/github-app/github-app.connection.ts +38 -4
  480. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +304 -0
  481. package/src/modules/kb-fs/locking-filesystem.ts +287 -0
  482. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +35 -0
  483. package/src/modules/kb-sync/kb-sync.interface.ts +2 -0
  484. package/src/modules/kb-sync/kb-sync.service.ts +6 -0
  485. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +68 -11
  486. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  487. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +37 -2
  488. package/src/modules/mcp/__tests__/mcp.app.e2e.test.ts +200 -0
  489. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +252 -0
  490. package/src/modules/mcp/__tests__/mcp.service.test.ts +42 -24
  491. package/src/modules/mcp/__tests__/tool-call-interface.e2e.test.ts +499 -0
  492. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  493. package/src/modules/mcp/mcp-auth.middleware.ts +2 -1
  494. package/src/modules/mcp/mcp.service.ts +218 -31
  495. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +42 -4
  496. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  497. package/src/modules/onboarding/__tests__/onboarding.routes.test.ts +163 -0
  498. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +635 -0
  499. package/src/modules/onboarding/__tests__/starter-packs.test.ts +169 -0
  500. package/src/modules/onboarding/onboarding.contract.ts +85 -0
  501. package/src/modules/onboarding/onboarding.routes.ts +85 -0
  502. package/src/modules/onboarding/starter-pack.service.ts +580 -0
  503. package/src/modules/onboarding/starter-packs.ts +182 -0
  504. package/src/modules/plugins/__tests__/plugins.tools.test.ts +177 -4
  505. package/src/modules/plugins/index.ts +1 -0
  506. package/src/modules/plugins/plugin-provision.service.ts +22 -1
  507. package/src/modules/plugins/plugins.routes.ts +29 -0
  508. package/src/modules/plugins/plugins.tools.ts +75 -15
  509. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +176 -56
  510. package/src/modules/settings/__tests__/repository-source.test.ts +9 -0
  511. package/src/modules/settings/deployment-settings.service.ts +144 -55
  512. package/src/modules/settings/repository-source.ts +4 -4
  513. package/src/modules/settings/setup.routes.ts +3 -6
  514. package/src/modules/skills/__tests__/skills.tools.description.test.ts +95 -0
  515. package/src/modules/skills/skills.tools.ts +112 -46
  516. package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +32 -0
  517. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +65 -0
  518. package/src/modules/tool-auth/connection-key-rejection.ts +13 -3
  519. package/src/modules/tool-auth/external-api-key.interface.ts +9 -0
  520. package/src/modules/tool-auth/external-api-key.service.ts +61 -4
  521. package/src/modules/tool-auth/tool-auth.middleware.ts +13 -4
  522. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +502 -0
  523. package/src/modules/tool-helpers/__tests__/route-argument-check.test.ts +337 -0
  524. package/src/modules/tool-helpers/index.ts +34 -1
  525. package/src/modules/tool-helpers/route-argument-check.ts +149 -0
  526. package/src/modules/tool-helpers/route-tool-schemas.ts +171 -0
  527. package/src/modules/tool-helpers/tool-def.ts +132 -7
  528. package/src/modules/tool-helpers/tool-handler.ts +196 -8
  529. package/src/modules/tool-helpers/tool.contract.ts +9 -0
  530. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  531. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  532. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  533. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  534. package/src/modules/tool-manuals/tool-manuals.tools.ts +86 -38
  535. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +173 -0
  536. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +81 -66
  537. package/src/modules/tool-registry/description-length.ts +57 -32
  538. package/src/modules/tool-registry/guide-first.ts +41 -0
  539. package/src/modules/tool-registry/tool-registry.ts +9 -2
  540. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  541. package/src/modules/workflow/__tests__/file-lock.service.deletion-gate.test.ts +104 -0
  542. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +66 -0
  543. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +31 -2
  544. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +110 -6
  545. package/src/modules/workflow/__tests__/sanitize-error.test.ts +89 -1
  546. package/src/modules/workflow/__tests__/workflow.routes.push-refused.test.ts +202 -0
  547. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +6 -6
  548. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +1026 -0
  549. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +468 -10
  550. package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +4 -0
  551. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +5 -4
  552. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +54 -2
  553. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  554. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1554 -0
  555. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +150 -4
  556. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  557. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +759 -0
  558. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  559. package/src/modules/workflow/agent-tools/workflow.tools.ts +137 -35
  560. package/src/modules/workflow/file-lock.service.ts +67 -5
  561. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  562. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  563. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +164 -0
  564. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  565. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +48 -1
  566. package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +231 -2
  567. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  568. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  569. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  570. package/src/modules/workflow/git/git.service.ts +714 -133
  571. package/src/modules/workflow/git/merge-commit.ts +88 -0
  572. package/src/modules/workflow/git/node-git-runner.ts +82 -7
  573. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  574. package/src/modules/workflow/pending-commits.service.ts +29 -1
  575. package/src/modules/workflow/pending-commits.worker.ts +24 -6
  576. package/src/modules/workflow/recovery-bot.ts +1 -1
  577. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  578. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  579. package/src/modules/workflow/review-workflow/review-workflow.service.ts +191 -12
  580. package/src/modules/workflow/sanitize-error.ts +50 -0
  581. package/src/modules/workflow/workflow.routes.ts +7 -2
  582. package/src/modules/workflow/workflow.service.ts +886 -112
  583. package/src/modules/workspace/__tests__/agent-downloads.test.ts +823 -0
  584. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  585. package/src/modules/workspace/__tests__/first-run.test.ts +234 -0
  586. package/src/modules/workspace/__tests__/git-internals.security.test.ts +5 -2
  587. package/src/modules/workspace/__tests__/md-links.test.ts +353 -0
  588. package/src/modules/workspace/__tests__/move-links.test.ts +261 -0
  589. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +219 -0
  590. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +3 -0
  591. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +102 -0
  592. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +52 -5
  593. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +190 -0
  594. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  595. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +8 -4
  596. package/src/modules/workspace/__tests__/workspace.service.test.ts +50 -8
  597. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  598. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +44 -1
  599. package/src/modules/workspace/__tests__/workspace.tools.test.ts +664 -34
  600. package/src/modules/workspace/agent-download.builder.ts +308 -0
  601. package/src/modules/workspace/agent-download.routes.ts +183 -0
  602. package/src/modules/workspace/agent-download.store.ts +533 -0
  603. package/src/modules/workspace/agent-upload.store.ts +23 -7
  604. package/src/modules/workspace/file-readers/raw-mime.ts +40 -0
  605. package/src/modules/workspace/file-readers/text-reader.ts +5 -8
  606. package/src/modules/workspace/first-run.ts +222 -0
  607. package/src/modules/workspace/move-links.ts +237 -0
  608. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  609. package/src/modules/workspace/startup/steps/seed-tree.ts +23 -38
  610. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  611. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  612. package/src/modules/workspace/workspace.routes.ts +64 -22
  613. package/src/modules/workspace/workspace.service.ts +202 -25
  614. package/src/modules/workspace/workspace.tools.ts +707 -68
  615. package/src/modules/write-access/__tests__/write-access.test.ts +13 -0
  616. package/src/modules/write-access/write-access.ts +9 -0
  617. package/src/shared/domain-errors.ts +123 -11
  618. package/src/shared/git.contract.ts +8 -1
  619. package/src/shared/hidden-tools.ts +45 -0
  620. package/src/shared/utf8-text.ts +21 -0
  621. package/src/tenancy/__tests__/static-tenant-source.test.ts +1 -0
  622. package/src/tenancy/static-tenant-source.ts +10 -2
  623. package/starter-packs/README.md +103 -0
  624. package/starter-packs/engineering/KnowledgeBase/About us.md +9 -0
  625. package/starter-packs/engineering/KnowledgeBase/Architecture.md +9 -0
  626. package/starter-packs/engineering/KnowledgeBase/Glossary.md +9 -0
  627. package/starter-packs/engineering/KnowledgeBase/How we ship.md +9 -0
  628. package/starter-packs/engineering/Plugins/engineering-starter/LICENSE +21 -0
  629. package/starter-packs/engineering/Plugins/engineering-starter/SOURCE.md +20 -0
  630. package/starter-packs/engineering/Plugins/engineering-starter/access.md +19 -0
  631. package/starter-packs/engineering/Plugins/engineering-starter/plugin.json +10 -0
  632. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/SKILL.md +171 -0
  633. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/sections/phase-2a-startup-diagnostic.md +157 -0
  634. package/starter-packs/engineering/Plugins/engineering-starter/skills/office-hours/sections/phase-2b-builder-brainstorm.md +39 -0
  635. package/starter-packs/engineering/Plugins/engineering-starter/skills/plan-ceo-review/SKILL.md +144 -0
  636. package/starter-packs/engineering/Plugins/engineering-starter/skills/plan-eng-review/SKILL.md +144 -0
  637. package/starter-packs/engineering/Plugins/engineering-starter/skills/review/SKILL.md +80 -0
  638. package/starter-packs/engineering/Plugins/engineering-starter/skills/review/checklist.md +184 -0
  639. package/starter-packs/engineering/pack.yaml +11 -0
  640. package/starter-packs/general/KnowledgeBase/About us.md +9 -0
  641. package/starter-packs/general/KnowledgeBase/Glossary.md +9 -0
  642. package/starter-packs/general/KnowledgeBase/How we work.md +9 -0
  643. package/starter-packs/general/Plugins/general-starter/LICENSE +202 -0
  644. package/starter-packs/general/Plugins/general-starter/SOURCE.md +10 -0
  645. package/starter-packs/general/Plugins/general-starter/access.md +19 -0
  646. package/starter-packs/general/Plugins/general-starter/plugin.json +10 -0
  647. package/starter-packs/general/Plugins/general-starter/skills/memory-management/SKILL.md +332 -0
  648. package/starter-packs/general/Plugins/general-starter/skills/productivity-start/SKILL.md +163 -0
  649. package/starter-packs/general/Plugins/general-starter/skills/productivity-update/SKILL.md +167 -0
  650. package/starter-packs/general/Plugins/general-starter/skills/task-management/SKILL.md +83 -0
  651. package/starter-packs/general/pack.yaml +10 -0
  652. package/starter-packs/operations/KnowledgeBase/Glossary.md +9 -0
  653. package/starter-packs/operations/KnowledgeBase/How we work.md +9 -0
  654. package/starter-packs/operations/KnowledgeBase/Processes.md +9 -0
  655. package/starter-packs/operations/KnowledgeBase/Tools we use.md +9 -0
  656. package/starter-packs/operations/KnowledgeBase/Vendors.md +9 -0
  657. package/starter-packs/operations/Plugins/operations-starter/LICENSE +202 -0
  658. package/starter-packs/operations/Plugins/operations-starter/SOURCE.md +10 -0
  659. package/starter-packs/operations/Plugins/operations-starter/access.md +19 -0
  660. package/starter-packs/operations/Plugins/operations-starter/plugin.json +10 -0
  661. package/starter-packs/operations/Plugins/operations-starter/skills/capacity-plan/SKILL.md +111 -0
  662. package/starter-packs/operations/Plugins/operations-starter/skills/change-request/SKILL.md +119 -0
  663. package/starter-packs/operations/Plugins/operations-starter/skills/compliance-tracking/SKILL.md +44 -0
  664. package/starter-packs/operations/Plugins/operations-starter/skills/process-doc/SKILL.md +80 -0
  665. package/starter-packs/operations/Plugins/operations-starter/skills/process-optimization/SKILL.md +39 -0
  666. package/starter-packs/operations/Plugins/operations-starter/skills/risk-assessment/SKILL.md +40 -0
  667. package/starter-packs/operations/Plugins/operations-starter/skills/runbook/SKILL.md +85 -0
  668. package/starter-packs/operations/Plugins/operations-starter/skills/status-report/SKILL.md +75 -0
  669. package/starter-packs/operations/Plugins/operations-starter/skills/vendor-review/SKILL.md +102 -0
  670. package/starter-packs/operations/pack.yaml +12 -0
  671. package/starter-packs/sales/KnowledgeBase/About us.md +9 -0
  672. package/starter-packs/sales/KnowledgeBase/Customers.md +9 -0
  673. package/starter-packs/sales/KnowledgeBase/Glossary.md +9 -0
  674. package/starter-packs/sales/KnowledgeBase/How we sell.md +9 -0
  675. package/starter-packs/sales/KnowledgeBase/Objections.md +9 -0
  676. package/starter-packs/sales/KnowledgeBase/Pricing.md +9 -0
  677. package/starter-packs/sales/Plugins/sales-starter/LICENSE +202 -0
  678. package/starter-packs/sales/Plugins/sales-starter/SOURCE.md +10 -0
  679. package/starter-packs/sales/Plugins/sales-starter/access.md +19 -0
  680. package/starter-packs/sales/Plugins/sales-starter/plugin.json +10 -0
  681. package/starter-packs/sales/Plugins/sales-starter/skills/account-context/SKILL.md +94 -0
  682. package/starter-packs/sales/Plugins/sales-starter/skills/account-plan/SKILL.md +95 -0
  683. package/starter-packs/sales/Plugins/sales-starter/skills/account-research/SKILL.md +99 -0
  684. package/starter-packs/sales/Plugins/sales-starter/skills/account-tiering/SKILL.md +101 -0
  685. package/starter-packs/sales/Plugins/sales-starter/skills/call-prep/SKILL.md +111 -0
  686. package/starter-packs/sales/Plugins/sales-starter/skills/call-summary/SKILL.md +111 -0
  687. package/starter-packs/sales/Plugins/sales-starter/skills/close-plan/SKILL.md +106 -0
  688. package/starter-packs/sales/Plugins/sales-starter/skills/competitive-intelligence/SKILL.md +102 -0
  689. package/starter-packs/sales/Plugins/sales-starter/skills/create-an-asset/SKILL.md +105 -0
  690. package/starter-packs/sales/Plugins/sales-starter/skills/crm-hygiene-check/SKILL.md +103 -0
  691. package/starter-packs/sales/Plugins/sales-starter/skills/customer-health/SKILL.md +108 -0
  692. package/starter-packs/sales/Plugins/sales-starter/skills/customer-voice/SKILL.md +94 -0
  693. package/starter-packs/sales/Plugins/sales-starter/skills/daily-briefing/SKILL.md +61 -0
  694. package/starter-packs/sales/Plugins/sales-starter/skills/deal-advance-gap/SKILL.md +102 -0
  695. package/starter-packs/sales/Plugins/sales-starter/skills/deal-review/SKILL.md +105 -0
  696. package/starter-packs/sales/Plugins/sales-starter/skills/deal-signals/SKILL.md +96 -0
  697. package/starter-packs/sales/Plugins/sales-starter/skills/deal-slip-scenario/SKILL.md +104 -0
  698. package/starter-packs/sales/Plugins/sales-starter/skills/draft-outreach/SKILL.md +116 -0
  699. package/starter-packs/sales/Plugins/sales-starter/skills/end-of-day/SKILL.md +114 -0
  700. package/starter-packs/sales/Plugins/sales-starter/skills/expansion-whitespace/SKILL.md +97 -0
  701. package/starter-packs/sales/Plugins/sales-starter/skills/forecast/SKILL.md +100 -0
  702. package/starter-packs/sales/Plugins/sales-starter/skills/handle-objection/SKILL.md +93 -0
  703. package/starter-packs/sales/Plugins/sales-starter/skills/inbox-sweep/SKILL.md +114 -0
  704. package/starter-packs/sales/Plugins/sales-starter/skills/lead-triage/SKILL.md +106 -0
  705. package/starter-packs/sales/Plugins/sales-starter/skills/log-activity/SKILL.md +111 -0
  706. package/starter-packs/sales/Plugins/sales-starter/skills/pipeline-review/SKILL.md +97 -0
  707. package/starter-packs/sales/Plugins/sales-starter/skills/renewal-radar/SKILL.md +95 -0
  708. package/starter-packs/sales/Plugins/sales-starter/skills/rep-context/SKILL.md +90 -0
  709. package/starter-packs/sales/Plugins/sales-starter/skills/route-lead/SKILL.md +111 -0
  710. package/starter-packs/sales/Plugins/sales-starter/skills/sales-setup/SKILL.md +200 -0
  711. package/starter-packs/sales/Plugins/sales-starter/skills/schedule-meeting/SKILL.md +99 -0
  712. package/starter-packs/sales/Plugins/sales-starter/skills/stakeholder-map/SKILL.md +107 -0
  713. package/starter-packs/sales/Plugins/sales-starter/skills/team-pipeline/SKILL.md +105 -0
  714. package/starter-packs/sales/Plugins/sales-starter/skills/update-opportunity/SKILL.md +112 -0
  715. package/starter-packs/sales/Plugins/sales-starter/skills/weekly-wrap/SKILL.md +90 -0
  716. package/starter-packs/sales/Plugins/sales-starter/skills/win-loss-review/SKILL.md +88 -0
  717. package/starter-packs/sales/pack.yaml +13 -0
  718. package/starter-packs/support/KnowledgeBase/Escalations.md +9 -0
  719. package/starter-packs/support/KnowledgeBase/Glossary.md +9 -0
  720. package/starter-packs/support/KnowledgeBase/Known issues.md +9 -0
  721. package/starter-packs/support/KnowledgeBase/Products.md +9 -0
  722. package/starter-packs/support/KnowledgeBase/Tone of voice.md +9 -0
  723. package/starter-packs/support/Plugins/support-starter/LICENSE +202 -0
  724. package/starter-packs/support/Plugins/support-starter/SOURCE.md +10 -0
  725. package/starter-packs/support/Plugins/support-starter/access.md +19 -0
  726. package/starter-packs/support/Plugins/support-starter/plugin.json +10 -0
  727. package/starter-packs/support/Plugins/support-starter/skills/customer-escalation/SKILL.md +246 -0
  728. package/starter-packs/support/Plugins/support-starter/skills/customer-research/SKILL.md +250 -0
  729. package/starter-packs/support/Plugins/support-starter/skills/draft-response/SKILL.md +416 -0
  730. package/starter-packs/support/Plugins/support-starter/skills/kb-article/SKILL.md +350 -0
  731. package/starter-packs/support/Plugins/support-starter/skills/ticket-triage/SKILL.md +273 -0
  732. package/starter-packs/support/pack.yaml +12 -0
  733. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,1554 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import type {
5
+ ChangeRequest,
6
+ ChangeRequestComment,
7
+ ChangeRequestDetail,
8
+ ChangeRequestState,
9
+ ChangedFile,
10
+ FileApproval,
11
+ } from '@bevel-software/platform-shared';
12
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
13
+ import { hashEmail } from '../../../../shared/email-identity.js';
14
+ import { ToolRegistry } from '../../../tool-registry/tool-registry.js';
15
+ import { InternalTokenService } from '../../../tool-auth/internal-token.service.js';
16
+ import { createToolAuthMiddleware } from '../../../tool-auth/tool-auth.middleware.js';
17
+ import { createToolContextResolver } from '../../../tool-helpers/tool-context.js';
18
+ import { createToolHandlerFactory } from '../../../tool-helpers/tool-handler.js';
19
+ import { registerChangeRequestReadTools } from '../change-request-read.tools.js';
20
+ import { APPLY_FAILURE_REASON_WITHHELD } from '../change-request-read-shape.js';
21
+
22
+ /** The signed-in caller of every test below, unless it says otherwise. */
23
+ const VIEWER = 'mia@bevel.software';
24
+ const AUTHOR = 'juan@bevel.software';
25
+
26
+ const TOOLS = [
27
+ 'list_change_requests',
28
+ 'get_change_request',
29
+ 'list_change_request_files',
30
+ 'list_change_request_reviews',
31
+ 'list_change_request_comments',
32
+ ] as const;
33
+
34
+ // ── The world each test sets up ─────────────────────────────────────────────
35
+
36
+ /** Repo-relative paths THIS caller may read at `origin/<base>`. Null = the access tree does not resolve. */
37
+ let readable: string[] | null = [];
38
+ /** The summaries `listChangeRequestsByState` answers with, per state asked for. */
39
+ let summaries: ChangeRequest[] = [];
40
+ /** The details `getChangeRequestDetail` answers with, by number. */
41
+ let details = new Map<number, ChangeRequestDetail>();
42
+ /** Every service call the tools made, so a test can prove none of them wrote. */
43
+ let calls: unknown[][] = [];
44
+
45
+ function file(path: string, over: Partial<ChangedFile> = {}): ChangedFile {
46
+ return {
47
+ path,
48
+ status: 'modified',
49
+ additions: 2,
50
+ deletions: 1,
51
+ isBinary: false,
52
+ sha: `blob-${path}`,
53
+ rawUrl: '',
54
+ patch: `@@ ${path} @@`,
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ function approval(path: string, over: Partial<FileApproval> = {}): FileApproval {
60
+ return {
61
+ path,
62
+ eligibleApprovers: { roles: ['Engineering'], users: [] },
63
+ approvedBy: [],
64
+ eligibilityResolved: true,
65
+ isApproved: false,
66
+ inMergeGate: true,
67
+ viewerCanApprove: false,
68
+ ...over,
69
+ };
70
+ }
71
+
72
+ /** A comment as the detail carries one. Module-level: the rename suite needs it too. */
73
+ function comment(over: Partial<ChangeRequestComment>): ChangeRequestComment {
74
+ return {
75
+ id: 'c-1',
76
+ author: { email: VIEWER, name: 'Mia' },
77
+ body: 'This line is out of date.',
78
+ headSha: 'head-1',
79
+ createdAt: '2026-09-29T08:00:00.000Z',
80
+ ...over,
81
+ };
82
+ }
83
+
84
+ function approvedBy(email: string, name: string, at: string, isStale = false) {
85
+ return { email, name, approvedAt: at, isStale, isSelfApproval: false };
86
+ }
87
+
88
+ /**
89
+ * A summary as `PullRequestService.summaryOf` builds one — `touchedNodeFiles`
90
+ * included, derived from `touchedNodePaths` unless a test overrides it. A
91
+ * fixture that left it out would be testing a summary no builder produces, and
92
+ * would hide whether the list reads it at all.
93
+ */
94
+ function summary(over: Partial<ChangeRequest> = {}): ChangeRequest {
95
+ const paths = over.touchedNodePaths ?? ['Knowledge/A.md'];
96
+ return {
97
+ touchedNodeFiles: paths.map((path) => ({ path })),
98
+ number: 12,
99
+ title: 'Rework the onboarding note',
100
+ authorId: hashEmail(AUTHOR),
101
+ author: { login: `user-${hashEmail(AUTHOR).slice(0, 12)}`, name: 'Juan' },
102
+ appAuthor: { name: 'Juan' },
103
+ branch: 'juan/my-draft',
104
+ base: 'main',
105
+ state: 'open',
106
+ createdAt: '2026-09-28T10:00:00.000Z',
107
+ updatedAt: '2026-09-28T10:00:00.000Z',
108
+ touchedNodePaths: ['Knowledge/A.md'],
109
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
110
+ url: 'https://hexis.example.com/change-requests/12',
111
+ ...over,
112
+ };
113
+ }
114
+
115
+ function detail(over: Partial<ChangeRequestDetail> = {}): ChangeRequestDetail {
116
+ const files = over.files ?? [file('Knowledge/A.md')];
117
+ return {
118
+ ...summary(over),
119
+ // `getPrDetail` pairs the summary off its real file list, so a detail used
120
+ // as a list row carries the same files its own file tool would answer.
121
+ touchedNodeFiles: files.map((f) => ({
122
+ path: f.path,
123
+ ...(f.previousPath ? { previousPath: f.previousPath } : {}),
124
+ })),
125
+ body: 'Why this change is needed.',
126
+ headSha: 'head-1',
127
+ baseSha: 'base-1',
128
+ files,
129
+ comments: [],
130
+ approvals: files.map((f) => approval(f.path)),
131
+ mergeableInBevel: false,
132
+ mergeBlockedReasons: [],
133
+ mergeWarnings: [],
134
+ viewerCanBypassMerge: false,
135
+ viewerCanCancel: false,
136
+ mergeBaseSha: 'fork-1',
137
+ behind: false,
138
+ needsUpdate: false,
139
+ viewerCanUpdate: false,
140
+ viewerIsAuthor: false,
141
+ viewerCanDelete: false,
142
+ ...over,
143
+ };
144
+ }
145
+
146
+ // ── Harness ─────────────────────────────────────────────────────────────────
147
+
148
+ const externalApiKeyService = {
149
+ looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
150
+ verifyAndLoadToken: async () => null,
151
+ } as never;
152
+
153
+ /** The caller the internal token resolves to — swapped by `asUser`. */
154
+ let callerEmail = VIEWER;
155
+ const authService = {
156
+ getUserById: async (id: string) => ({ id, email: callerEmail, name: 'Caller' }),
157
+ } as never;
158
+
159
+ /** The clone the workspace service knows of — null for a deployment where none has been created yet. */
160
+ let anyWorkspaceId: string | null = 'existing-ws';
161
+ const workspaceService = {
162
+ // The read tools resolve any existing clone: every verdict is read at
163
+ // `origin/<base>`, so no draft has to be cloned to answer.
164
+ findAnyWorkspaceId: async () => anyWorkspaceId,
165
+ getOrCreateForUser: async () => ({ id: 'existing-ws' }),
166
+ getWorkspacePath: async () => '/tmp/ws',
167
+ getOrCreateForBranch: async (b: string) => ({ id: b }),
168
+ } as never;
169
+
170
+ /** The workspace each listing was asked to read in, in order. */
171
+ let listedIn: Array<string | undefined> = [];
172
+ const workflowService = {
173
+ listChangeRequestsByState: async (states: ChangeRequestState[], opts?: { workspaceId?: string }) => {
174
+ calls.push(['listChangeRequestsByState', [...states].sort()]);
175
+ listedIn.push(opts?.workspaceId);
176
+ return summaries.filter((s) => states.includes(s.state));
177
+ },
178
+ getChangeRequestDetail: async (
179
+ number: number,
180
+ opts: { patches?: boolean; viewerEmail?: string },
181
+ ) => {
182
+ calls.push(['getChangeRequestDetail', number, opts.patches, opts.viewerEmail]);
183
+ const found = details.get(number);
184
+ if (!found) return null;
185
+ // Mirror the service: `patches: false` means no patch is generated at all.
186
+ if (opts.patches !== false) return found;
187
+ return {
188
+ ...found,
189
+ files: found.files.map((f) => {
190
+ const withoutPatch = { ...f };
191
+ delete withoutPatch.patch;
192
+ return withoutPatch;
193
+ }),
194
+ };
195
+ },
196
+ } as never;
197
+
198
+ /** Every path the access tree was asked about, across the call under test. */
199
+ let accessAskedFor: string[] = [];
200
+
201
+ const accessControl = {
202
+ canReadBatchAtRef: async (ws: string, ref: string, email: string, paths: string[]) => {
203
+ calls.push(['canReadBatchAtRef', ws, ref, email]);
204
+ accessAskedFor.push(...paths);
205
+ if (readable === null) return null;
206
+ return new Map(paths.map((p) => [p, readable!.includes(p)]));
207
+ },
208
+ };
209
+
210
+ const events = { emit: () => ({}) } as never;
211
+ const internalToken = new InternalTokenService({ secret: 's' });
212
+
213
+ let httpServer: HttpServer | undefined;
214
+ let registryRef: ToolRegistry | undefined;
215
+
216
+ async function start(): Promise<string> {
217
+ const registry = new ToolRegistry();
218
+ registryRef = registry;
219
+ const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
220
+ const resolve = createToolContextResolver({
221
+ authService,
222
+ workspaceService,
223
+ workflowService,
224
+ events,
225
+ kbDirName: 'knowledge-base',
226
+ creatorAccess: {
227
+ planForCreate: async () => null,
228
+ grantInExtractedFile: async () => null,
229
+ noteAccessFileWritten: () => {},
230
+ },
231
+ } as never);
232
+ const toolHandler = createToolHandlerFactory(resolve);
233
+
234
+ const router = express.Router();
235
+ registerChangeRequestReadTools(registry, router, toolAuth, toolHandler, accessControl, testKbContext());
236
+
237
+ const app = express();
238
+ app.use(express.json());
239
+ app.use('/api', router);
240
+ httpServer = await new Promise<HttpServer>((r) => {
241
+ const s = app.listen(0, () => r(s));
242
+ });
243
+ return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
244
+ }
245
+
246
+ const tok = () => internalToken.mint({ userId: 'user-A' });
247
+
248
+ async function call(
249
+ base: string,
250
+ tool: (typeof TOOLS)[number],
251
+ body: unknown = {},
252
+ ): Promise<{ status: number; json: Record<string, never> }> {
253
+ const res = await fetch(`${base}/api/agent/tools/${tool}`, {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${tok()}` },
256
+ body: JSON.stringify(body),
257
+ });
258
+ return { status: res.status, json: (await res.json()) as Record<string, never> };
259
+ }
260
+
261
+ beforeEach(() => {
262
+ readable = ['Knowledge/A.md'];
263
+ summaries = [];
264
+ details = new Map();
265
+ calls = [];
266
+ accessAskedFor = [];
267
+ callerEmail = VIEWER;
268
+ });
269
+ afterEach(async () => {
270
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
271
+ httpServer = undefined;
272
+ registryRef = undefined;
273
+ });
274
+
275
+ // ── The catalog ─────────────────────────────────────────────────────────────
276
+
277
+ describe('the five read tools', () => {
278
+ it('are all registered, on both surfaces, by their GitHub-counterpart names', async () => {
279
+ await start();
280
+ const internal = (await registryRef!.listInternal()).map((t) => t.name);
281
+ const external = (await registryRef!.listExternal()).map((t) => t.name);
282
+ for (const name of TOOLS) {
283
+ expect(internal, name).toContain(name);
284
+ expect(external, name).toContain(name);
285
+ }
286
+ });
287
+
288
+ // "None of the tools changes anything." A `write`-tagged tool is refused to
289
+ // read-scoped callers; these must carry no such tag, because none writes.
290
+ it('carry no `write` tag, so a read-scoped caller may use every one', async () => {
291
+ await start();
292
+ for (const def of await registryRef!.listExternal()) {
293
+ if (!(TOOLS as readonly string[]).includes(def.name)) continue;
294
+ expect(def.tags, def.name).not.toContain('write');
295
+ }
296
+ });
297
+
298
+ it('name no branch: each is keyed by a change-request number, or by nothing', async () => {
299
+ await start();
300
+ for (const def of await registryRef!.listExternal()) {
301
+ if (!(TOOLS as readonly string[]).includes(def.name)) continue;
302
+ const body = (def.inputs as { properties: { body: { properties: Record<string, unknown> } } })
303
+ .properties.body;
304
+ expect(Object.keys(body.properties), def.name).not.toContain('branch');
305
+ }
306
+ });
307
+
308
+ it('call only reads on the workflow service', async () => {
309
+ const base = await start();
310
+ summaries = [summary()];
311
+ details.set(12, detail());
312
+ for (const tool of TOOLS) {
313
+ await call(base, tool, tool === 'list_change_requests' ? {} : { number: 12 });
314
+ }
315
+ const methods = new Set(calls.map((c) => c[0]));
316
+ expect([...methods].sort()).toEqual([
317
+ 'canReadBatchAtRef',
318
+ 'getChangeRequestDetail',
319
+ 'listChangeRequestsByState',
320
+ ]);
321
+ });
322
+ });
323
+
324
+ // ── Scenario: list by state and source branch ───────────────────────────────
325
+
326
+ describe('list_change_requests', () => {
327
+ // WHEN an agent calls `list_change_requests` with `state: open` and
328
+ // `head: juan/my-draft` THEN it gets the open request from that branch — in
329
+ // HEXIS's field names, the ones `open_change_request` answers in: `url`
330
+ // first, `number`, `title`, `state`, `author`, `sourceBranch`,
331
+ // `targetBranch`, `createdAt`, `updatedAt`.
332
+ it("answers the open request from the named source branch, in Hexis's field names", async () => {
333
+ const base = await start();
334
+ summaries = [
335
+ summary(),
336
+ summary({ number: 13, branch: 'juan/other', touchedNodePaths: ['Knowledge/A.md'] }),
337
+ ];
338
+ const { status, json } = await call(base, 'list_change_requests', {
339
+ state: 'open',
340
+ head: 'juan/my-draft',
341
+ });
342
+ expect(status).toBe(200);
343
+ expect(json).toMatchObject({ totalCount: 1, page: 1, perPage: 30, hasNextPage: false });
344
+ expect(json.changeRequests).toEqual([
345
+ {
346
+ url: 'https://hexis.example.com/change-requests/12',
347
+ number: 12,
348
+ title: 'Rework the onboarding note',
349
+ state: 'open',
350
+ author: { login: `user-${hashEmail(AUTHOR).slice(0, 12)}`, name: 'Juan' },
351
+ sourceBranch: 'juan/my-draft',
352
+ targetBranch: 'main',
353
+ createdAt: '2026-09-28T10:00:00.000Z',
354
+ updatedAt: '2026-09-28T10:00:00.000Z',
355
+ changedFiles: 1,
356
+ withheldFiles: 0,
357
+ },
358
+ ]);
359
+ // No GitHub field names anywhere in the answer, and no `merged` flag: the
360
+ // state says it.
361
+ const row = json.changeRequests[0] as Record<string, unknown>;
362
+ for (const gh of ['html_url', 'user', 'head', 'base', 'merged', 'created_at', 'changed_files']) {
363
+ expect(row).not.toHaveProperty(gh);
364
+ }
365
+ // `url` is the first key, so a truncated answer still carries the link.
366
+ expect(Object.keys(row)[0]).toBe('url');
367
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['open']]);
368
+ });
369
+
370
+ it('defaults to the open ones, as GitHub does', async () => {
371
+ const base = await start();
372
+ summaries = [summary(), summary({ number: 13, state: 'merged' })];
373
+ await call(base, 'list_change_requests', {});
374
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['open']]);
375
+ });
376
+
377
+ it('`state: closed` asks for the applied and the declined alike; `all` for everything', async () => {
378
+ const base = await start();
379
+ await call(base, 'list_change_requests', { state: 'closed' });
380
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged']]);
381
+ calls = [];
382
+ await call(base, 'list_change_requests', { state: 'all' });
383
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged', 'open']]);
384
+ });
385
+
386
+ it('refuses a `state` it does not know instead of answering the open ones', async () => {
387
+ const base = await start();
388
+ // `merged` is what the ANSWER says for an applied request, so it is the
389
+ // word an agent is most likely to feed back in; a typo is refused too.
390
+ // The route's argument check answers first — the schema pins `state` to
391
+ // its three values — and names them in its refusal; the handler's own
392
+ // check beneath it is the floor for a caller that reaches it another way.
393
+ for (const state of ['merged', 'applied', 'OPEN', '', 7, null]) {
394
+ calls = [];
395
+ const { status, json } = await call(base, 'list_change_requests', { state });
396
+ expect(status, JSON.stringify(state)).toBe(400);
397
+ expect((json as { kind?: string }).kind, JSON.stringify(state)).toBe('arguments-do-not-match');
398
+ // The three values are named either in the mismatch line (a wrong
399
+ // string) or in the interface the refusal carries (a wrong type).
400
+ expect(JSON.stringify(json), JSON.stringify(state)).toMatch(/open.*closed.*all/);
401
+ expect(calls, JSON.stringify(state)).toEqual([]);
402
+ }
403
+ });
404
+
405
+ it('filters by target branch and by author', async () => {
406
+ const base = await start();
407
+ summaries = [
408
+ summary(),
409
+ summary({ number: 13, base: 'release', touchedNodePaths: ['Knowledge/A.md'] }),
410
+ summary({
411
+ number: 14,
412
+ authorId: hashEmail('ana@bevel.software'),
413
+ author: { login: 'user-ana', name: 'Ana' },
414
+ appAuthor: { name: 'Ana' },
415
+ touchedNodePaths: ['Knowledge/A.md'],
416
+ }),
417
+ ];
418
+ const byBase = await call(base, 'list_change_requests', { base: 'release' });
419
+ expect(byBase.json.changeRequests).toHaveLength(1);
420
+ expect((byBase.json.changeRequests as unknown as { number: number }[])[0].number).toBe(13);
421
+
422
+ const byAuthor = await call(base, 'list_change_requests', { author: AUTHOR });
423
+ expect((byAuthor.json.changeRequests as unknown as { number: number }[]).map((c) => c.number))
424
+ .toEqual([12, 13]);
425
+ });
426
+
427
+ it('resolves each target branch once, not each request', async () => {
428
+ const base = await start();
429
+ summaries = [
430
+ summary({ number: 12 }),
431
+ summary({ number: 13 }),
432
+ summary({ number: 14, base: 'release' }),
433
+ ];
434
+ await call(base, 'list_change_requests', { state: 'open' });
435
+ const refs = calls.filter((c) => c[0] === 'canReadBatchAtRef').map((c) => c[2]);
436
+ expect(refs.sort()).toEqual(['origin/main', 'origin/release']);
437
+ });
438
+
439
+ it('leaves out a request whose every file is closed to the caller', async () => {
440
+ const base = await start();
441
+ readable = [];
442
+ summaries = [summary()];
443
+ const { json } = await call(base, 'list_change_requests', {});
444
+ expect(json.changeRequests).toEqual([]);
445
+ expect(json.totalCount).toBe(0);
446
+ });
447
+
448
+ it('lists in the same clone the by-number tools read in — the default one when none exists yet', async () => {
449
+ // The by-number tools fall back to the default workspace; a listing that
450
+ // fell back to nothing built every file list empty and hid every request
451
+ // the caller did not author, while `get_change_request` served them. One
452
+ // resolution, handed to both.
453
+ const base = await start();
454
+ summaries = [summary()];
455
+ listedIn = [];
456
+ await call(base, 'list_change_requests', {});
457
+ expect(listedIn).toEqual(['existing-ws']);
458
+ anyWorkspaceId = null;
459
+ try {
460
+ listedIn = [];
461
+ await call(base, 'list_change_requests', {});
462
+ expect(listedIn).toEqual([testKbContext().defaultWorkspaceId()]);
463
+ } finally {
464
+ anyWorkspaceId = 'existing-ws';
465
+ }
466
+ });
467
+
468
+ it('still shows the author their own request when they may read none of it', async () => {
469
+ const base = await start();
470
+ readable = [];
471
+ callerEmail = AUTHOR;
472
+ summaries = [summary()];
473
+ const { json } = await call(base, 'list_change_requests', {});
474
+ expect(json.changeRequests).toHaveLength(1);
475
+ expect((json.changeRequests as unknown as { withheldFiles: number }[])[0].withheldFiles).toBe(1);
476
+ });
477
+
478
+ it('counts the files it withheld without naming them', async () => {
479
+ const base = await start();
480
+ readable = ['Knowledge/A.md'];
481
+ summaries = [summary({ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'] })];
482
+ const { json } = await call(base, 'list_change_requests', {});
483
+ expect(json.changeRequests).toHaveLength(1);
484
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 1, withheldFiles: 1 });
485
+ expect(JSON.stringify(json)).not.toContain('Payroll');
486
+ });
487
+
488
+ it('tells why the last Apply failed, in its own words only when every file is readable', async () => {
489
+ const base = await start();
490
+ const failure = { reason: 'Conflicts in Payroll/Rates.md', conflicts: true, at: '2026-09-29T09:00:00.000Z' };
491
+ readable = ['Knowledge/A.md'];
492
+ summaries = [summary({ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'], lastApplyFailure: failure })];
493
+ const withheld = await call(base, 'list_change_requests', {});
494
+ expect(withheld.json.changeRequests[0]).toMatchObject({
495
+ lastApplyFailure: { reason: APPLY_FAILURE_REASON_WITHHELD, conflicts: true, at: failure.at },
496
+ });
497
+ expect(JSON.stringify(withheld.json)).not.toContain('Payroll');
498
+ // The detail withholds on the same verdict, through its own path.
499
+ details.set(
500
+ 12,
501
+ detail({
502
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
503
+ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'],
504
+ lastApplyFailure: failure,
505
+ }),
506
+ );
507
+ const detailWithheld = (await call(base, 'get_change_request', { number: 12 })).json;
508
+ expect(detailWithheld).toMatchObject({ lastApplyFailure: { reason: APPLY_FAILURE_REASON_WITHHELD } });
509
+ expect(JSON.stringify(detailWithheld)).not.toContain('Payroll');
510
+ readable = ['Knowledge/A.md', 'Payroll/Rates.md'];
511
+ const shown = await call(base, 'list_change_requests', {});
512
+ expect(shown.json.changeRequests[0]).toMatchObject({ lastApplyFailure: failure });
513
+ expect((await call(base, 'get_change_request', { number: 12 })).json).toMatchObject({ lastApplyFailure: failure });
514
+ });
515
+
516
+ it('pages after the access filter, so a page length says nothing about what was withheld', async () => {
517
+ const base = await start();
518
+ readable = ['Knowledge/A.md'];
519
+ summaries = Array.from({ length: 40 }, (_, i) =>
520
+ summary({ number: i + 1, touchedNodePaths: i % 2 === 0 ? ['Knowledge/A.md'] : ['Payroll/Rates.md'] }),
521
+ );
522
+ const { json } = await call(base, 'list_change_requests', {});
523
+ // 20 of the 40 are visible — a full page of 30 would have been the giveaway.
524
+ expect(json.totalCount).toBe(20);
525
+ expect(json.changeRequests).toHaveLength(20);
526
+ expect(json.hasNextPage).toBe(false);
527
+ });
528
+ });
529
+
530
+ // ── Scenario: read one request ──────────────────────────────────────────────
531
+
532
+ describe('get_change_request', () => {
533
+ // WHEN it calls `get_change_request` with that number THEN it also gets
534
+ // `body`, `mergeable`, the blockers and the `viewer` block — unwrapped, the
535
+ // way `open_change_request` answers since #349.
536
+ it('adds body, mergeable, the blockers and the viewer block', async () => {
537
+ const base = await start();
538
+ const waiting = ['Waiting on approval for Knowledge/A.md from Engineering.'];
539
+ details.set(
540
+ 12,
541
+ detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting, mergeableInBevel: false }),
542
+ );
543
+ const { status, json } = await call(base, 'get_change_request', { number: 12 });
544
+ expect(status).toBe(200);
545
+ expect(json).toMatchObject({
546
+ url: 'https://hexis.example.com/change-requests/12',
547
+ number: 12,
548
+ title: 'Rework the onboarding note',
549
+ state: 'open',
550
+ body: 'Why this change is needed.',
551
+ sourceBranch: 'juan/my-draft',
552
+ targetBranch: 'main',
553
+ headSha: 'head-1',
554
+ baseSha: 'base-1',
555
+ mergeable: false,
556
+ mergeBlockedReasons: waiting,
557
+ withheldMergeBlockedReasons: 0,
558
+ viewer: { mayApprove: false, mayMerge: false, isAuthor: false },
559
+ });
560
+ // Not wrapped in `change_request`, and `url` still first.
561
+ expect(json).not.toHaveProperty('change_request');
562
+ expect(Object.keys(json)[0]).toBe('url');
563
+ });
564
+
565
+ it('tells the author it is theirs, and an approver that they may approve', async () => {
566
+ const base = await start();
567
+ callerEmail = AUTHOR;
568
+ details.set(12, detail({ approvals: [approval('Knowledge/A.md', { viewerCanApprove: true })] }));
569
+ const { json } = await call(base, 'get_change_request', { number: 12 });
570
+ expect(json).toMatchObject({ viewer: { isAuthor: true, mayApprove: true } });
571
+ });
572
+
573
+ // WHEN the request is merged THEN `state` says `merged` — Hexis's own state,
574
+ // not GitHub's `closed` plus a flag a reader has to look for.
575
+ it('reports a merged request as merged, with no flag to cross-read', async () => {
576
+ const base = await start();
577
+ details.set(
578
+ 12,
579
+ detail({
580
+ state: 'merged',
581
+ mergeBlockedReasons: ['This pull request has already been merged.'],
582
+ }),
583
+ );
584
+ const { json } = await call(base, 'get_change_request', { number: 12 });
585
+ expect(json).toMatchObject({ state: 'merged', viewer: { mayMerge: false } });
586
+ expect(json).not.toHaveProperty('merged');
587
+ });
588
+
589
+ // An applied request is normally read from its merge commit (the 2026-10-02
590
+ // decision), so this is the corner that is left: a row whose file set could not
591
+ // be resolved AT ALL — no `merged_sha`, or a clone that does not hold the
592
+ // commit yet. No file of it can be proven readable, so fail-closed leaves it to
593
+ // its author alone: "we cannot tell what it touched" is not "you may see it".
594
+ it('leaves an applied request whose files could not be resolved at all to its author', async () => {
595
+ const base = await start();
596
+ details.set(12, detail({ state: 'merged', files: [], approvals: [] }));
597
+ expect((await call(base, 'get_change_request', { number: 12 })).status).toBe(404);
598
+
599
+ callerEmail = AUTHOR;
600
+ const mine = await call(base, 'get_change_request', { number: 12 });
601
+ expect(mine.status).toBe(200);
602
+ expect(mine.json).toMatchObject({
603
+ state: 'merged',
604
+ changedFiles: 0,
605
+ withheldFiles: 0,
606
+ viewer: { isAuthor: true, mayMerge: false },
607
+ });
608
+ });
609
+
610
+ // The AUTHOR reads it, because nobody else can: a declined request is read
611
+ // from nothing (no sha is recorded, and its branch now holds later work), so
612
+ // its file set is empty and an empty file set proves no read access. Asking as
613
+ // a stranger would pin the state mapping on a combination the service cannot
614
+ // produce — a non-author holding a readable file list for a declined request.
615
+ it('reports a declined request as closed, which in Hexis means declined', async () => {
616
+ const base = await start();
617
+ callerEmail = AUTHOR;
618
+ details.set(12, detail({ state: 'closed', files: [], approvals: [] }));
619
+ const { json } = await call(base, 'get_change_request', { number: 12 });
620
+ expect(json).toMatchObject({ state: 'closed', changedFiles: 0, viewer: { isAuthor: true } });
621
+ expect(json).not.toHaveProperty('merged');
622
+ });
623
+
624
+ // WHEN the caller may read none of the request's files and is not its author
625
+ // THEN `get_change_request` answers 404.
626
+ it('answers not found when the caller may read none of its files', async () => {
627
+ const base = await start();
628
+ readable = [];
629
+ details.set(12, detail());
630
+ const { status, json } = await call(base, 'get_change_request', { number: 12 });
631
+ expect(status).toBe(404);
632
+ // Word for word what a number that was never issued gets: the answer is a
633
+ // function of the number alone, so a probe learns nothing from the
634
+ // difference between a request withheld and a request that never existed.
635
+ expect(json.error).toBe('Change request #12 not found.');
636
+ const absent = await call(base, 'get_change_request', { number: 99 });
637
+ expect(absent.status).toBe(404);
638
+ expect(absent.json.error).toBe('Change request #99 not found.');
639
+ });
640
+
641
+ // An OPEN request that proposes nothing, before the background sweep has
642
+ // closed it: still "not found" to an agent that is not its author. Closing
643
+ // empty requests changed nothing about what agents may see.
644
+ it('answers not found for an open request with no files to an agent that is not its author', async () => {
645
+ const base = await start();
646
+ details.set(12, detail({ state: 'open', files: [], approvals: [] }));
647
+ const { status, json } = await call(base, 'get_change_request', { number: 12 });
648
+ expect(status).toBe(404);
649
+ expect(json.error).toBe('Change request #12 not found.');
650
+ callerEmail = AUTHOR;
651
+ expect((await call(base, 'get_change_request', { number: 12 })).status).toBe(200);
652
+ });
653
+
654
+ it('answers not found when the access tree at the target cannot be resolved', async () => {
655
+ const base = await start();
656
+ readable = null;
657
+ details.set(12, detail());
658
+ expect((await call(base, 'get_change_request', { number: 12 })).status).toBe(404);
659
+ });
660
+
661
+ it('withholds a merge blocker that would name a file the caller may not read', async () => {
662
+ const base = await start();
663
+ readable = ['Knowledge/A.md'];
664
+ const reasons = [
665
+ 'Waiting on approval for Knowledge/A.md from Engineering.',
666
+ 'Waiting on approval for Payroll/Rates.md from Finance.',
667
+ ];
668
+ details.set(
669
+ 12,
670
+ detail({
671
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
672
+ mergeBlockedReasons: reasons,
673
+ mergeWarnings: reasons,
674
+ }),
675
+ );
676
+ const { json } = await call(base, 'get_change_request', { number: 12 });
677
+ expect(json).toMatchObject({
678
+ changedFiles: 1,
679
+ withheldFiles: 1,
680
+ mergeBlockedReasons: [reasons[0]],
681
+ withheldMergeBlockedReasons: 1,
682
+ });
683
+ expect(JSON.stringify(json)).not.toContain('Payroll');
684
+ });
685
+
686
+ it('refuses a number that is not a positive integer', async () => {
687
+ const base = await start();
688
+ // 2^53 and above are integers JSON can spell that a double cannot hold
689
+ // exactly: refused like a fraction, rather than rounded on the way to
690
+ // the database or turned into its error.
691
+ for (const number of [0, -3, 1.5, 2 ** 53, 1e300, 'twelve', undefined]) {
692
+ expect((await call(base, 'get_change_request', { number })).status, String(number)).toBe(400);
693
+ }
694
+ });
695
+ });
696
+
697
+ // ── Scenario: files, approvals and patches ──────────────────────────────────
698
+
699
+ describe('list_change_request_files', () => {
700
+ // WHEN a reviewer approved two of three files THEN `list_change_request_files`
701
+ // shows `approvedBy` on those two and the missing approver on the third.
702
+ it('shows approvedBy on the approved files and the missing approver on the rest', async () => {
703
+ const base = await start();
704
+ readable = ['A.md', 'B.md', 'C.md'];
705
+ const mia = approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z');
706
+ details.set(
707
+ 12,
708
+ detail({
709
+ files: [file('A.md'), file('B.md'), file('C.md')],
710
+ approvals: [
711
+ approval('A.md', { approvedBy: [mia], isApproved: true }),
712
+ approval('B.md', { approvedBy: [mia], isApproved: true }),
713
+ approval('C.md', {
714
+ eligibleApprovers: { roles: [], users: [{ name: 'Ana', email: 'ana@bevel.software' }] },
715
+ }),
716
+ ],
717
+ }),
718
+ );
719
+ const { status, json } = await call(base, 'list_change_request_files', { number: 12 });
720
+ expect(status).toBe(200);
721
+ const files = json.files as unknown as {
722
+ path: string;
723
+ approved: boolean;
724
+ approvedBy: { user: { name: string } }[];
725
+ requiredApprovers: { roles: string[]; users: { email: string }[] };
726
+ }[];
727
+ expect(files.map((f) => f.path)).toEqual(['A.md', 'B.md', 'C.md']);
728
+ expect(files[0].approvedBy.map((a) => a.user.name)).toEqual(['Mia']);
729
+ expect(files[1].approvedBy.map((a) => a.user.name)).toEqual(['Mia']);
730
+ expect(files[2].approvedBy).toEqual([]);
731
+ expect(files[2].approved).toBe(false);
732
+ expect(files[2].requiredApprovers.users.map((u) => u.email)).toEqual(['ana@bevel.software']);
733
+ });
734
+
735
+ // WHEN the request touches one file in a folder the caller may not read THEN
736
+ // the files list leaves it out and `withheldFiles` is 1.
737
+ it('leaves out a file in a folder the caller may not read, and counts it', async () => {
738
+ const base = await start();
739
+ readable = ['Knowledge/A.md'];
740
+ details.set(12, detail({ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')] }));
741
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
742
+ expect((json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
743
+ 'Knowledge/A.md',
744
+ ]);
745
+ expect(json.withheldFiles).toBe(1);
746
+ expect(json.totalCount).toBe(1);
747
+ expect(JSON.stringify(json)).not.toContain('Payroll');
748
+ });
749
+
750
+ // WHEN the request has 40 files THEN the first page holds 30 and says there
751
+ // is a second.
752
+ it('pages 40 files into 30 and a second page', async () => {
753
+ const base = await start();
754
+ const paths = Array.from({ length: 40 }, (_, i) => `Knowledge/F${i}.md`);
755
+ readable = paths;
756
+ details.set(12, detail({ files: paths.map((p) => file(p)) }));
757
+ const first = await call(base, 'list_change_request_files', { number: 12 });
758
+ expect(first.json.files).toHaveLength(30);
759
+ expect(first.json).toMatchObject({ totalCount: 40, page: 1, perPage: 30, hasNextPage: true });
760
+ const second = await call(base, 'list_change_request_files', { number: 12, page: 2 });
761
+ expect(second.json.files).toHaveLength(10);
762
+ expect(second.json.hasNextPage).toBe(false);
763
+ });
764
+
765
+ it('returns no patch unless `include: ["patches"]` asks for one', async () => {
766
+ const base = await start();
767
+ details.set(12, detail());
768
+ const without = await call(base, 'list_change_request_files', { number: 12 });
769
+ expect((without.json.files as unknown as { patch?: string }[])[0].patch).toBeUndefined();
770
+ expect(calls).toContainEqual(['getChangeRequestDetail', 12, false, VIEWER]);
771
+
772
+ calls = [];
773
+ const withPatches = await call(base, 'list_change_request_files', {
774
+ number: 12,
775
+ include: ['patches'],
776
+ });
777
+ expect((withPatches.json.files as unknown as { patch?: string }[])[0].patch).toBe(
778
+ '@@ Knowledge/A.md @@',
779
+ );
780
+ expect(calls).toContainEqual(['getChangeRequestDetail', 12, true, VIEWER]);
781
+ });
782
+
783
+ it('answers not found when the caller may read none of its files', async () => {
784
+ const base = await start();
785
+ readable = [];
786
+ details.set(12, detail());
787
+ expect((await call(base, 'list_change_request_files', { number: 12 })).status).toBe(404);
788
+ });
789
+ });
790
+
791
+ // ── Scenario: reviews ───────────────────────────────────────────────────────
792
+
793
+ describe('list_change_request_reviews', () => {
794
+ // WHEN a reviewer approved two of three files THEN
795
+ // `list_change_request_reviews` names the reviewer and the two files.
796
+ it('names the reviewer and the two files they approved', async () => {
797
+ const base = await start();
798
+ readable = ['A.md', 'B.md', 'C.md'];
799
+ details.set(
800
+ 12,
801
+ detail({
802
+ files: [file('A.md'), file('B.md'), file('C.md')],
803
+ approvals: [
804
+ approval('A.md', { approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z')], isApproved: true }),
805
+ approval('B.md', { approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-29T09:00:00.000Z')], isApproved: true }),
806
+ approval('C.md'),
807
+ ],
808
+ }),
809
+ );
810
+ const { status, json } = await call(base, 'list_change_request_reviews', { number: 12 });
811
+ expect(status).toBe(200);
812
+ expect(json.reviews).toHaveLength(1);
813
+ expect(json.reviews[0]).toMatchObject({
814
+ reviewer: { name: 'Mia', email: VIEWER },
815
+ stale: false,
816
+ submittedAt: '2026-09-29T09:00:00.000Z',
817
+ files: ['A.md', 'B.md'],
818
+ withheldFiles: 0,
819
+ });
820
+ // Hexis's own word for an approval that still stands, not GitHub's review
821
+ // state: nothing here says APPROVED or DISMISSED.
822
+ expect(JSON.stringify(json)).not.toContain('APPROVED');
823
+ });
824
+
825
+ it("counts a reviewer's withheld files without naming them", async () => {
826
+ const base = await start();
827
+ readable = ['Knowledge/A.md'];
828
+ const mia = approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z');
829
+ details.set(
830
+ 12,
831
+ detail({
832
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
833
+ approvals: [
834
+ approval('Knowledge/A.md', { approvedBy: [mia], isApproved: true }),
835
+ approval('Payroll/Rates.md', { approvedBy: [mia], isApproved: true }),
836
+ ],
837
+ }),
838
+ );
839
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
840
+ expect(json.reviews[0]).toMatchObject({ files: ['Knowledge/A.md'], withheldFiles: 1 });
841
+ expect(json.withheldReviews).toBe(0);
842
+ expect(JSON.stringify(json)).not.toContain('Payroll');
843
+ });
844
+
845
+ it('drops a review about files the caller may not read, and counts it instead', async () => {
846
+ const base = await start();
847
+ readable = ['Knowledge/A.md'];
848
+ details.set(
849
+ 12,
850
+ detail({
851
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
852
+ approvals: [
853
+ approval('Knowledge/A.md'),
854
+ approval('Payroll/Rates.md', {
855
+ approvedBy: [approvedBy('ana@bevel.software', 'Ana', '2026-09-29T08:00:00.000Z')],
856
+ isApproved: true,
857
+ }),
858
+ ],
859
+ }),
860
+ );
861
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
862
+ expect(json.reviews).toEqual([]);
863
+ expect(json.withheldReviews).toBe(1);
864
+ // Neither the reviewer nor the file they approved is named.
865
+ expect(JSON.stringify(json)).not.toContain('Ana');
866
+ expect(JSON.stringify(json)).not.toContain('Payroll');
867
+ });
868
+
869
+ it('reports an approval a later push invalidated as stale, in Hexis\'s own word', async () => {
870
+ const base = await start();
871
+ details.set(
872
+ 12,
873
+ detail({
874
+ approvals: [
875
+ approval('Knowledge/A.md', {
876
+ approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-28T08:00:00.000Z', true)],
877
+ }),
878
+ ],
879
+ }),
880
+ );
881
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
882
+ expect(json.reviews[0]).toMatchObject({ stale: true, files: ['Knowledge/A.md'] });
883
+ expect(JSON.stringify(json)).not.toContain('DISMISSED');
884
+ });
885
+
886
+ it('answers an empty list when nobody has approved anything yet', async () => {
887
+ const base = await start();
888
+ details.set(12, detail());
889
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
890
+ expect(json).toMatchObject({ reviews: [], withheldReviews: 0, totalCount: 0, hasNextPage: false });
891
+ });
892
+ });
893
+
894
+ // ── Scenario: comments ──────────────────────────────────────────────────────
895
+
896
+ describe('list_change_request_comments', () => {
897
+ // WHEN a reviewer left an inline comment and a reply followed THEN
898
+ // `list_change_request_comments` returns both, the reply with `parentId`.
899
+ it('returns the inline comment and its reply, the reply carrying parentId', async () => {
900
+ const base = await start();
901
+ details.set(
902
+ 12,
903
+ detail({
904
+ comments: [
905
+ comment({ id: 'c-1', path: 'Knowledge/A.md', line: 14 }),
906
+ comment({
907
+ id: 'c-2',
908
+ author: { email: AUTHOR, name: 'Juan' },
909
+ body: 'Fixed.',
910
+ path: 'Knowledge/A.md',
911
+ line: 14,
912
+ parentId: 'c-1',
913
+ createdAt: '2026-09-29T09:00:00.000Z',
914
+ }),
915
+ ],
916
+ }),
917
+ );
918
+ const { status, json } = await call(base, 'list_change_request_comments', { number: 12 });
919
+ expect(status).toBe(200);
920
+ expect(json.comments).toHaveLength(2);
921
+ expect(json.comments[0]).toMatchObject({
922
+ id: 'c-1',
923
+ author: { name: 'Mia', email: VIEWER },
924
+ body: 'This line is out of date.',
925
+ path: 'Knowledge/A.md',
926
+ line: 14,
927
+ headSha: 'head-1',
928
+ createdAt: '2026-09-29T08:00:00.000Z',
929
+ });
930
+ expect(json.comments[1]).toMatchObject({ id: 'c-2', parentId: 'c-1' });
931
+ expect(json.withheldComments).toBe(0);
932
+ });
933
+
934
+ it('keeps a general comment, which belongs to the request rather than a file', async () => {
935
+ const base = await start();
936
+ details.set(12, detail({ comments: [comment({ id: 'c-9', body: 'Ready for review.' })] }));
937
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
938
+ expect(json.comments).toHaveLength(1);
939
+ expect((json.comments as unknown as { path?: string }[])[0].path).toBeUndefined();
940
+ });
941
+
942
+ it('leaves out a comment on a file the caller may not read, and counts it', async () => {
943
+ const base = await start();
944
+ readable = ['Knowledge/A.md'];
945
+ details.set(
946
+ 12,
947
+ detail({
948
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
949
+ comments: [
950
+ comment({ id: 'c-1', path: 'Knowledge/A.md', line: 1 }),
951
+ comment({ id: 'c-2', path: 'Payroll/Rates.md', line: 2, body: 'The band is wrong.' }),
952
+ ],
953
+ }),
954
+ );
955
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
956
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-1']);
957
+ expect(json.withheldComments).toBe(1);
958
+ expect(JSON.stringify(json)).not.toContain('Payroll');
959
+ expect(JSON.stringify(json)).not.toContain('band is wrong');
960
+ });
961
+
962
+ it('keeps a comment on a file the request no longer changes but the caller may read', async () => {
963
+ const base = await start();
964
+ readable = ['Knowledge/A.md', 'Knowledge/Gone.md'];
965
+ details.set(12, detail({ comments: [comment({ id: 'c-7', path: 'Knowledge/Gone.md', line: 3 })] }));
966
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
967
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-7']);
968
+ expect(json.withheldComments).toBe(0);
969
+ });
970
+
971
+ it('pages the comments', async () => {
972
+ const base = await start();
973
+ details.set(
974
+ 12,
975
+ detail({
976
+ comments: Array.from({ length: 40 }, (_, i) =>
977
+ comment({ id: `c-${i}`, createdAt: `2026-09-29T08:00:${String(i).padStart(2, '0')}.000Z` }),
978
+ ),
979
+ }),
980
+ );
981
+ const first = await call(base, 'list_change_request_comments', { number: 12 });
982
+ expect(first.json.comments).toHaveLength(30);
983
+ expect(first.json.hasNextPage).toBe(true);
984
+ const second = await call(base, 'list_change_request_comments', { number: 12, page: 2 });
985
+ expect(second.json.comments).toHaveLength(10);
986
+ expect(second.json.hasNextPage).toBe(false);
987
+ });
988
+
989
+ it('answers not found when the caller may read none of its files', async () => {
990
+ const base = await start();
991
+ readable = [];
992
+ details.set(12, detail({ comments: [comment({ id: 'c-1' })] }));
993
+ expect((await call(base, 'list_change_request_comments', { number: 12 })).status).toBe(404);
994
+ });
995
+ });
996
+
997
+ /**
998
+ * Reproductions of what Local Testing found on sha ae2f49d9, through the HTTP
999
+ * tool surface it found them on — not just the pure mapper underneath.
1000
+ */
1001
+ describe('a mixed-access caller is never handed a path they may not read', () => {
1002
+ /** A body exactly as `openChangeRequest` builds it: prose, then the block. */
1003
+ const bodyWithOwnersBlock = [
1004
+ 'Please review the check-in note.',
1005
+ '',
1006
+ '## Affected owners',
1007
+ '',
1008
+ '- `KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md` — Admin',
1009
+ '- `KnowledgeBase/GTM/Notes.md` — GTM Team',
1010
+ ].join('\n');
1011
+
1012
+ /** The reader of one folder of a two-folder request — john.newcomer's case. */
1013
+ function mixedAccessRequest(): void {
1014
+ readable = ['KnowledgeBase/GTM/Notes.md'];
1015
+ const waiting = [
1016
+ 'Waiting on approval for KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md from Admin.',
1017
+ 'Waiting on approval for KnowledgeBase/GTM/Notes.md from GTM Team.',
1018
+ ];
1019
+ details.set(
1020
+ 1,
1021
+ detail({
1022
+ number: 1,
1023
+ body: bodyWithOwnersBlock,
1024
+ files: [
1025
+ file('KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md'),
1026
+ file('KnowledgeBase/GTM/Notes.md'),
1027
+ ],
1028
+ approvals: [
1029
+ approval('KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md', {
1030
+ eligibleApprovers: { roles: ['Admin'], users: [] },
1031
+ }),
1032
+ approval('KnowledgeBase/GTM/Notes.md', {
1033
+ eligibleApprovers: { roles: ['GTM Team'], users: [] },
1034
+ }),
1035
+ ],
1036
+ mergeBlockedReasons: waiting,
1037
+ mergeWarnings: waiting,
1038
+ }),
1039
+ );
1040
+ }
1041
+
1042
+ it('get_change_request answers the author\'s reason and names no withheld file anywhere', async () => {
1043
+ const base = await start();
1044
+ mixedAccessRequest();
1045
+ const { status, json } = await call(base, 'get_change_request', { number: 1 });
1046
+ expect(status).toBe(200);
1047
+ expect(json).toMatchObject({
1048
+ body: 'Please review the check-in note.',
1049
+ changedFiles: 1,
1050
+ withheldFiles: 1,
1051
+ });
1052
+ // The whole payload, not just `body` — this is the assertion whose absence
1053
+ // let the leak through the first time.
1054
+ const whole = JSON.stringify(json);
1055
+ expect(whole).not.toContain('Avi-Checkin');
1056
+ expect(whole).not.toContain('KnowledgeBase/Engineering');
1057
+ expect(whole).not.toContain('Affected owners');
1058
+ // The file they CAN read is still named, and its blocker still readable.
1059
+ expect(whole).toContain('KnowledgeBase/GTM/Notes.md');
1060
+ expect(json).toMatchObject({ withheldMergeBlockedReasons: 1 });
1061
+ });
1062
+
1063
+ it('no tool of the five names the withheld file, in any field', async () => {
1064
+ const base = await start();
1065
+ mixedAccessRequest();
1066
+ summaries = [details.get(1)!];
1067
+ for (const tool of TOOLS) {
1068
+ // `include` only where the tool declares it: a call naming an argument
1069
+ // its tool does not have is refused before it runs.
1070
+ const args =
1071
+ tool === 'list_change_requests'
1072
+ ? {}
1073
+ : tool === 'list_change_request_files'
1074
+ ? { number: 1, include: ['patches'] }
1075
+ : { number: 1 };
1076
+ const { status, json } = await call(base, tool, args);
1077
+ expect(status, tool).toBe(200);
1078
+ const whole = JSON.stringify(json);
1079
+ expect(whole, tool).not.toContain('Avi-Checkin');
1080
+ expect(whole, tool).not.toContain('KnowledgeBase/Engineering');
1081
+ }
1082
+ });
1083
+
1084
+ it('the author sees their own body too — the cut is the same for everyone', async () => {
1085
+ const base = await start();
1086
+ mixedAccessRequest();
1087
+ callerEmail = AUTHOR;
1088
+ readable = [
1089
+ 'KnowledgeBase/GTM/Notes.md',
1090
+ 'KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md',
1091
+ ];
1092
+ const { json } = await call(base, 'get_change_request', { number: 1 });
1093
+ // A caller who may read everything gets the same author text, not the
1094
+ // machine block — one body nobody has to reason about.
1095
+ expect(json).toMatchObject({
1096
+ body: 'Please review the check-in note.',
1097
+ changedFiles: 2,
1098
+ withheldFiles: 0,
1099
+ });
1100
+ expect(JSON.stringify(json)).not.toContain('Affected owners');
1101
+ });
1102
+ });
1103
+
1104
+ describe('a file renamed out of a folder the caller may not read', () => {
1105
+ /**
1106
+ * Two files: one renamed out of a closed folder, one plainly readable. The
1107
+ * readable one keeps the REQUEST visible, so what this suite measures is the
1108
+ * rename's own treatment rather than the 404 that an all-withheld request
1109
+ * already gets (covered above).
1110
+ */
1111
+ function renameRequest(): void {
1112
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md'];
1113
+ details.set(
1114
+ 12,
1115
+ detail({
1116
+ files: [
1117
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1118
+ file('Knowledge/Plain.md'),
1119
+ ],
1120
+ approvals: [approval('Knowledge/Open.md'), approval('Knowledge/Plain.md')],
1121
+ }),
1122
+ );
1123
+ }
1124
+
1125
+ it('is withheld whole, so `previousPath` can never name the closed path', async () => {
1126
+ const base = await start();
1127
+ renameRequest();
1128
+ const { json } = await call(base, 'list_change_request_files', {
1129
+ number: 12,
1130
+ include: ['patches'],
1131
+ });
1132
+ // `Knowledge/Open.md` is readable by its new name, but its diff shows what
1133
+ // was at `Payroll/Rates.md` — so it is withheld, counted, and neither of
1134
+ // its two names appears. The plainly readable file is unaffected.
1135
+ expect((json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
1136
+ 'Knowledge/Plain.md',
1137
+ ]);
1138
+ expect(json.withheldFiles).toBe(1);
1139
+ const whole = JSON.stringify(json);
1140
+ expect(whole).not.toContain('Payroll');
1141
+ expect(whole).not.toContain('Rates');
1142
+ expect(whole).not.toContain('Knowledge/Open.md');
1143
+ });
1144
+
1145
+ it('is listed, with `previousPath`, once both of its names are readable', async () => {
1146
+ const base = await start();
1147
+ renameRequest();
1148
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md', 'Payroll/Rates.md'];
1149
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
1150
+ expect(json.files).toHaveLength(2);
1151
+ expect(json.files[0]).toMatchObject({
1152
+ path: 'Knowledge/Open.md',
1153
+ previousPath: 'Payroll/Rates.md',
1154
+ // `open_change_request`'s word for it, from the same `changeKindOf`:
1155
+ // git's `renamed` and `copied` both read `moved`.
1156
+ change: 'moved',
1157
+ });
1158
+ expect(json.files[0]).not.toHaveProperty('status');
1159
+ expect(json.withheldFiles).toBe(0);
1160
+ });
1161
+
1162
+ it('keeps a comment anchored to the withheld rename out of the comment list', async () => {
1163
+ const base = await start();
1164
+ renameRequest();
1165
+ details.set(
1166
+ 12,
1167
+ detail({
1168
+ files: [
1169
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1170
+ file('Knowledge/Plain.md'),
1171
+ ],
1172
+ approvals: [approval('Knowledge/Open.md'), approval('Knowledge/Plain.md')],
1173
+ comments: [
1174
+ comment({ id: 'c-1', path: 'Knowledge/Plain.md', line: 4 }),
1175
+ comment({ id: 'c-2', path: 'Knowledge/Open.md', line: 9, body: 'Rate band looks off.' }),
1176
+ ],
1177
+ }),
1178
+ );
1179
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1180
+ // `Knowledge/Open.md` passes a bare read check — it is the file's new name —
1181
+ // but the file is withheld whole, so a comment on it is withheld too. Were
1182
+ // it kept, it would print the name the files tool refuses to print.
1183
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-1']);
1184
+ expect(json.withheldComments).toBe(1);
1185
+ const whole = JSON.stringify(json);
1186
+ expect(whole).not.toContain('Knowledge/Open.md');
1187
+ expect(whole).not.toContain('Payroll');
1188
+ expect(whole).not.toContain('Rate band');
1189
+ });
1190
+
1191
+ it('keeps the withheld rename out of the files a review names, and counts it', async () => {
1192
+ const base = await start();
1193
+ renameRequest();
1194
+ details.set(
1195
+ 12,
1196
+ detail({
1197
+ files: [
1198
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1199
+ file('Knowledge/Plain.md'),
1200
+ ],
1201
+ approvals: [
1202
+ approval('Knowledge/Open.md', {
1203
+ approvedBy: [approvedBy('ali@bevel.software', 'Ali', '2026-09-30T11:00:00.000Z')],
1204
+ }),
1205
+ approval('Knowledge/Plain.md', {
1206
+ approvedBy: [approvedBy('ali@bevel.software', 'Ali', '2026-09-30T09:00:00.000Z')],
1207
+ }),
1208
+ ],
1209
+ }),
1210
+ );
1211
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
1212
+ expect(json.reviews).toHaveLength(1);
1213
+ expect(json.reviews[0]).toMatchObject({
1214
+ reviewer: { name: 'Ali' },
1215
+ files: ['Knowledge/Plain.md'],
1216
+ withheldFiles: 1,
1217
+ // Taken from the readable approval alone — the later one is withheld.
1218
+ submittedAt: '2026-09-30T09:00:00.000Z',
1219
+ });
1220
+ const whole = JSON.stringify(json);
1221
+ expect(whole).not.toContain('Knowledge/Open.md');
1222
+ expect(whole).not.toContain('Payroll');
1223
+ expect(whole).not.toContain('11:00:00');
1224
+ });
1225
+
1226
+ it('answers mayApprove false when the only approvable file is the withheld one', async () => {
1227
+ const base = await start();
1228
+ renameRequest();
1229
+ details.set(
1230
+ 12,
1231
+ detail({
1232
+ files: [
1233
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1234
+ file('Knowledge/Plain.md'),
1235
+ ],
1236
+ // A write grant at `origin/<base>` can hold for a file the read verdict
1237
+ // withholds, so the request's whole approval set says the caller may
1238
+ // approve something — but not anything they are shown.
1239
+ approvals: [
1240
+ approval('Knowledge/Open.md', { viewerCanApprove: true }),
1241
+ approval('Knowledge/Plain.md', { viewerCanApprove: false }),
1242
+ ],
1243
+ }),
1244
+ );
1245
+ const first = await call(base, 'get_change_request', { number: 12 });
1246
+ expect(first.json).toMatchObject({ withheldFiles: 1 });
1247
+ expect((first.json as unknown as { viewer: { mayApprove: boolean } }).viewer.mayApprove).toBe(false);
1248
+ // Readable, and the answer turns true — the filter is the read verdict, not
1249
+ // a blanket false.
1250
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md', 'Payroll/Rates.md'];
1251
+ const second = await call(base, 'get_change_request', { number: 12 });
1252
+ expect((second.json as unknown as { viewer: { mayApprove: boolean } }).viewer.mayApprove).toBe(true);
1253
+ });
1254
+
1255
+ it('counts a withheld rename ONCE, though it goes by two names', async () => {
1256
+ const base = await start();
1257
+ readable = [];
1258
+ details.set(
1259
+ 12,
1260
+ detail({
1261
+ files: [file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' })],
1262
+ approvals: [approval('Knowledge/Open.md')],
1263
+ }),
1264
+ );
1265
+ callerEmail = AUTHOR;
1266
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
1267
+ expect(json.withheldFiles).toBe(1);
1268
+ });
1269
+ });
1270
+
1271
+ /**
1272
+ * The contradiction Local Testing found on sha 45f18939: `list_change_requests`
1273
+ * advertised change request #3 to a caller for whom all four by-number tools
1274
+ * answered 404, and reported `withheldFiles: 0` where it was 1. The list judged
1275
+ * the flat `touchedNodePaths`, which names a rename by its new path alone.
1276
+ */
1277
+ describe('the list and the by-number tools never disagree about a request', () => {
1278
+ const OLD = 'KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md';
1279
+ const NEW = 'KnowledgeBase/GTM/Knowledge/Moved-From-Engineering.md';
1280
+
1281
+ /**
1282
+ * CR#3 from the report: one file, renamed out of Engineering (which john
1283
+ * cannot read) into GTM (which he can). `touchedNodePaths` carries the new
1284
+ * path only — exactly as `changedPathsForPr` reports a detected rename.
1285
+ */
1286
+ function renamedOutOfReach(): ChangeRequest {
1287
+ return summary({
1288
+ number: 3,
1289
+ title: 'Local test: rename out of a folder john cannot read',
1290
+ touchedNodePaths: [NEW],
1291
+ touchedNodeFiles: [{ path: NEW, previousPath: OLD }],
1292
+ });
1293
+ }
1294
+
1295
+ beforeEach(() => {
1296
+ readable = [NEW];
1297
+ });
1298
+
1299
+ it('does not list a request whose every file the by-number tools withhold', async () => {
1300
+ const base = await start();
1301
+ const cr = renamedOutOfReach();
1302
+ summaries = [cr];
1303
+ details.set(3, detail({ ...cr, files: [file(NEW, { status: 'renamed', previousPath: OLD })] }));
1304
+
1305
+ const listed = await call(base, 'list_change_requests', {});
1306
+ expect(listed.json.changeRequests).toEqual([]);
1307
+ expect(listed.json.totalCount).toBe(0);
1308
+
1309
+ // ...and the four by-number tools agree, as they already did.
1310
+ for (const tool of TOOLS.filter((t) => t !== 'list_change_requests')) {
1311
+ const { status, json } = await call(base, tool, { number: 3 });
1312
+ expect(status, tool).toBe(404);
1313
+ expect(json.error, tool).toBe('Change request #3 not found.');
1314
+ }
1315
+ });
1316
+
1317
+ it('names neither side of the rename in the list it does answer', async () => {
1318
+ const base = await start();
1319
+ summaries = [renamedOutOfReach(), summary({ number: 2, touchedNodePaths: [NEW] })];
1320
+ const { json } = await call(base, 'list_change_requests', {});
1321
+ // #2 touches the readable file plainly, so it is listed; #3 is not, and
1322
+ // nothing of it — not its title, not either of its paths — comes back.
1323
+ expect((json.changeRequests as unknown as { number: number }[]).map((c) => c.number)).toEqual([2]);
1324
+ const whole = JSON.stringify(json);
1325
+ expect(whole).not.toContain('Avi-Checkin');
1326
+ expect(whole).not.toContain('rename out of a folder');
1327
+ });
1328
+
1329
+ it('counts the withheld rename as one file for its author, who may see it', async () => {
1330
+ const base = await start();
1331
+ callerEmail = AUTHOR;
1332
+ summaries = [renamedOutOfReach()];
1333
+ const { json } = await call(base, 'list_change_requests', {});
1334
+ expect(json.changeRequests).toHaveLength(1);
1335
+ // The author may SEE their request; they still may not read the file, and
1336
+ // the count says so — one file, not two, though it goes by two names.
1337
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 0, withheldFiles: 1 });
1338
+ expect(JSON.stringify(json)).not.toContain('Avi-Checkin');
1339
+ });
1340
+
1341
+ it('lists the request once both of the rename\'s names are readable', async () => {
1342
+ const base = await start();
1343
+ readable = [NEW, OLD];
1344
+ summaries = [renamedOutOfReach()];
1345
+ const { json } = await call(base, 'list_change_requests', {});
1346
+ expect(json.changeRequests).toHaveLength(1);
1347
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 1, withheldFiles: 0 });
1348
+ });
1349
+
1350
+ it('asks the access tree about both of a rename\'s names', async () => {
1351
+ const base = await start();
1352
+ summaries = [renamedOutOfReach()];
1353
+ callerEmail = AUTHOR;
1354
+ await call(base, 'list_change_requests', {});
1355
+ // One lookup for the target branch, carrying both paths — the old side is
1356
+ // what the previous implementation never asked about.
1357
+ const asked = calls.filter((c) => c[0] === 'canReadBatchAtRef');
1358
+ expect(asked).toHaveLength(1);
1359
+ expect(asked[0][2]).toBe('origin/main');
1360
+ expect(accessAskedFor).toEqual(expect.arrayContaining([NEW, OLD]));
1361
+ });
1362
+
1363
+ /**
1364
+ * A summary from somewhere that never filled `touchedNodeFiles` proves
1365
+ * nothing, rather than silently falling back to the flat paths — the fallback
1366
+ * is what this whole class of bug was.
1367
+ */
1368
+ it('treats a summary with no paired files as proving no read access', async () => {
1369
+ const base = await start();
1370
+ const bare = summary({ number: 5 });
1371
+ delete bare.touchedNodeFiles;
1372
+ summaries = [bare];
1373
+ const strangers = await call(base, 'list_change_requests', {});
1374
+ expect(strangers.json.changeRequests).toEqual([]);
1375
+
1376
+ callerEmail = AUTHOR;
1377
+ const mine = await call(base, 'list_change_requests', {});
1378
+ expect(mine.json.changeRequests).toHaveLength(1);
1379
+ });
1380
+ });
1381
+
1382
+ /**
1383
+ * Scenario (Razvan's decision, 2026-10-02): a MERGED request's files are
1384
+ * recovered from its merge commit and filtered by access like an open one, so a
1385
+ * reviewer can read back what happened. A DECLINED one has no merge commit and
1386
+ * stays readable by its author alone.
1387
+ *
1388
+ * The recovery itself is the service's (`getPrDetail` of an applied request, and
1389
+ * `changedFilesAtCommit` under it, both tested there). What these pin is the part
1390
+ * the tools own: a merged request with a resolved file set is read like any
1391
+ * other — same access filter, same withholding, same 404 — and nothing about
1392
+ * being applied makes it either more or less visible.
1393
+ */
1394
+ describe('reading a request that is no longer open', () => {
1395
+ const applied = (over: Partial<ChangeRequestDetail> = {}) =>
1396
+ detail({
1397
+ number: 20,
1398
+ state: 'merged',
1399
+ files: [file('KnowledgeBase/GTM/Notes.md'), file('Payroll/Rates.md')],
1400
+ mergeBlockedReasons: ['This pull request has already been merged.'],
1401
+ ...over,
1402
+ });
1403
+
1404
+ it('shows a non-author the applied files they may read, withholding the rest', async () => {
1405
+ const base = await start();
1406
+ readable = ['KnowledgeBase/GTM/Notes.md'];
1407
+ const cr = applied();
1408
+ details.set(20, cr);
1409
+ summaries = [cr];
1410
+
1411
+ const got = await call(base, 'get_change_request', { number: 20 });
1412
+ expect(got.status).toBe(200);
1413
+ expect(got.json).toMatchObject({ state: 'merged', changedFiles: 1, withheldFiles: 1 });
1414
+
1415
+ const files = await call(base, 'list_change_request_files', { number: 20 });
1416
+ expect((files.json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
1417
+ 'KnowledgeBase/GTM/Notes.md',
1418
+ ]);
1419
+ expect(files.json.withheldFiles).toBe(1);
1420
+ // Applied or not, the withheld file is never named.
1421
+ expect(JSON.stringify(files.json)).not.toContain('Payroll');
1422
+ });
1423
+
1424
+ it('lists it under `state: closed`, which covers applied and declined alike', async () => {
1425
+ const base = await start();
1426
+ readable = ['KnowledgeBase/GTM/Notes.md'];
1427
+ summaries = [applied()];
1428
+ const { json } = await call(base, 'list_change_requests', { state: 'closed' });
1429
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged']]);
1430
+ expect(json.changeRequests).toHaveLength(1);
1431
+ // And the row says WHICH of the two it is, where GitHub would say `closed`.
1432
+ expect(json.changeRequests[0]).toMatchObject({
1433
+ number: 20,
1434
+ state: 'merged',
1435
+ changedFiles: 1,
1436
+ withheldFiles: 1,
1437
+ });
1438
+ });
1439
+
1440
+ it('still answers 404 to a caller who may read none of the applied files', async () => {
1441
+ const base = await start();
1442
+ readable = [];
1443
+ summaries = [applied()];
1444
+ details.set(20, applied());
1445
+ expect((await call(base, 'get_change_request', { number: 20 })).status).toBe(404);
1446
+ expect((await call(base, 'list_change_requests', { state: 'all' })).json.changeRequests).toEqual([]);
1447
+ });
1448
+
1449
+ // A declined request records no sha, so the service reads it from nothing and
1450
+ // resolves no files for it — and an empty file set proves no read access.
1451
+ //
1452
+ // That precondition is the SERVICE's to keep, and this test cannot check it:
1453
+ // it hands the tools a detail directly. It used to be false. Declining does
1454
+ // not retire the source branch, so `getPrDetail` resolved a declined request's
1455
+ // branch pair and published its files, while the list — which asks git nothing
1456
+ // about a declined row — hid the same request from the same caller. What the
1457
+ // tools are shown here is now what the service produces, pinned by
1458
+ // `PullRequestService.getPrDetail of a declined request whose branch still
1459
+ // resolves`. Deliberately not re-asserted in the tool layer: a second copy of
1460
+ // the rule is what let the two surfaces disagree in the first place.
1461
+ it('leaves a declined request, whose files cannot be resolved, to its author', async () => {
1462
+ const base = await start();
1463
+ const declined = detail({ number: 21, state: 'closed', files: [], approvals: [] });
1464
+ details.set(21, declined);
1465
+ summaries = [declined];
1466
+ expect((await call(base, 'get_change_request', { number: 21 })).status).toBe(404);
1467
+ expect((await call(base, 'list_change_requests', { state: 'closed' })).json.changeRequests).toEqual([]);
1468
+
1469
+ callerEmail = AUTHOR;
1470
+ const mine = await call(base, 'get_change_request', { number: 21 });
1471
+ expect(mine.status).toBe(200);
1472
+ expect(mine.json).toMatchObject({ state: 'closed', changedFiles: 0, viewer: { isAuthor: true } });
1473
+ });
1474
+ });
1475
+
1476
+ /**
1477
+ * Scenario (Razvan's review, 2026-10-02, finding 3): a reply is shown only when
1478
+ * the comment it replies to is.
1479
+ *
1480
+ * `post_change_request_comment` takes `parentId` without requiring `path`, so a
1481
+ * reply to a comment on a withheld file has no path of its own — and judged on
1482
+ * itself it looked like a general comment about the whole request. It came back
1483
+ * with its body and a `parentId` naming a comment the caller cannot see.
1484
+ */
1485
+ describe('a reply is shown only when the comment it replies to is', () => {
1486
+ /** A request whose two files the caller can read one of. */
1487
+ function threadRequest(comments: ChangeRequestComment[]) {
1488
+ readable = ['Knowledge/Open.md'];
1489
+ details.set(
1490
+ 12,
1491
+ detail({
1492
+ files: [file('Knowledge/Open.md'), file('Payroll/Rates.md')],
1493
+ approvals: [approval('Knowledge/Open.md'), approval('Payroll/Rates.md')],
1494
+ comments,
1495
+ }),
1496
+ );
1497
+ }
1498
+
1499
+ it('withholds a pathless reply to a comment on a file the caller may not read', async () => {
1500
+ const base = await start();
1501
+ threadRequest([
1502
+ comment({ id: 'c-1', path: 'Payroll/Rates.md', line: 3, body: 'This band is wrong.' }),
1503
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Agreed, the band moved in April.' }),
1504
+ ]);
1505
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1506
+ expect(json.comments).toEqual([]);
1507
+ expect(json.withheldComments).toBe(2);
1508
+ const whole = JSON.stringify(json);
1509
+ // Neither the parent's path, nor the reply's body, nor the id it answers.
1510
+ expect(whole).not.toContain('Payroll');
1511
+ expect(whole).not.toContain('band moved');
1512
+ expect(whole).not.toContain('c-1');
1513
+ });
1514
+
1515
+ it('withholds every reply down the thread, however deep it runs', async () => {
1516
+ const base = await start();
1517
+ threadRequest([
1518
+ comment({ id: 'c-1', path: 'Payroll/Rates.md', body: 'This band is wrong.' }),
1519
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Which one?' }),
1520
+ comment({ id: 'c-3', parentId: 'c-2', body: 'The senior one.' }),
1521
+ ]);
1522
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1523
+ expect(json.comments).toEqual([]);
1524
+ expect(json.withheldComments).toBe(3);
1525
+ expect(JSON.stringify(json)).not.toContain('senior');
1526
+ });
1527
+
1528
+ it('keeps a thread on a file the caller may read, replies and all', async () => {
1529
+ const base = await start();
1530
+ threadRequest([
1531
+ comment({ id: 'c-1', path: 'Knowledge/Open.md', line: 4, body: 'Out of date.' }),
1532
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Fixed.' }),
1533
+ comment({ id: 'c-3', parentId: 'c-2', body: 'Thanks.' }),
1534
+ ]);
1535
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1536
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual([
1537
+ 'c-1',
1538
+ 'c-2',
1539
+ 'c-3',
1540
+ ]);
1541
+ expect(json.withheldComments).toBe(0);
1542
+ });
1543
+
1544
+ it('keeps a general comment and its replies — neither is about a file', async () => {
1545
+ const base = await start();
1546
+ threadRequest([
1547
+ comment({ id: 'c-1', body: 'Ready for review.' }),
1548
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Looking now.' }),
1549
+ ]);
1550
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1551
+ expect(json.comments).toHaveLength(2);
1552
+ expect(json.withheldComments).toBe(0);
1553
+ });
1554
+ });