@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
@@ -14,24 +14,24 @@
14
14
  *
15
15
  * - the MCP `instructions` of the initialize handshake (see `compose.ts`),
16
16
  * which Claude Code, Claude Desktop and Cursor put in the system prompt;
17
- * - the platform-managed agent guide at the repository root (`AGENTS.md` by
18
- * default), rendered from `{{sharedFileRules}}` in the template — claude.ai
19
- * on the web, the Agent SDK and Cline drop `instructions`, and a guide the
20
- * agent is told to read before its first action is always available.
17
+ * - the platform's agent guide, which `get_agent_guide` returns and a
18
+ * `read_file` of the guide's name serves (see `modules/agent-guide`) —
19
+ * claude.ai on the web, the Agent SDK and Cline drop `instructions`, and a
20
+ * guide the agent is told to read before its first action is always
21
+ * available.
21
22
  *
22
23
  * Both places get the SAME string, from {@link sharedFileRulesSection} — not
23
24
  * two hand-mirrored copies. A rule written twice is a rule that drifts, and a
24
25
  * drifted rule is worse than a repeated one, because the agent cannot tell
25
- * which copy is current. Each description ends instead with
26
- * {@link sharedRulesPointer}, one sentence naming the section and the file.
26
+ * which copy is current. Each description opens instead with the one
27
+ * sentence sending the agent to the guide (`tool-registry/guide-first.ts`).
27
28
  *
28
- * Pure text, a function of the layout only: the guide's file name is a
29
- * deployment setting, so nothing here may snapshot `AGENTS.md`.
29
+ * Pure text, a function of the layout only: the guide's name is a deployment
30
+ * setting (an alias a deployment chose before the guide left the disk), so
31
+ * nothing here may snapshot `AGENTS.md`.
30
32
  */
31
33
 
