@bevel-software/platform-core-backend 0.25.0 → 0.26.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 (254) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,724 @@
1
+ import type { Router, RequestHandler } from 'express';
2
+ import type {
3
+ ChangeRequest,
4
+ ChangeRequestDetail,
5
+ ChangedPathPair,
6
+ FileApproval,
7
+ } from '@bevel-software/platform-shared';
8
+ import type { IToolRegistry, JsonSchema } from '../../tool-registry/tool.contract.js';
9
+ import { ToolError, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
10
+ import { toolDef } from '../../tool-helpers/tool-def.js';
11
+ import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
12
+ import type { KbContext } from '../../../shared/kb-context.js';
13
+ import type { IAccessControl } from '../../access/access-control.interface.js';
14
+ import {
15
+ PER_PAGE_DEFAULT,
16
+ PER_PAGE_MAX,
17
+ fileIsReadable,
18
+ isAuthor,
19
+ matchesAuthor,
20
+ pathsOf,
21
+ maySeeChangeRequest,
22
+ pageOf,
23
+ pagingOf,
24
+ statesFor,
25
+ toCrComment,
26
+ toCrDetail,
27
+ toCrFile,
28
+ toCrReviews,
29
+ toCrSummary,
30
+ toCrViewer,
31
+ visibleBlockers,
32
+ visibleComments,
33
+ } from './change-request-read-shape.js';
34
+
35
+ /**
36
+ * The five read tools over change requests, named and shaped after GitHub's
37
+ * pull-request API: `list_change_requests`, `get_change_request`,
38
+ * `list_change_request_files`, `list_change_request_reviews` and
39
+ * `list_change_request_comments`. The mapping lives in
40
+ * `change-request-read-shape.ts`; this file does the IO and the access
41
+ * filtering.
42
+ *
43
+ * They ANSWER in Hexis's own vocabulary, not GitHub's field names (Razvan's
44
+ * decision on the review of PR #347, 2026-10-02): every field shared with
45
+ * `open_change_request`'s summary takes that summary's name, and the rest follow
46
+ * its style, so an agent opens a change request and reads it back in one
47
+ * vocabulary. Their INPUTS keep GitHub's filter names, which the ticket pins.
48
+ * The shape module's header says what that changed, field by field.
49
+ *
50
+ * None of them writes anything — every handler is registered `write: false`,
51
+ * so a read-scoped connection key may call all five, and every service call
52
+ * below is a read.
53
+ *
54
+ * ## What the caller may see
55
+ *
56
+ * The app's own change-request routes serve the whole list and the whole
57
+ * detail to any signed-in viewer; only the file CONTENT routes gate per path
58
+ * (`canReadAtRef` at `origin/<base>`, with an unresolvable verdict as a
59
+ * denial). These tools apply that same content gate to the whole payload,
60
+ * because an agent's answer is the content: a file list, a reviewer's comment
61
+ * and a gate warning all name paths, and an agent hands what it reads to
62
+ * whoever it is talking to.
63
+ *
64
+ * So, per request:
65
+ *
66
+ * - every path is resolved at `origin/<base>` — the target's access tree, the
67
+ * one the request is asking to be judged against — in ONE batched lookup;
68
+ * - BOTH names of a moved file are resolved, and a file is readable only if
69
+ * both are: the diff of a move shows what was at the old path, so a file
70
+ * moved out of a closed folder stays closed, however open its new home.
71
+ * The LIST decides this over the same `touchedNodeFiles` pairs the detail
72
+ * does — a list that judged the flat `touchedNodePaths` could not see a
73
+ * move's old side, and advertised requests its own by-number tools
74
+ * answered 404 for;
75
+ * - files the caller may not read are left out and counted in
76
+ * `withheldFiles`, never named (decision 2 on the ticket);
77
+ * - comments on a withheld file, and gate blockers naming one, go the same
78
+ * way, with their own counts; a REPLY goes when the comment it replies to
79
+ * does, up the whole chain;
80
+ * - `body` is the author's own description, with the generated
81
+ * `## Affected owners` block cut off the end — see `authorsDescription`;
82
+ * - a request with no readable file and no claim of authorship answers 404,
83
+ * indistinguishable from a number that was never issued.
84
+ *
85
+ * ## Reading a request that is no longer open
86
+ *
87
+ * A MERGED request is read from the merge commit its row records: its branch is
88
+ * retired, but the commit holds exactly the change that landed, so its file list
89
+ * is recovered from there and filtered by access like an open request's (the
90
+ * 2026-10-02 decision). Nothing in that path touches the network — the commit is
91
+ * in the clone or it is not.
92
+ *
93
+ * A DECLINED request has no merge commit and no branch left to diff, so its file
94
+ * set cannot be resolved at all; it fails closed, and is therefore readable by
95
+ * its author alone. "We cannot tell what this request touched" is not "you may
96
+ * see it", and an empty path set is the same answer `scopeApplyFailures` already
97
+ * refuses to grant anything on. The same holds for any request whose file set
98
+ * does not resolve — an access tree that will not load at `origin/<base>`, or a
99
+ * clone that does not have the merge commit yet.
100
+ */
101
+ export function registerChangeRequestReadTools(
102
+ registry: IToolRegistry,
103
+ router: Router,
104
+ toolAuth: RequestHandler,
105
+ toolHandler: ToolHandlerFactory,
106
+ accessControl: Pick<IAccessControl, 'canReadBatchAtRef'>,
107
+ kb: Pick<KbContext, 'defaultWorkspaceId'>,
108
+ ): void {
109
+ /**
110
+ * Mount one read tool. Every one of them is keyed by a change-request number
111
+ * (or by nothing at all), never by a draft, so none declares the injected
112
+ * `branch` input — the clone a verdict is resolved in is a scratch clone of
113
+ * the one shared origin, not anybody's working branch.
114
+ */
115
+ const mount = (spec: {
116
+ name: string;
117
+ description: string;
118
+ inputs: JsonSchema;
119
+ outputs: JsonSchema;
120
+ handler: ToolHandler;
121
+ }): void => {
122
+ const path = `/api/agent/tools/${spec.name}`;
123
+ const def = toolDef({
124
+ name: spec.name,
125
+ description: spec.description,
126
+ path,
127
+ inputs: spec.inputs,
128
+ outputs: spec.outputs,
129
+ tags: ['workflow'],
130
+ });
131
+ registry.registerInternalTool(def);
132
+ registry.registerExternalTool(def);
133
+ router.post(path.slice('/api'.length), toolAuth, toolHandler(spec.handler, { write: false }));
134
+ };
135
+
136
+ /**
137
+ * A clone to resolve access verdicts in. All clones track the same origin and
138
+ * every verdict here is read at `origin/<base>`, so any existing one answers
139
+ * identically — reusing one avoids cloning a branch just to read a ref.
140
+ */
141
+ const repoGlobalWorkspaceId = async (ctx: ToolContext): Promise<string> =>
142
+ (await ctx.workspaceService.findAnyWorkspaceId()) ?? kb.defaultWorkspaceId();
143
+
144
+ /** A change-request number as the tools accept it. */
145
+ const numberArg = (args: Record<string, unknown>): number => {
146
+ const n = args.number;
147
+ // A SAFE integer: JSON carries numbers a double cannot hold exactly, and
148
+ // one of those would reach the database rounded, or as an error the
149
+ // caller could not have predicted — not as the 400 a wrong number earns.
150
+ if (typeof n !== 'number' || !Number.isSafeInteger(n) || n <= 0) {
151
+ throw new ToolError('`number` is required and must be a positive integer.', 400);
152
+ }
153
+ return n;
154
+ };
155
+
156
+ /**
157
+ * Read verdicts for `paths` at `origin/<base>`, as a membership set of what
158
+ * the caller MAY read. A null answer (the ref or its `roles.yaml` does not
159
+ * resolve) withholds everything, which is the same reading the app's
160
+ * fork-point route gives it.
161
+ */
162
+ const readablePaths = async (
163
+ ctx: ToolContext,
164
+ workspaceId: string,
165
+ base: string,
166
+ paths: string[],
167
+ ): Promise<Set<string>> => {
168
+ if (paths.length === 0) return new Set();
169
+ const verdicts = await accessControl.canReadBatchAtRef(
170
+ workspaceId,
171
+ `origin/${base}`,
172
+ ctx.user.email,
173
+ [...new Set(paths)],
174
+ );
175
+ if (!verdicts) return new Set();
176
+ return new Set([...verdicts].filter(([, allowed]) => allowed).map(([p]) => p));
177
+ };
178
+
179
+ /**
180
+ * The detail of request `number` with the access filter applied, or a 404 —
181
+ * the one place the four by-number tools get their data, so they cannot
182
+ * drift on who may see what.
183
+ */
184
+ interface ScopedDetail {
185
+ detail: ChangeRequestDetail;
186
+ /**
187
+ * Whether the caller may read one path of this request — the raw per-path
188
+ * verdict, as the access tree gives it at `origin/<base>`.
189
+ */
190
+ mayRead: (path: string) => boolean;
191
+ /**
192
+ * Whether this request may NAME `path` to the caller — `mayRead`, minus
193
+ * every name a withheld file goes by. The ONE verdict the tools below ask
194
+ * about a path, so none of them can decide it a second way.
195
+ *
196
+ * The two differ on exactly one shape, and it is the shape that leaks: a
197
+ * file moved out of a folder the caller may not read is readable under
198
+ * its NEW name, yet `fileIsReadable` withholds it whole (its diff shows the
199
+ * old path's content). Anything keyed on that new path — an approval, a
200
+ * comment anchored to it — would otherwise say the request touches a file
201
+ * the file tool refuses to list, under a name it refuses to print.
202
+ */
203
+ mayShow: (path: string) => boolean;
204
+ /** The request's files the caller may read, in the request's own order. */
205
+ readableFiles: ChangeRequestDetail['files'];
206
+ /**
207
+ * Every name the withheld files go by — for filtering the gate blockers and
208
+ * for deciding `mayShow`. NEVER answered: use `withheldFileCount` to report
209
+ * them.
210
+ */
211
+ withheldFilePaths: string[];
212
+ /** How many files are withheld. One per file, whatever its move names. */
213
+ withheldFileCount: number;
214
+ viewerIsAuthor: boolean;
215
+ }
216
+
217
+ const scopedDetail = async (
218
+ ctx: ToolContext,
219
+ number: number,
220
+ opts: { patches: boolean },
221
+ ): Promise<ScopedDetail> => {
222
+ const workspaceId = await repoGlobalWorkspaceId(ctx);
223
+ const detail = await ctx.workflowService.getChangeRequestDetail(number, {
224
+ workspaceId,
225
+ viewerEmail: ctx.user.email,
226
+ patches: opts.patches,
227
+ });
228
+ if (!detail) throw notFound(number);
229
+ const viewerIsAuthor = isAuthor(detail, ctx.user.email);
230
+ // Comment paths join the batch: a comment may name a file the request no
231
+ // longer changes, and without a verdict of its own it would be withheld
232
+ // from a caller who can read it perfectly well.
233
+ const commentPaths = detail.comments.map((c) => c.path).filter((p): p is string => !!p);
234
+ // BOTH names of every file: a move is judged on its old path as well as
235
+ // its new one, because the diff of a move shows what was at the old one.
236
+ const readable = await readablePaths(ctx, workspaceId, detail.base, [
237
+ ...detail.files.flatMap(pathsOf),
238
+ ...commentPaths,
239
+ ]);
240
+ const mayRead = (path: string) => readable.has(path);
241
+ const readableFiles = detail.files.filter((f) => fileIsReadable(f, mayRead));
242
+ if (!maySeeChangeRequest({ readableFiles: readableFiles.length, isAuthor: viewerIsAuthor })) {
243
+ throw notFound(number);
244
+ }
245
+ // Every name a withheld file goes by, so a gate warning quoting either
246
+ // spelling is caught by the blocker filter — and so `mayShow` catches
247
+ // whichever spelling an approval or a comment happens to use.
248
+ const withheldFilePaths = detail.files
249
+ .filter((f) => !fileIsReadable(f, mayRead))
250
+ .flatMap(pathsOf);
251
+ const withheldNames = new Set(withheldFilePaths);
252
+ return {
253
+ detail,
254
+ mayRead,
255
+ mayShow: (path: string) => mayRead(path) && !withheldNames.has(path),
256
+ readableFiles,
257
+ withheldFilePaths,
258
+ withheldFileCount: detail.files.length - readableFiles.length,
259
+ viewerIsAuthor,
260
+ };
261
+ };
262
+
263
+ /**
264
+ * The same answer for a number that was never issued and for one the caller
265
+ * may not see — requirement 7. Says nothing a probe could tell apart.
266
+ */
267
+ const notFound = (number: number): ToolError =>
268
+ new ToolError(`Change request #${number} not found.`, 404);
269
+
270
+ /**
271
+ * The changed files of a SUMMARY, as the pairs a read gate decides over.
272
+ *
273
+ * Absent `touchedNodeFiles` means no summary builder filled it, and that is
274
+ * answered with no files rather than by falling back to `touchedNodePaths`:
275
+ * the fallback cannot pair a move, which is the whole reason this field
276
+ * exists, and a silent one would restore the list-versus-detail disagreement
277
+ * the next time a summary reached here from somewhere new. No files means
278
+ * nothing proven, which means author-only — the same fail-closed reading an
279
+ * empty path set already gets.
280
+ */
281
+ const filesOf = (cr: ChangeRequest): ChangedPathPair[] => cr.touchedNodeFiles ?? [];
282
+
283
+ /** `approvals` is one entry per file, same order; index it by path. */
284
+ const approvalsByPath = (detail: ChangeRequestDetail): Map<string, FileApproval> =>
285
+ new Map(detail.approvals.map((a) => [a.path, a]));
286
+
287
+ // ── Shared output sub-schemas ─────────────────────────────────────────────
288
+
289
+ const userSchema: JsonSchema = {
290
+ type: 'object',
291
+ properties: {
292
+ login: { type: 'string', description: 'Hash-derived login (`user-<12 hex>`); no raw address.' },
293
+ name: { type: 'string' },
294
+ email: { type: 'string', description: 'Set for comment authors and approvers only.' },
295
+ },
296
+ required: ['login'],
297
+ };
298
+
299
+ const changeRequestSchema: JsonSchema = {
300
+ type: 'object',
301
+ properties: {
302
+ url: { type: 'string', description: 'Link a person can open. First field: hand this to the user.' },
303
+ urlNote: { type: 'string', description: 'Present only when `url` is relative.' },
304
+ number: { type: 'integer' },
305
+ title: { type: 'string' },
306
+ state: { type: 'string', enum: ['open', 'merged', 'closed'], description: "Hexis's own state: `merged` is applied, `closed` declined." },
307
+ author: userSchema,
308
+ sourceBranch: { type: 'string', description: 'The branch the change comes from.' },
309
+ targetBranch: { type: 'string', description: 'The branch it would be applied to.' },
310
+ createdAt: { type: 'string', description: 'ISO timestamp.' },
311
+ updatedAt: { type: 'string', description: 'ISO timestamp — the close time of a closed request, else the creation time.' },
312
+ changedFiles: { type: 'integer', description: 'How many of its files YOU may read.' },
313
+ withheldFiles: { type: 'integer', description: 'How many of its files you may not read. Never named.' },
314
+ lastApplyFailure: {
315
+ type: 'object',
316
+ description:
317
+ 'Present while an open request carries the refusal its last Apply met: `reason` in the gate\'s or git\'s ' +
318
+ 'words when you may read every file of the request, else a line saying the reason is withheld; ' +
319
+ '`conflicts` when git refused on conflicts with the target; `at` an ISO timestamp.',
320
+ properties: {
321
+ reason: { type: 'string' },
322
+ conflicts: { type: 'boolean' },
323
+ at: { type: 'string' },
324
+ },
325
+ required: ['reason', 'conflicts', 'at'],
326
+ },
327
+ },
328
+ required: ['url', 'number', 'title', 'state', 'author', 'sourceBranch', 'targetBranch', 'createdAt', 'updatedAt', 'changedFiles', 'withheldFiles'],
329
+ };
330
+
331
+ const pagingOutputs: Record<string, JsonSchema> = {
332
+ totalCount: { type: 'integer', description: 'Entries you may read, across all pages.' },
333
+ page: { type: 'integer' },
334
+ perPage: { type: 'integer' },
335
+ hasNextPage: { type: 'boolean', description: 'True when a further page exists.' },
336
+ };
337
+
338
+ const pagingRequired = ['totalCount', 'page', 'perPage', 'hasNextPage'];
339
+
340
+ const pagingInputs: Record<string, JsonSchema> = {
341
+ per_page: { type: 'integer', minimum: 1, maximum: PER_PAGE_MAX, description: `Entries per page (default ${PER_PAGE_DEFAULT}, at most ${PER_PAGE_MAX}).` },
342
+ page: { type: 'integer', minimum: 1, description: 'Page number, from 1.' },
343
+ };
344
+
345
+ // ── list_change_requests ──────────────────────────────────────────────────
346
+
347
+ mount({
348
+ name: 'list_change_requests',
349
+ description:
350
+ 'List change requests (GitHub: list pull requests), newest first. Filter by `state` ' +
351
+ '(`open`, the default, `closed` — which covers applied and declined alike — or `all`), ' +
352
+ '`head` (source branch), `base` (target branch) and `author` (an email or a `login`). ' +
353
+ "Each answers in Hexis's own words: `url`, `number`, `title`, `state` (`open`, `merged` or " +
354
+ '`closed`), `sourceBranch`, `targetBranch`. Read-only, and limited to what you may read: a ' +
355
+ 'request whose files are all closed to you is not listed unless you opened it, and ' +
356
+ '`withheldFiles` counts the ones left out of each.',
357
+ inputs: {
358
+ type: 'object',
359
+ properties: {
360
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'Default `open`. `closed` covers applied and declined alike; the answer says which.' },
361
+ head: { type: 'string', description: 'Source branch — list only requests coming FROM it.' },
362
+ base: { type: 'string', description: 'Target branch — list only requests going INTO it.' },
363
+ author: { type: 'string', description: "The author's email, or their `author.login`." },
364
+ ...pagingInputs,
365
+ },
366
+ additionalProperties: false,
367
+ },
368
+ outputs: {
369
+ type: 'object',
370
+ properties: {
371
+ changeRequests: { type: 'array', items: changeRequestSchema },
372
+ ...pagingOutputs,
373
+ },
374
+ required: ['changeRequests', ...pagingRequired],
375
+ },
376
+ handler: async (args, ctx: ToolContext) => {
377
+ const state = args.state === 'closed' || args.state === 'all' ? args.state : 'open';
378
+ const head = typeof args.head === 'string' ? args.head : undefined;
379
+ const base = typeof args.base === 'string' ? args.base : undefined;
380
+ const author = typeof args.author === 'string' ? args.author : undefined;
381
+ const { perPage, page } = pagingOf(args);
382
+
383
+ // The one clone every answer here is read in, resolved ONCE and handed
384
+ // to the listing too: the summaries' file lists are built in it, and
385
+ // the by-number tools read the same request in the same clone, so the
386
+ // list cannot hide a request the detail would serve — or the reverse —
387
+ // for want of a workspace the other path fell back to.
388
+ const workspaceId = await repoGlobalWorkspaceId(ctx);
389
+ const all = await ctx.workflowService.listChangeRequestsByState(statesFor(state), { workspaceId });
390
+ const matching = all.filter(
391
+ (cr) =>
392
+ (head === undefined || cr.branch === head) &&
393
+ (base === undefined || cr.base === base) &&
394
+ (author === undefined || matchesAuthor(cr, author)),
395
+ );
396
+
397
+ // One access lookup per distinct target branch, not per request: the
398
+ // access tree is read at `origin/<base>`, and a list is usually a dozen
399
+ // requests into the same two or three targets.
400
+ //
401
+ // Over `touchedNodeFiles`, NOT `touchedNodePaths`. The flat list reports a
402
+ // move under its new name alone, so a file moved out of a folder this
403
+ // caller cannot open looked readable here while the detail — which pairs
404
+ // the two names — refused it: the list advertised a change request that
405
+ // all four by-number tools answered 404 for, and counted its withheld
406
+ // file as zero. One notion of a readable file, shared with the detail
407
+ // through the same `fileIsReadable`, is what stops the two disagreeing.
408
+ const byBase = new Map<string, string[]>();
409
+ for (const cr of matching) {
410
+ const bucket = byBase.get(cr.base) ?? [];
411
+ bucket.push(...filesOf(cr).flatMap(pathsOf));
412
+ byBase.set(cr.base, bucket);
413
+ }
414
+ const readableByBase = new Map<string, Set<string>>();
415
+ await Promise.all(
416
+ [...byBase].map(async ([baseRef, paths]) => {
417
+ readableByBase.set(baseRef, await readablePaths(ctx, workspaceId, baseRef, paths));
418
+ }),
419
+ );
420
+
421
+ const visible = [];
422
+ for (const cr of matching) {
423
+ const readable = readableByBase.get(cr.base) ?? new Set<string>();
424
+ const mayRead = (path: string) => readable.has(path);
425
+ const files = filesOf(cr);
426
+ const readableCount = files.filter((f) => fileIsReadable(f, mayRead)).length;
427
+ const mine = isAuthor(cr, ctx.user.email);
428
+ if (!maySeeChangeRequest({ readableFiles: readableCount, isAuthor: mine })) continue;
429
+ visible.push(
430
+ toCrSummary(cr, {
431
+ readable: readableCount,
432
+ // One per withheld FILE, whatever its move names it.
433
+ withheld: files.length - readableCount,
434
+ }),
435
+ );
436
+ }
437
+ // Paged AFTER the filter, so a page's length says nothing about what was
438
+ // withheld — the counts do that.
439
+ const { items, ...paging } = pageOf(visible, perPage, page);
440
+ return { changeRequests: items, ...paging };
441
+ },
442
+ });
443
+
444
+ // ── get_change_request ────────────────────────────────────────────────────
445
+
446
+ mount({
447
+ name: 'get_change_request',
448
+ description:
449
+ 'Read one change request by `number` (GitHub: get a pull request) — its `url`, `title`, ' +
450
+ '`body` (what the author wrote, without the generated owners block Hexis appends), ' +
451
+ '`state`, `sourceBranch`, `targetBranch`, whether it is `mergeable`, the ' +
452
+ '`mergeBlockedReasons` holding it up, and a `viewer` block saying whether YOU may approve ' +
453
+ 'or apply it. The same field names `open_change_request` answers in. Read-only. Answers ' +
454
+ '404 both for a number that does not exist and for a request you may not see.',
455
+ inputs: {
456
+ type: 'object',
457
+ properties: { number: { type: 'integer', minimum: 1, description: 'Change request number.' } },
458
+ required: ['number'],
459
+ additionalProperties: false,
460
+ },
461
+ outputs: {
462
+ type: 'object',
463
+ properties: {
464
+ ...(changeRequestSchema as { properties: Record<string, JsonSchema> }).properties,
465
+ body: { type: 'string', description: "What the AUTHOR wrote. The generated `## Affected owners` block Hexis appends — which names every changed path — is cut off the end; read who must approve each file from `list_change_request_files`." },
466
+ headSha: { type: 'string', description: 'The source commit this answer describes. Absent when no commit could be resolved.' },
467
+ baseSha: { type: 'string', description: 'The target commit it is read against. Absent when no commit could be resolved.' },
468
+ mergeable: { type: 'boolean', description: "Hexis's merge gate, which also waits on the per-file approvals. May be false while `mergeBlockedReasons` is empty, if a blocker names a file you may not read." },
469
+ mergeBlockedReasons: { type: 'array', items: { type: 'string' }, description: 'Why it cannot be applied yet, in the gate\'s own words. Blockers naming a file you may not read are left out.' },
470
+ withheldMergeBlockedReasons: { type: 'integer', description: 'How many blockers were left out because they name a file you may not read.' },
471
+ viewer: {
472
+ type: 'object',
473
+ description: 'What YOU, specifically, may do with this request.',
474
+ properties: {
475
+ mayApprove: { type: 'boolean', description: 'Whether you may approve at least one of the files you are shown. False on any request that is not open: approvals are given while it is.' },
476
+ mayMerge: { type: 'boolean', description: 'Whether an Apply by you would be accepted right now.' },
477
+ isAuthor: { type: 'boolean', description: 'Whether you opened it (your agent counts as you).' },
478
+ },
479
+ required: ['mayApprove', 'mayMerge', 'isAuthor'],
480
+ },
481
+ },
482
+ required: [
483
+ ...(changeRequestSchema as { required: string[] }).required,
484
+ 'body',
485
+ 'mergeable',
486
+ 'mergeBlockedReasons',
487
+ 'withheldMergeBlockedReasons',
488
+ 'viewer',
489
+ ],
490
+ },
491
+ handler: async (args, ctx: ToolContext) => {
492
+ const number = numberArg(args);
493
+ const scoped = await scopedDetail(ctx, number, { patches: false });
494
+ const { detail, readableFiles, withheldFilePaths, withheldFileCount, viewerIsAuthor } = scoped;
495
+ // Unwrapped, as `open_change_request` answers since #349: `url` is the
496
+ // first field of the answer itself, so a truncation cannot take it.
497
+ return toCrDetail(
498
+ detail,
499
+ { readable: readableFiles.length, withheld: withheldFileCount },
500
+ visibleBlockers(detail.mergeBlockedReasons, withheldFilePaths),
501
+ toCrViewer(
502
+ detail,
503
+ viewerIsAuthor,
504
+ // `mayApprove` over the approvals of the SHOWN files alone: an
505
+ // approval keyed on a withheld file must not answer for it.
506
+ detail.approvals.filter((a) => scoped.mayShow(a.path)),
507
+ ),
508
+ );
509
+ },
510
+ });
511
+
512
+ // ── list_change_request_files ─────────────────────────────────────────────
513
+
514
+ mount({
515
+ name: 'list_change_request_files',
516
+ description:
517
+ "List a change request's changed files (GitHub: list pull request files). Each answers " +
518
+ '`path`, `change` (`added`, `changed`, `deleted` or `moved`, a moved one with its ' +
519
+ '`previousPath`), `additions` and `deletions` — the same words ' +
520
+ '`open_change_request` uses — plus who must approve it (`requiredApprovers`) and who has ' +
521
+ '(`approvedBy`). No patch is returned unless you ask with `include: ["patches"]`. ' +
522
+ 'Read-only. Files you may not read are left out and counted in `withheldFiles`, never named.',
523
+ inputs: {
524
+ type: 'object',
525
+ properties: {
526
+ number: { type: 'integer', minimum: 1, description: 'Change request number.' },
527
+ include: {
528
+ type: 'array',
529
+ items: { type: 'string', enum: ['patches'] },
530
+ description: 'Pass `["patches"]` to get each file\'s unified diff. Omit it and no patch is returned.',
531
+ },
532
+ ...pagingInputs,
533
+ },
534
+ required: ['number'],
535
+ additionalProperties: false,
536
+ },
537
+ outputs: {
538
+ type: 'object',
539
+ properties: {
540
+ files: {
541
+ type: 'array',
542
+ items: {
543
+ type: 'object',
544
+ properties: {
545
+ path: { type: 'string', description: 'Repository-relative path.' },
546
+ previousPath: { type: 'string', description: 'Where a `moved` file came from.' },
547
+ change: { type: 'string', enum: ['added', 'changed', 'deleted', 'moved'], description: 'What happened to it.' },
548
+ additions: { type: 'integer' },
549
+ deletions: { type: 'integer' },
550
+ patch: { type: 'string', description: 'Unified diff — only on `include: ["patches"]`, and never for a binary.' },
551
+ isBinary: { type: 'boolean' },
552
+ requiredApprovers: {
553
+ type: 'object',
554
+ description: 'Who must approve this file before the request can be applied.',
555
+ properties: {
556
+ roles: { type: 'array', items: { type: 'string' } },
557
+ users: { type: 'array', items: userSchema },
558
+ },
559
+ required: ['roles', 'users'],
560
+ },
561
+ approversUnknown: { type: 'boolean', description: 'Present when the approver set could not be resolved — then an empty `requiredApprovers` means UNKNOWN, not nobody.' },
562
+ approvedBy: {
563
+ type: 'array',
564
+ description: 'The approvals standing on this file.',
565
+ items: {
566
+ type: 'object',
567
+ properties: {
568
+ user: userSchema,
569
+ approvedAt: { type: 'string', description: 'ISO timestamp.' },
570
+ stale: { type: 'boolean', description: 'True when a later push invalidated it.' },
571
+ selfApproval: { type: 'boolean', description: "True when the approver is the request's author." },
572
+ },
573
+ required: ['user', 'approvedAt', 'stale', 'selfApproval'],
574
+ },
575
+ },
576
+ approved: { type: 'boolean', description: 'A current approval by an eligible approver stands.' },
577
+ inMergeGate: { type: 'boolean', description: 'Whether the gate waits on this file at all.' },
578
+ viewerMayApprove: { type: 'boolean', description: 'Whether YOU may approve it. False on any request that is not open: approvals are given while it is.' },
579
+ },
580
+ required: ['path', 'change', 'additions', 'deletions', 'isBinary', 'requiredApprovers', 'approvedBy', 'approved', 'inMergeGate', 'viewerMayApprove'],
581
+ },
582
+ },
583
+ withheldFiles: { type: 'integer', description: 'How many of its files you may not read. Never named.' },
584
+ ...pagingOutputs,
585
+ },
586
+ required: ['files', 'withheldFiles', ...pagingRequired],
587
+ },
588
+ handler: async (args, ctx: ToolContext) => {
589
+ const include = Array.isArray(args.include) ? args.include : [];
590
+ const patches = include.includes('patches');
591
+ const number = numberArg(args);
592
+ const { perPage, page } = pagingOf(args);
593
+ const scoped = await scopedDetail(ctx, number, { patches });
594
+ const approvals = approvalsByPath(scoped.detail);
595
+ const { items, ...paging } = pageOf(scoped.readableFiles, perPage, page);
596
+ return {
597
+ files: items.map((f) => toCrFile(f, approvals.get(f.path), { patches, open: scoped.detail.state === 'open' })),
598
+ withheldFiles: scoped.withheldFileCount,
599
+ ...paging,
600
+ };
601
+ },
602
+ });
603
+
604
+ // ── list_change_request_reviews ───────────────────────────────────────────
605
+
606
+ mount({
607
+ name: 'list_change_request_reviews',
608
+ description:
609
+ "List a change request's reviews (GitHub: list reviews): who approved what, and when. " +
610
+ 'Hexis approves per FILE, so each entry names a `reviewer`, the `files` they approved and ' +
611
+ 'the time of the latest of them; `stale` is true for approvals a later push invalidated. ' +
612
+ 'Nothing records who DECLINED a request, so no entry reports one — a reviewer\'s objection ' +
613
+ 'is a comment. Read-only: an entry keeps only the files you may read, and one with no ' +
614
+ 'readable file at all is counted in `withheldReviews` rather than named.',
615
+ inputs: {
616
+ type: 'object',
617
+ properties: {
618
+ number: { type: 'integer', minimum: 1, description: 'Change request number.' },
619
+ ...pagingInputs,
620
+ },
621
+ required: ['number'],
622
+ additionalProperties: false,
623
+ },
624
+ outputs: {
625
+ type: 'object',
626
+ properties: {
627
+ reviews: {
628
+ type: 'array',
629
+ items: {
630
+ type: 'object',
631
+ properties: {
632
+ id: { type: 'string', description: 'Stable within the request: the reviewer, and whether their approvals still stand.' },
633
+ reviewer: userSchema,
634
+ stale: { type: 'boolean', description: 'True when a later push invalidated the approvals gathered here.' },
635
+ submittedAt: { type: 'string', description: 'ISO timestamp of the latest approval in this entry.' },
636
+ files: { type: 'array', items: { type: 'string' }, description: 'The files this reviewer approved.' },
637
+ withheldFiles: { type: 'integer', description: 'Further files of this review you may not read. Never named.' },
638
+ },
639
+ required: ['id', 'reviewer', 'stale', 'submittedAt', 'files', 'withheldFiles'],
640
+ },
641
+ },
642
+ withheldReviews: { type: 'integer', description: 'Reviews every file of which you may not read. Neither the reviewer nor the files are named.' },
643
+ ...pagingOutputs,
644
+ },
645
+ required: ['reviews', 'withheldReviews', ...pagingRequired],
646
+ },
647
+ handler: async (args, ctx: ToolContext) => {
648
+ const number = numberArg(args);
649
+ const { perPage, page } = pagingOf(args);
650
+ const scoped = await scopedDetail(ctx, number, { patches: false });
651
+ // The read predicate goes IN rather than a pre-filtered list: a review is
652
+ // grouped over every file the reviewer approved, so that it can count the
653
+ // withheld ones under its own id and drop itself when they are all it has.
654
+ // `mayShow` keeps a withheld move out of the files a review names.
655
+ const { reviews, withheldReviews } = toCrReviews(scoped.detail.approvals, scoped.mayShow);
656
+ const { items, ...paging } = pageOf(reviews, perPage, page);
657
+ return { reviews: items, withheldReviews, ...paging };
658
+ },
659
+ });
660
+
661
+ // ── list_change_request_comments ──────────────────────────────────────────
662
+
663
+ mount({
664
+ name: 'list_change_request_comments',
665
+ description:
666
+ "List a change request's comments (GitHub: list review comments and issue comments — Hexis " +
667
+ 'keeps both in one thread). Each carries its `author`, `body`, `path` and `line` when it is ' +
668
+ 'anchored to a file, `parentId` when it is a reply (the same id you pass to ' +
669
+ '`post_change_request_comment` to reply yourself), and its times. Read-only. A comment on a ' +
670
+ 'file you may not read is left out and counted in `withheldComments`, and so is a reply to ' +
671
+ 'a comment that was left out.',
672
+ inputs: {
673
+ type: 'object',
674
+ properties: {
675
+ number: { type: 'integer', minimum: 1, description: 'Change request number.' },
676
+ ...pagingInputs,
677
+ },
678
+ required: ['number'],
679
+ additionalProperties: false,
680
+ },
681
+ outputs: {
682
+ type: 'object',
683
+ properties: {
684
+ comments: {
685
+ type: 'array',
686
+ items: {
687
+ type: 'object',
688
+ properties: {
689
+ id: { type: 'string', description: 'Pass as `parentId` to `post_change_request_comment` to reply.' },
690
+ author: userSchema,
691
+ body: { type: 'string' },
692
+ path: { type: 'string', description: 'Repository-relative path, on a file-level or inline comment.' },
693
+ line: { type: 'integer', description: 'Set on an inline comment.' },
694
+ parentId: { type: 'string', description: 'The comment this one replies to.' },
695
+ headSha: { type: 'string', description: 'The commit the comment was anchored to.' },
696
+ createdAt: { type: 'string', description: 'ISO timestamp.' },
697
+ updatedAt: { type: 'string', description: 'ISO timestamp; present once edited.' },
698
+ },
699
+ required: ['id', 'author', 'body', 'headSha', 'createdAt'],
700
+ },
701
+ },
702
+ withheldComments: { type: 'integer', description: 'Comments on a file you may not read, and replies to those.' },
703
+ ...pagingOutputs,
704
+ },
705
+ required: ['comments', 'withheldComments', ...pagingRequired],
706
+ },
707
+ handler: async (args, ctx: ToolContext) => {
708
+ const number = numberArg(args);
709
+ const { perPage, page } = pagingOf(args);
710
+ const scoped = await scopedDetail(ctx, number, { patches: false });
711
+ // A comment with no path is about the request as a whole; one with a path
712
+ // is as readable as that path is NAMEABLE here. A reply inherits its
713
+ // parent's verdict up the chain — see `visibleComments`.
714
+ const { visible, withheld } = visibleComments(scoped.detail.comments, scoped.mayShow);
715
+ const { items, ...paging } = pageOf(visible, perPage, page);
716
+ return {
717
+ comments: items.map(toCrComment),
718
+ withheldComments: withheld,
719
+ ...paging,
720
+ };
721
+ },
722
+ });
723
+ }
724
+