32
34
  import {
33
- DEFAULT_KB_LAYOUT,
34
- agentsFileOf,
35
35
  LEGACY_AGENTS_FILE,
36
36
  platformFilesByDepth,
37
37
  type KbLayout,
@@ -43,9 +43,9 @@ export const SHARED_RULES_SECTION = 'Working with files';
43
43
 
44
44
  /**
45
45
  * The guide's name on a knowledge base seeded before it was renamed to
46
- * {@link LEGACY_AGENTS_FILE}. Named in the conventions rule because the seeder
47
- * never deletes a file it did not expect, so such a knowledge base still
48
- * carries one.
46
+ * {@link LEGACY_AGENTS_FILE}, back when the guide was a file. The platform's
47
+ * own copy is taken out at startup now (see template-files.step.ts); one that
48
+ * stays is a file the organisation edited, so it is theirs and still named.
49
49
  */
50
50
  const PRE_RENAME_AGENTS_FILE = 'CLAUDE.md';
51
51
 
@@ -84,12 +84,11 @@ export interface SharedFileRule {
84
84
  * ones that said "this tool" or "this call" name the tools instead.
85
85
  */
86
86
  export function sharedFileRules(layout: KbLayout): readonly SharedFileRule[] {
87
- const agentsFile = agentsFileOf(layout);
88
87
  return [
89
88
  {
90
89
  id: 'agent-guide',
91
90
  heading: "This knowledge base's own conventions",
92
- body: conventionsRule(agentsFile),
91
+ body: conventionsRule(),
93
92
  },
94
93
  {
95
94
  id: 'content-kinds',
@@ -204,15 +203,14 @@ export function sharedFileRules(layout: KbLayout): readonly SharedFileRule[] {
204
203
 
205
204
  /**
206
205
  * The platform files as the rules list them — from the one function that knows
207
- * which they are, so the list cannot drift from what actually refuses a move,
208
- * and the guide appears under this deployment's name for it.
206
+ * which they are, so the list cannot drift from what actually refuses a move.
209
207
  *
210
208
  * WITH THE DEPTH each name counts at, because the name alone is half the rule:
211
209
  * `access.md` governs the folder it sits in and `.bevelignore` layers, so both
212
- * are platform files wherever they are; `roles.yaml` and the guide are read
213
- * from the repository root only, so a nested copy of either is ordinary
214
- * content that moves and deletes like any page. An agent told only the names
215
- * refuses a rename it may make, and trusts a nested `access.md` it may not.
210
+ * are platform files wherever they are; `roles.yaml` is read from the
211
+ * repository root only, so a nested copy is ordinary content that moves and
212
+ * deletes like any page. An agent told only the names refuses a rename it may
213
+ * make, and trusts a nested `access.md` it may not.
216
214
  */
217
215
  function platformFileList(layout: KbLayout): string {
218
216
  const { anyDepth, rootOnly } = platformFilesByDepth(layout);
@@ -221,30 +219,21 @@ function platformFileList(layout: KbLayout): string {
221
219
  }
222
220
 
223
221
  /**
224
- * The conventions reminder — which file holds the author's own rules for this
225
- * knowledge base, and to read it first.
222
+ * The conventions reminder — where the platform's guide is, that the
223
+ * organisation's own conventions file comes with it, and to read both first.
226
224
  *
227
- * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
228
- * rename still carry one. WHEN THE GUIDE HAS BEEN RENAMED the sentence names
229
- * two files, ours first: the second is the organisation's OWN `AGENTS.md`,
230
- * which on such a deployment is ordinary content the platform never touches —
231
- * and which no harness reads for a remote agent, because a remote agent has no
232
- * checkout. Under the default name the wording collapses to the one file it
233
- * has always named.
225
+ * The guide is read by name at the repository root, where coding agents look
226
+ * for an `AGENTS.md` by convention, and `get_agent_guide` returns it alone.
227
+ * One name on every deployment, so the rule takes no layout. `CLAUDE.md` is
228
+ * named as a fallback because a knowledge base seeded before the rename may
229
+ * still carry one its people edited.
234
230
  */
235
- function conventionsRule(agentsFile: string): string {
236
- if (agentsFile === LEGACY_AGENTS_FILE) {
237
- return (
238
- `Before your first read or change in a workspace, read \`${LEGACY_AGENTS_FILE}\` at the KB root — or ` +
239
- `\`${PRE_RENAME_AGENTS_FILE}\` on a knowledge base seeded before it was renamed — if either exists: it holds ` +
240
- "the author's conventions for this knowledge base, and you should follow them."
241
- );
242
- }
231
+ function conventionsRule(): string {
243
232
  return (
244
- `Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
245
- `\`${LEGACY_AGENTS_FILE}\` if it also exists (the organisation's own conventions) — or ` +
246
- `\`${PRE_RENAME_AGENTS_FILE}\` on a knowledge base seeded before it was renamed: together they hold the ` +
247
- 'conventions for this knowledge base, and you should follow them.'
233
+ "Before your first read or change in a workspace, call `get_agent_guide` and read the platform's guide " +
234
+ `(whole, or one section). read_file on \`${LEGACY_AGENTS_FILE}\` at the KB root answers with the same ` +
235
+ "guide, after the organisation's own conventions file of that name when it has one: follow both. A " +
236
+ `\`${PRE_RENAME_AGENTS_FILE}\` at the KB root is the organisation's own too; read it if it exists.`
248
237
  );
249
238
  }
250
239
 
@@ -260,55 +249,4 @@ export function sharedFileRulesSection(layout: KbLayout): string {
260
249
  return `## ${SHARED_RULES_SECTION}\n\n${body}`;
261
250
  }
262
251
 
263
- /**
264
- * The longest guide file name the pointer sentence spells out. Beyond this it
265
- * names the guide by its ROLE instead (see {@link sharedRulesPointer}).
266
- *
267
- * There has to be a bound somewhere, because the pointer rides on every file
268
- * tool and a file name is not a fixed cost: `validateFilename` allows a name
269
- * of up to 255 bytes, so an unbounded pointer could reach 318 characters and
270
- * push `file_stat` to 1,428 — over the description cap, recreating on a
271
- * renamed deployment exactly the truncation this module exists to prevent, and
272
- * invisibly, because every measurement is taken under the default layout.
273
- *
274
- * 40 is well past any name a deployment plausibly picks
275
- * (`ENGINEERING-AGENT-CONVENTIONS.md` is 32) and the fallback below is only
276
- * reachable past it.
277
- */
278
- export const POINTER_GUIDE_NAME_BUDGET = 40;
279
-
280
- /**
281
- * The one sentence a tool description ends with, in place of the paragraphs it
282
- * used to carry. Short on purpose: it costs every description the same ~100
283
- * characters at worst, and its whole job is to name the section and the file
284
- * to read.
285
- *
286
- * BOUNDED BY CONSTRUCTION, which is what lets the description cap mean
287
- * something on a deployment that renamed its guide: a name within
288
- * {@link POINTER_GUIDE_NAME_BUDGET} is spelled out, and a longer one gets the
289
- * generic wording. Naming the file is the better sentence and wins whenever it
290
- * fits; a name past the budget is pathological, and there the choice is between
291
- * a sentence that says where to look and a catalog entry the client cuts. The
292
- * guide's own name is still in the section's first rule either way.
293
- *
294
- * An absent layout means the default one, as it does in
295
- * `composeAgentInstructions`: a caller reading the layout from configuration
296
- * gets `undefined` when none is set, and the pointer must still name a file.
297
- */
298
- export function sharedRulesPointer(layout: KbLayout = DEFAULT_KB_LAYOUT): string {
299
- const agentsFile = agentsFileOf(layout);
300
- const where =
301
- agentsFile.length <= POINTER_GUIDE_NAME_BUDGET ? agentsFile : 'the agent guide at the KB root';
302
- return ` Shared rules for all file tools: see "${SHARED_RULES_SECTION}" in ${where}.`;
303
- }
304
252
 
305
- /**
306
- * The most the pointer can ever cost a description, over every layout. What the
307
- * description cap is measured against, the way the tool prefix is measured at
308
- * ITS cap rather than at whatever the current admin wrote: a description that
309
- * only fits beside the short default guide name does not really fit.
310
- */
311
- export const SHARED_RULES_POINTER_MAX = Math.max(
312
- sharedRulesPointer({ ...DEFAULT_KB_LAYOUT, agentsFile: `${'x'.repeat(POINTER_GUIDE_NAME_BUDGET - 3)}.md` }).length,
313
- sharedRulesPointer({ ...DEFAULT_KB_LAYOUT, agentsFile: `${'x'.repeat(POINTER_GUIDE_NAME_BUDGET + 10)}.md` }).length,
314
- );
@@ -8,6 +8,7 @@ import {
8
8
  ListToolsRequestSchema,
9
9
  isInitializeRequest,
10
10
  type CallToolResult,
11
+ type ListToolsResult,
11
12
  } from '@modelcontextprotocol/sdk/types.js';
12
13
 
13
14
  /**
@@ -44,6 +45,13 @@ export interface FakeDownstreamOptions {
44
45
  * revoked or expired token with.
45
46
  */
46
47
  acceptsToken?: (token: string) => boolean;
48
+ /**
49
+ * What `tools/list` answers, in place of the single `echo` tool. A FUNCTION,
50
+ * read per request, so a test can have the server correct a schema between
51
+ * two discoveries the way a vendor's fix reaches us — through the next load,
52
+ * with nothing restarted here.
53
+ */
54
+ tools?: () => Array<{ name: string; description?: string; inputSchema: unknown }>;
47
55
  }
48
56
 
49
57
  export async function startFakeDownstreamMcpServer(opts: FakeDownstreamOptions = {}): Promise<FakeDownstreamMcpServer> {
@@ -55,13 +63,16 @@ export async function startFakeDownstreamMcpServer(opts: FakeDownstreamOptions =
55
63
  function buildServer(): Server {
56
64
  const server = new Server({ name: 'downstream', version: '0.0.0' }, { capabilities: { tools: {} } });
57
65
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
58
- tools: [
66
+ // Cast because a test may deliberately advertise a schema that is NOT
67
+ // valid JSON Schema, which is precisely what the SDK's type forbids and
68
+ // what a real server is free to send.
69
+ tools: (opts.tools?.() ?? [
59
70
  {
60
71
  name: 'echo',
61
72
  description: 'Return the text it was given.',
62
- inputSchema: { type: 'object' as const, properties: { text: { type: 'string' } } },
73
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
63
74
  },
64
- ],
75
+ ]) as unknown as ListToolsResult['tools'],
65
76
  }));
66
77
  server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
67
78
  executions += 1;
@@ -1147,3 +1147,253 @@ describe('the first call on a fresh connection, fifty at once', () => {
1147
1147
  expect(report.distinctSessionIds).toBe(50);
1148
1148
  }, 120_000);
1149
1149
  });
1150
+
1151
+
1152
+ /**
1153
+ * A connected server's input schemas, end to end: what the server sends is
1154
+ * what a client is offered, and a tool whose schema is genuinely invalid is
1155
+ * kept off every agent surface with a reason its owner can read.
1156
+ *
1157
+ * Over the real transport, because this is the layer where the three
1158
+ * silently-dropped tools would have been caught. The proxy had been corrupting
1159
+ * deeply nested schemas on the way OUT, after every parse in the chain had
1160
+ * approved them, so nothing short of a client's own refusal said so.
1161
+ *
1162
+ * One boundary this suite also pins, because it decides what the check can
1163
+ * ever see: a tool whose schema breaks the shape `@modelcontextprotocol/sdk`
1164
+ * models at the ROOT (`type`, `properties`, `required`) makes the SDK client
1165
+ * reject the WHOLE `tools/list` response, so that server's manual fails to
1166
+ * register and Hexis is handed none of its tools. See the last test.
1167
+ */
1168
+ describe('a connected tool whose schema is invalid is not offered to agents', () => {
1169
+ let downstream: FakeDownstreamMcpServer | undefined;
1170
+ afterEach(async () => {
1171
+ await downstream?.stop();
1172
+ downstream = undefined;
1173
+ });
1174
+
1175
+ const manualAt = (server: FakeDownstreamMcpServer) => () => [
1176
+ { name: 'notion', call_template_type: 'mcp', config: { mcpServers: { srv: { transport: 'http', url: server.url } } } },
1177
+ ];
1178
+
1179
+ /** The constructs the dropped tools carried, nested as deep as theirs were. */
1180
+ function richSchema(levels: number): Record<string, unknown> {
1181
+ let node: Record<string, unknown> = {
1182
+ type: 'object',
1183
+ properties: {
1184
+ socialLinks: { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'null' }] } },
1185
+ value: { anyOf: [{ type: 'object', required: ['id', 'name'] }, { type: 'null' }] },
1186
+ table: { type: 'object', properties: { rows: { type: 'array', items: { type: 'string' } } } },
1187
+ },
1188
+ required: ['value'],
1189
+ };
1190
+ for (let i = levels; i > 0; i -= 1) {
1191
+ node = { type: 'object', properties: { [`level${i}`]: node }, required: [`level${i}`] };
1192
+ }
1193
+ return node;
1194
+ }
1195
+
1196
+ /**
1197
+ * The Notion refusal, as reported: `required` holding a number, in an
1198
+ * `anyOf` branch. `anyOf` is a keyword nothing between the server and Hexis
1199
+ * models, which is why a tool carrying this arrives intact and is ours to
1200
+ * screen — unlike the same mistake at the root (last test).
1201
+ */
1202
+ const INVALID_SCHEMA = {
1203
+ type: 'object',
1204
+ properties: { value: { anyOf: [{ type: 'object', required: [7] }] } },
1205
+ };
1206
+ const INVALID_AT = '/properties/value/anyOf/0/required/0';
1207
+ const INVALID_BECAUSE = 'must be a string';
1208
+
1209
+ const goodTool = (name: string) => ({
1210
+ name,
1211
+ description: `does ${name}`,
1212
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
1213
+ });
1214
+
1215
+ it('offers an `anyOf` list, a `required` list and nested `items` exactly as the server sent them', async () => {
1216
+ // Twelve levels deep: past the point at which the proxy used to replace
1217
+ // whatever node it had reached with `{}`, which is how `anyOf: {}`,
1218
+ // `required: [{}]` and `type: {}` reached clients and cost three real
1219
+ // tools their place in the agent's toolset.
1220
+ const sent = richSchema(12);
1221
+ downstream = await startFakeDownstreamMcpServer({
1222
+ tools: () => [{ name: 'query', description: 'query it', inputSchema: sent }],
1223
+ });
1224
+ const { baseUrl } = await startPlatform({ manualsFor: manualAt(downstream) });
1225
+ const { client } = await connectSdkClient(baseUrl);
1226
+ const offered = (await client.listTools()).tools.find((t) => t.name === 'notion_srv_query');
1227
+ expect(offered).toBeDefined();
1228
+ expect(offered!.inputSchema).toEqual(sent);
1229
+ });
1230
+
1231
+ it('hides the tool whose schema is invalid, keeps its nine siblings, and marks it for the owner', async () => {
1232
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
1233
+ downstream = await startFakeDownstreamMcpServer({
1234
+ tools: () => [
1235
+ ...Array.from({ length: 9 }, (_, i) => goodTool(`good${i}`)),
1236
+ { name: 'broken', description: 'broken', inputSchema: INVALID_SCHEMA },
1237
+ ],
1238
+ });
1239
+ const platform = await startPlatform({ manualsFor: manualAt(downstream) });
1240
+ const { client } = await connectSdkClient(platform.baseUrl);
1241
+ const names = (await client.listTools()).tools.map((t) => t.name);
1242
+
1243
+ expect(names).not.toContain('notion_srv_broken');
1244
+ for (let i = 0; i < 9; i += 1) expect(names).toContain(`notion_srv_good${i}`);
1245
+
1246
+ // The owner's side of the same fact, by the manual's catalog name.
1247
+ expect(platform.service.hiddenTools.hiddenFor('notion')).toEqual([
1248
+ {
1249
+ manual: 'notion',
1250
+ name: 'notion_srv_broken',
1251
+ path: INVALID_AT,
1252
+ reason: INVALID_BECAUSE,
1253
+ marker: `Hidden from agents: its schema is invalid at ${INVALID_AT} (${INVALID_BECAUSE}).`,
1254
+ },
1255
+ ]);
1256
+ });
1257
+
1258
+ it('keeps the hidden tool out of `list_tools` and out of the tool chain', async () => {
1259
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
1260
+ downstream = await startFakeDownstreamMcpServer({
1261
+ tools: () => [goodTool('fine'), { name: 'broken', description: 'broken', inputSchema: INVALID_SCHEMA }],
1262
+ });
1263
+ const { baseUrl } = await startPlatform({ manualsFor: manualAt(downstream) });
1264
+ const { client } = await connectSdkClient(baseUrl);
1265
+ // `list_tools` and the chain read the SAME tool repository the listing is
1266
+ // built from, which is why taking the tool off it covers all three.
1267
+ const listed = toolText(await client.callTool({ name: 'list_tools', arguments: {} }));
1268
+ expect(listed).toContain('notion.srv_fine');
1269
+ expect(listed).not.toContain('broken');
1270
+
1271
+ // The chain's view of the catalog is the same repository: it can describe
1272
+ // the sibling and knows nothing of the hidden tool.
1273
+ const info = JSON.parse(
1274
+ toolText(
1275
+ await client.callTool({
1276
+ name: 'tools_info',
1277
+ arguments: { tool_names: ['notion.srv_fine', 'notion.srv_broken'] },
1278
+ }),
1279
+ ),
1280
+ ) as { interfaces: string; not_found: string[] };
1281
+ expect(info.interfaces).toContain('fine');
1282
+ expect(info.not_found).toEqual(['notion.srv_broken']);
1283
+
1284
+ // And a chain that calls it dies on a tool that is not there — the
1285
+ // namespace is bound, the member is simply absent, so the isolate's own
1286
+ // reason NAMES it. Asserted on that reason rather than on a prefix: the
1287
+ // chain reports a failure as `isError` with the thrown text, and a test
1288
+ // pinned to the wrapper's wording passes while the tool is still bound.
1289
+ const chain = await client.callTool({
1290
+ name: 'call_tool_chain',
1291
+ arguments: { code: 'return notion.srv_broken({ body: {} });' },
1292
+ });
1293
+ expect(chain.isError).toBe(true);
1294
+ expect(toolText(chain)).toContain('notion.srv_broken is not a function');
1295
+
1296
+ // The same chain, same namespace, on the sibling: bound and callable. This
1297
+ // is what makes the line above a statement about the HIDDEN tool rather
1298
+ // than about a namespace the chain could not reach at all.
1299
+ const sibling = await client.callTool({
1300
+ name: 'call_tool_chain',
1301
+ arguments: { code: 'return notion.srv_fine({ body: {} });' },
1302
+ });
1303
+ expect(sibling.isError).toBeFalsy();
1304
+ });
1305
+
1306
+ it('answers an agent that calls the hidden tool by name, without quoting the schema to it', async () => {
1307
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
1308
+ downstream = await startFakeDownstreamMcpServer({
1309
+ tools: () => [{ name: 'broken', description: 'broken', inputSchema: INVALID_SCHEMA }],
1310
+ });
1311
+ const { baseUrl } = await startPlatform({ manualsFor: manualAt(downstream) });
1312
+ const { client } = await connectSdkClient(baseUrl);
1313
+ await client.listTools(); // the load that screens the server's tools
1314
+ const res = await client.callTool({ name: 'notion_srv_broken', arguments: {} });
1315
+ expect(res.isError).toBe(true);
1316
+ const text = toolText(res);
1317
+ expect(text).toContain('hidden from agents because its schema is invalid');
1318
+ expect(text).toContain('list_tool_setup');
1319
+ // The place and the reason are the owner's to read, not the agent's.
1320
+ expect(text).not.toContain(INVALID_AT);
1321
+ });
1322
+
1323
+ it('offers the tool again, with no marker, once the server sends a corrected schema', async () => {
1324
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
1325
+ let required: unknown = [7];
1326
+ downstream = await startFakeDownstreamMcpServer({
1327
+ tools: () => [
1328
+ {
1329
+ name: 'query',
1330
+ description: 'query it',
1331
+ inputSchema: { type: 'object', properties: { value: { anyOf: [{ type: 'object', required }] } } },
1332
+ },
1333
+ ],
1334
+ });
1335
+ const platform = await startPlatform({ manualsFor: manualAt(downstream) });
1336
+ const { client } = await connectSdkClient(platform.baseUrl);
1337
+ expect((await client.listTools()).tools.map((t) => t.name)).not.toContain('notion_srv_query');
1338
+
1339
+ required = ['id']; // the vendor fixes it
1340
+ // Dropping the pooled connection is what a refresh IS: the next request
1341
+ // re-dials and re-reads the tools. Nothing is restarted, and no marker has
1342
+ // to be cleared by hand.
1343
+ platform.service.onSecretsChanged(null);
1344
+
1345
+ expect((await client.listTools()).tools.map((t) => t.name)).toContain('notion_srv_query');
1346
+ expect(platform.service.hiddenTools.hiddenFor('notion')).toEqual([]);
1347
+ });
1348
+
1349
+ it('a tool call does not re-run the check, and does not hide a tool it ran on before', async () => {
1350
+ downstream = await startFakeDownstreamMcpServer({ tools: () => [goodTool('echo')] });
1351
+ const { baseUrl } = await startPlatform({ manualsFor: manualAt(downstream) });
1352
+ const { client } = await connectSdkClient(baseUrl);
1353
+ await client.listTools();
1354
+ // The surface is rebuilt per request, so a call sees the same schemas
1355
+ // again; what it must not do is check them again. That the check runs once
1356
+ // per distinct schema is counted in the guard's own unit test — here the
1357
+ // point is that calling costs nothing and changes nothing.
1358
+ for (const text of ['a', 'b', 'c']) {
1359
+ expect((await client.callTool({ name: 'notion_srv_echo', arguments: { text } })).isError).toBeFalsy();
1360
+ }
1361
+ expect((await client.listTools()).tools.map((t) => t.name)).toContain('notion_srv_echo');
1362
+ });
1363
+
1364
+ /**
1365
+ * The boundary, pinned so nobody has to rediscover it: at the ROOT of a
1366
+ * tool's input schema, `type`, `properties` and `required` are the three
1367
+ * fields `@modelcontextprotocol/sdk` models itself
1368
+ * (`ToolSchema.inputSchema`, with `required: z.array(z.string())`), and the
1369
+ * client rejects the ENTIRE `tools/list` response when one tool breaks them.
1370
+ *
1371
+ * So for that class of defect Hexis is handed NOTHING — not the bad tool, not
1372
+ * the good ones — and cannot hide one tool or mark it: the whole manual fails
1373
+ * to register, which is what it did before this change too. The reason is
1374
+ * logged, with the SDK's own message naming the tool's index.
1375
+ */
1376
+ it('cannot single out a root-level defect: the MCP SDK refuses the whole tools/list response', async () => {
1377
+ const warnings: string[] = [];
1378
+ vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
1379
+ warnings.push(args.map(String).join(' '));
1380
+ });
1381
+ vi.spyOn(console, 'error').mockImplementation(() => {});
1382
+ downstream = await startFakeDownstreamMcpServer({
1383
+ tools: () => [
1384
+ goodTool('fine'),
1385
+ { name: 'broken', description: 'broken', inputSchema: { type: 'object', properties: {}, required: [7] } },
1386
+ ],
1387
+ });
1388
+ const platform = await startPlatform({ manualsFor: manualAt(downstream) });
1389
+ const { client } = await connectSdkClient(platform.baseUrl);
1390
+ const names = (await client.listTools()).tools.map((t) => t.name);
1391
+
1392
+ expect(names).not.toContain('notion_srv_broken');
1393
+ // Its sibling goes with it, and the marker cannot name what never arrived.
1394
+ expect(names).not.toContain('notion_srv_fine');
1395
+ expect(platform.service.hiddenTools.hiddenFor('notion')).toEqual([]);
1396
+ expect(warnings.join('\n')).toContain('skipping manual "notion"');
1397
+ expect(warnings.join('\n')).toContain('required');
1398
+ });
1399
+ });
@@ -12,7 +12,9 @@ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { toolDef } from '../../tool-helpers/tool-def.js';
14
14
  import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
15
- import { TOOL_PREFIX_LINE, platformInstructions, sharedRulesPointer } from '../../agent-instructions/index.js';
15
+ import { TOOL_PREFIX_LINE, platformInstructions } from '../../agent-instructions/index.js';
16
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
17
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
16
18
  import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.contract.js';
17
19
 
18
20
  /** The platform-owned part of the handshake text: the header plus the shared file rules. */
@@ -266,23 +268,23 @@ describe('McpService (UTCP→MCP proxy)', () => {
266
268
  expect(askSchema.properties.body?.properties?.prompt).toBeDefined();
267
269
  });
268
270
 
269
- it('ends the served call_tool_chain description with the shared-rules pointer', async () => {
270
- // What a chained read does to an IMAGE is one of the rules the file tools
271
- // share, so it is stated once — in the handshake instructions and in the
272
- // managed guide — and the chain, like every file tool, ends with the one
273
- // sentence saying where. The clients that drop `instructions` have only
274
- // descriptions to go on, so that sentence is their way to the rule.
271
+ it('opens every served meta-tool with the guide-first sentence, and states the chain rules nowhere on the chain', async () => {
272
+ // What a chained read does to an IMAGE, a failure or a large result is one
273
+ // of the rules the file tools share, so it is stated once — in the
274
+ // handshake instructions and in the guide — and each meta-tool, like every
275
+ // tool of the platform's own, opens with the one sentence saying where.
276
+ // The clients that drop `instructions` have only descriptions to go on, so
277
+ // that sentence is their way to the rules.
275
278
  const client = await setup();
276
279
  const { tools } = await client.listTools();
277
- const chain = tools.find((t) => t.name === 'call_tool_chain')!;
278
- const pointer = sharedRulesPointer(DEFAULT_KB_LAYOUT);
279
- expect(chain.description!.endsWith(pointer)).toBe(true);
280
- // Once, and not on the two meta-tools that describe the registry rather
281
- // than a file.
282
- expect(chain.description!.split(pointer)).toHaveLength(2);
283
- for (const name of ['list_tools', 'tools_info']) {
284
- expect(tools.find((t) => t.name === name)!.description, name).not.toContain(pointer);
280
+ for (const name of ['call_tool_chain', 'list_tools', 'tools_info']) {
281
+ const served = tools.find((t) => t.name === name)!;
282
+ expect(served.description!.startsWith(`${GUIDE_FIRST_SENTENCE} `), name).toBe(true);
283
+ expect(served.description!.split(GUIDE_FIRST_SENTENCE), name).toHaveLength(2);
285
284
  }
285
+ const chain = tools.find((t) => t.name === 'call_tool_chain')!;
286
+ expect(chain.description).not.toContain('image_omitted');
287
+ expect(chain.description).not.toContain('Shared rules for all file tools');
286
288
  });
287
289
 
288
290
  it('a $defs/$ref tool schema survives tools/list and a real MCP client accepts it', async () => {
@@ -612,27 +614,33 @@ describe('McpService — agent instructions', () => {
612
614
  expect.stringContaining('mcp-description.md'),
613
615
  expect.objectContaining({ message: 'disk' }),
614
616
  );
615
- // And the four tools carry the fixed line alone.
617
+ // And the four tools carry the fixed line alone, ahead of the guide-first
618
+ // opening every listed tool has.
616
619
  const { tools } = await client.listTools();
617
620
  for (const name of KB_TOOLS) {
618
- expect(tools.find((t) => t.name === name)?.description).toBe(`${TOOL_PREFIX_LINE}\n\noriginal ${name} description`);
621
+ expect(tools.find((t) => t.name === name)?.description).toBe(
622
+ `${TOOL_PREFIX_LINE}\n\n${GUIDE_FIRST_SENTENCE} original ${name} description`,
623
+ );
619
624
  }
620
625
  });
621
626
 
622
- it('prefixes exactly the four knowledge-base tools; every other description is byte-for-byte unchanged', async () => {
627
+ it('prefixes exactly the four knowledge-base tools; every other description is as the catalog lists it', async () => {
623
628
  const client = await setup({ readAgentPreamble: async () => 'Acme builds solar farms.', extraTools: KB_TOOLS });
624
629
  const { tools } = await client.listTools();
625
630
  const byName = Object.fromEntries(tools.map((t) => [t.name, t.description]));
626
631
  const prefix = `${TOOL_PREFIX_LINE} Acme builds solar farms.`;
627
632
  for (const name of KB_TOOLS) {
628
- expect(byName[name], name).toBe(`${prefix}\n\noriginal ${name} description`);
633
+ expect(byName[name], name).toBe(`${prefix}\n\n${GUIDE_FIRST_SENTENCE} original ${name} description`);
629
634
  }
630
- // Regression: the rest, meta-tools included, is untouched.
631
- expect(byName.ask).toBe('echo the prompt');
632
- expect(byName.boom).toBe('always errors');
633
- expect(byName.refy).toBe('has $defs/$ref in its schema');
635
+ // Regression: the rest carries only what the catalog gives every tool —
636
+ // the guide-first opening — and never the purpose prefix; the meta-tools
637
+ // open with that sentence too.
638
+ expect(byName.ask).toBe(`${GUIDE_FIRST_SENTENCE} echo the prompt`);
639
+ expect(byName.boom).toBe(`${GUIDE_FIRST_SENTENCE} always errors`);
640
+ expect(byName.refy).toBe(`${GUIDE_FIRST_SENTENCE} has $defs/$ref in its schema`);
634
641
  for (const meta of ['call_tool_chain', 'list_tools', 'tools_info']) {
635
642
  expect(byName[meta], meta).not.toContain(TOOL_PREFIX_LINE);
643
+ expect(byName[meta]!.startsWith(`${GUIDE_FIRST_SENTENCE} `), meta).toBe(true);
636
644
  }
637
645
  });
638
646