@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
@@ -7,7 +7,6 @@ import { deploymentSettings } from '../database/core-schema.js';
7
7
  import {
8
8
  DEFAULT_KB_LAYOUT,
9
9
  type KbLayout,
10
- validateAgentsFileName,
11
10
  validateBranchModel,
12
11
  validateKbLayout,
13
12
  validateKbRootName,
@@ -228,14 +227,16 @@ export const CORE_SETTINGS: SettingDef[] = [
228
227
  /**
229
228
  * The KB layout: the three root folders a deployment may rename so hexis can
230
229
  * read a repository laid out by someone else (`skills/` and `plugins/` in
231
- * lowercase, say), and the file name of the managed agent guide.
230
+ * lowercase, say). The agent guide's name is not among them any more: the
231
+ * guide is served by the platform as `AGENTS.md` everywhere, and a name a
232
+ * deployment saved for the written guide is ignored.
232
233
  * Restart-to-apply like the branch model — the names are applied once at boot
233
234
  * through `configureKbLayout` and served to the browser once by
234
235
  * `/api/config`. Each has a default, so an unset field means the default, not
235
- * an unconfigured deployment. Checked as a QUARTET in `save`: the four must
236
+ * an unconfigured deployment. Checked as a TRIO in `save`: the three must
236
237
  * differ, and one field alone cannot see the other three.
237
238
  *
238
- * NO `envVar`, on any of the four. Layout is deployment configuration that is
239
+ * NO `envVar`, on any of the three. Layout is deployment configuration that is
239
240
  * entered once in the app; the three that used to be environment-driven are
240
241
  * imported into their saved setting on the first boot after the upgrade
241
242
  * ({@link DeploymentSettingsService.importLegacyLayoutEnv}) so nothing
@@ -262,37 +263,6 @@ export const CORE_SETTINGS: SettingDef[] = [
262
263
  restartToApply: true,
263
264
  unsetMeans: DEFAULT_KB_LAYOUT.pluginsDir,
264
265
  },
265
- {
266
- /**
267
- * The managed agent guide's file name. Its own validator says what a guide
268
- * may be called; the quartet check in `plan` is what keeps it clear of the
269
- * three folder names it is saved beside.
270
- */
271
- key: 'agentsFile',
272
- section: 'knowledge-base',
273
- // Judged against the default roots here; the quartet check in `plan`
274
- // judges it against the roots the same save puts in effect.
275
- validate: (v) => validateAgentsFileName(v, DEFAULT_KB_LAYOUT),
276
- restartToApply: true,
277
- unsetMeans: DEFAULT_KB_LAYOUT.agentsFile,
278
- },
279
- {
280
- /**
281
- * Whether to keep the platform's one-sentence pointer in a customer's own
282
- * `AGENTS.md` — the admin's consent to the only text the platform ever adds
283
- * to a file it does not own. On unless it is explicitly turned off, because
284
- * a renamed guide nothing points at is a guide no coding agent will find.
285
- *
286
- * Restart-to-apply like the name it belongs to: the check runs once per
287
- * start, in the KB startup phase.
288
- */
289
- key: 'agentsFileLink',
290
- section: 'knowledge-base',
291
- validate: (v) => (v === 'true' || v === 'false' ? null : 'Use "true" or "false".'),
292
- restartToApply: true,
293
- // On unless explicitly turned off — the reading `resolveAgentsFileLink` applies.
294
- unsetMeans: 'true',
295
- },
296
266
 
297
267
 
298
268
  /**
@@ -587,20 +557,10 @@ export class DeploymentSettingsService {
587
557
  knowledgeBaseDir: this.resolve('knowledgeBaseDir') || DEFAULT_KB_LAYOUT.knowledgeBaseDir,
588
558
  skillsDir: this.resolve('skillsDir') || DEFAULT_KB_LAYOUT.skillsDir,
589
559
  pluginsDir: this.resolve('pluginsDir') || DEFAULT_KB_LAYOUT.pluginsDir,
590
- agentsFile: this.resolve('agentsFile') || DEFAULT_KB_LAYOUT.agentsFile,
560
+ agentsFile: DEFAULT_KB_LAYOUT.agentsFile,
591
561
  };
592
562
  }
593
563
 
594
- /**
595
- * Whether the platform should keep its pointer sentence in a customer-owned
596
- * `AGENTS.md`. On unless the admin turned it off — an unset setting is a
597
- * deployment that never saw the checkbox, and the sentence is what makes a
598
- * renamed guide findable at all.
599
- */
600
- resolveAgentsFileLink(): boolean {
601
- return this.resolve('agentsFileLink') !== 'false';
602
- }
603
-
604
564
  /**
605
565
  * Import the retired layout environment variables into their saved settings,
606
566
  * ONCE, on a deployment that still sets them.
@@ -832,12 +792,12 @@ export class DeploymentSettingsService {
832
792
  if (problem) problems.protectedBranches = problem;
833
793
  }
834
794
 
835
- // The layout quartet is the other cross-field rule: three folder names and
836
- // a guide file name that must all differ. Judged on the layout this save
837
- // WOULD produce, with the default standing in for anything neither written
838
- // nor stored — so renaming the plugins folder to what the guide is already
839
- // called is refused whichever of the two the save names.
840
- const layoutKeys = ['knowledgeBaseDir', 'skillsDir', 'pluginsDir', 'agentsFile'] as const;
795
+ // The layout trio is the other cross-field rule: three folder names that
796
+ // must all differ. Judged on the layout this save WOULD produce, with the
797
+ // default standing in for anything neither written nor stored — so
798
+ // renaming one folder to what another is already called is refused
799
+ // whichever of the two the save names.
800
+ const layoutKeys = ['knowledgeBaseDir', 'skillsDir', 'pluginsDir'] as const;
841
801
  if (toWrite.some((w) => (layoutKeys as readonly string[]).includes(w.key))) {
842
802
  const effective = (key: (typeof layoutKeys)[number]) =>
843
803
  toWrite.find((w) => w.key === key)?.value || this.resolve(key) || DEFAULT_KB_LAYOUT[key];
@@ -845,7 +805,6 @@ export class DeploymentSettingsService {
845
805
  knowledgeBaseDir: effective('knowledgeBaseDir'),
846
806
  skillsDir: effective('skillsDir'),
847
807
  pluginsDir: effective('pluginsDir'),
848
- agentsFile: effective('agentsFile'),
849
808
  });
850
809
  // Against the field being written — the first one in the batch — since
851
810
  // any of the three could be the one that collides.
@@ -873,7 +832,7 @@ export class DeploymentSettingsService {
873
832
  knowledgeBaseDir: effective('knowledgeBaseDir'),
874
833
  skillsDir: effective('skillsDir'),
875
834
  pluginsDir: effective('pluginsDir'),
876
- agentsFile: effective('agentsFile'),
835
+ agentsFile: DEFAULT_KB_LAYOUT.agentsFile,
877
836
  });
878
837
  } catch (err) {
879
838
  // Against the field this save is writing — the checkout name when that
@@ -64,17 +64,14 @@ const SECRET_FOR_THAT_PROVIDER =
64
64
  'Enter the application secret for that provider — the saved one is only sent to the provider it was saved for.';
65
65
 
66
66
  /**
67
- * The knowledge-base layout as setting keys: the three renameable roots, the
68
- * guide's file name, and the consent that rides with it — which the KB startup
69
- * phase reads through a getter, so the completing save puts it in effect along
70
- * with the names.
67
+ * The knowledge-base layout as setting keys: the three renameable roots. The
68
+ * guide's file name is not one of them — the settings layer sets none, so the
69
+ * layout it produces carries the default, `AGENTS.md`.
71
70
  */
72
71
  const LAYOUT_KEYS: readonly string[] = [
73
72
  'knowledgeBaseDir',
74
73
  'skillsDir',
75
74
  'pluginsDir',
76
- 'agentsFile',
77
- 'agentsFileLink',
78
75
  ];
79
76
  /** The branch model, as setting keys. */
80
77
  const BRANCH_KEYS: readonly string[] = ['defaultBranch', 'protectedBranches'];
@@ -0,0 +1,91 @@
1
+ import express from 'express';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
4
+ import { TOOL_DESCRIPTION_CAP, clientVisibleLength } from '../../tool-registry/description-length.js';
5
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
6
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
7
+ import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
8
+ import { registerSkillsTools } from '../skills.tools.js';
9
+ import type { ISkillService, Skill } from '../skills.contract.js';
10
+
11
+ /**
12
+ * The two skill tools name the skills the caller may read in their
13
+ * descriptions. The catalog is the organisation's to grow, and a client cuts
14
+ * a long description from the END — so the names are the part that gives way,
15
+ * never the tool's own text, and never an id cut in half.
16
+ */
17
+ describe('the skill tools name the available skills within the description cap', () => {
18
+ const unused = () => new Proxy({}, { get: () => undefined }) as never;
19
+
20
+ async function served(skills: readonly Skill[]) {
21
+ const registry = new ToolRegistry();
22
+ const skillService = { listSkills: async () => skills } as unknown as ISkillService;
23
+ registerSkillsTools(registry, express.Router(), ((_req, _res, next) => next()) as unknown as ToolAuth, createToolHandlerFactory(unused()), skillService);
24
+ const tools = await registry.listExternal();
25
+ return {
26
+ list: tools.find((t) => t.name === 'list_skills')!,
27
+ get: tools.find((t) => t.name === 'get_skill')!,
28
+ };
29
+ }
30
+
31
+ const skill = (name: string): Skill => ({ name, description: '', path: `Skills/${name}` }) as unknown as Skill;
32
+
33
+ it('names every skill when they fit, and says so plainly when there are none', async () => {
34
+ const few = await served(['rfi', 'deck-review'].map(skill));
35
+ for (const tool of [few.list, few.get]) {
36
+ expect(tool.description).toContain('Currently available skills: `rfi`, `deck-review`.');
37
+ expect(tool.description).not.toContain(' more');
38
+ }
39
+ const none = await served([]);
40
+ expect(none.list.description).toContain('No skills are currently available.');
41
+ });
42
+
43
+ it('lists every skill when the complete line fits, even where a shorter list plus its count would not', async () => {
44
+ // The budget is what the tool's fixed text leaves under the cap once the
45
+ // guide-first sentence is counted — read off a served description rather
46
+ // than guessed, so the names below are sized to fill it EXACTLY.
47
+ const probe = await served([skill('x')]);
48
+ const fixed = probe.list.description!.slice(GUIDE_FIRST_SENTENCE.length + 1, probe.list.description!.indexOf('Currently available skills: '));
49
+ const budget = TOOL_DESCRIPTION_CAP - GUIDE_FIRST_SENTENCE.length - 1 - fixed.length;
50
+ const head = 'Currently available skills: '.length;
51
+ // Twenty-one names, the LAST one short — shorter than the ", and 1 more…"
52
+ // tail a cut before it would carry — and the first one sized so the
53
+ // complete line is the budget to the character. A cut-then-count loop
54
+ // stops short of the complete list here (a prefix plus its count tail is
55
+ // over budget well before the last name) though the complete line fits.
56
+ const count = 21;
57
+ const base = Math.floor((budget - head - 1 - (count - 1) * 2 - count * 2) / count); // chars inside the backticks
58
+ const names = Array.from({ length: count }, (_, i) => `n${i}`.padEnd(base, 'x'));
59
+ names[count - 1] = 'z';
60
+ const slack = budget - (head + names.reduce((n, name) => n + name.length + 2, 0) + (count - 1) * 2 + 1);
61
+ names[0] = names[0] + 'y'.repeat(slack);
62
+ const { list } = await served(names.map(skill));
63
+ const line = list.description!.slice(list.description!.indexOf('Currently available skills: '));
64
+ expect(line.length).toBe(budget);
65
+ expect(line.endsWith('.')).toBe(true);
66
+ expect(line).not.toContain(' more');
67
+ for (const name of names) expect(line).toContain(`\`${name}\``);
68
+ });
69
+
70
+ it('cuts the list to what fits under the cap with the guide-first sentence counted, and counts the rest', async () => {
71
+ const many = Array.from({ length: 300 }, (_, i) => skill(`a-skill-with-a-long-name-number-${i}`));
72
+ const { list, get } = await served(many);
73
+ for (const tool of [list, get]) {
74
+ // Measured as the registry serves it: the opener in front.
75
+ expect(clientVisibleLength(tool), tool.name).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
76
+ expect(tool.description!.startsWith(GUIDE_FIRST_SENTENCE), tool.name).toBe(true);
77
+ // The tool's own text is whole; the names are what gave way.
78
+ expect(tool.description, tool.name).toContain('Currently available skills: `a-skill-with-a-long-name-number-0`');
79
+ const counted = /, and (\d+) more that list_skills names\.$/.exec(tool.description!);
80
+ expect(counted, tool.name).not.toBeNull();
81
+ const shown = (tool.description!.match(/`a-skill-with-a-long-name-number-\d+`/g) ?? []).length;
82
+ expect(shown + Number(counted![1]), tool.name).toBe(300);
83
+ // No name is cut in half: every one listed is a real one, closing
84
+ // backtick included — a dangling name (no closing backtick) is caught
85
+ // because the scan does not require one.
86
+ for (const name of tool.description!.match(/`a-skill-with-a-long-name-number-[^`,.]*`?/g) ?? []) {
87
+ expect(name, tool.name).toMatch(/^`a-skill-with-a-long-name-number-\d+`$/);
88
+ }
89
+ }
90
+ });
91
+ });
@@ -1,5 +1,7 @@
1
1
  import type { Router, RequestHandler } from 'express';
2
2
  import type { IToolRegistry, UtcpTool } from '../tool-registry/tool.contract.js';
3
+ import { TOOL_DESCRIPTION_CAP } from '../tool-registry/description-length.js';
4
+ import { GUIDE_FIRST_SENTENCE } from '../tool-registry/guide-first.js';
3
5
  import { ToolError, type ToolContext } from '../tool-helpers/tool.contract.js';
4
6
  import { toolDef } from '../tool-helpers/tool-def.js';
5
7
  import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
@@ -97,25 +99,66 @@ function branchArg(args: Record<string, unknown>): string | undefined {
97
99
  return typeof branch === 'string' && branch.trim().length > 0 ? branch : undefined;
98
100
  }
99
101
 
100
- /** "Currently available skills: `a`, `b`." (or a no-skills note), filtered to what the caller may read. */
101
- async function availableSkillsLine(skillService: ISkillService, userEmail?: string): Promise<string> {
102
+ /**
103
+ * "Currently available skills: `a`, `b`." (or a no-skills note), filtered to
104
+ * what the caller may read, and cut to `budget` characters: as many names as
105
+ * fit, then a count of the rest. The names are a convenience — `list_skills`
106
+ * is the complete answer — and a description a client cuts from the end
107
+ * would lose the tool's own tail to a catalog that grew; the budget is what
108
+ * the tool's fixed text leaves under {@link TOOL_DESCRIPTION_CAP} once the
109
+ * guide-first sentence the registry puts in front is counted.
110
+ */
111
+ async function availableSkillsLine(skillService: ISkillService, userEmail: string | undefined, budget: number): Promise<string> {
102
112
  const skills = await skillService.listSkills(userEmail);
103
113
  if (skills.length === 0) return 'No skills are currently available.';
104
- return `Currently available skills: ${skills.map((s) => `\`${s.name}\``).join(', ')}.`;
114
+ const names = skills.map((s) => `\`${s.name}\``);
115
+ const rest = (shown: number): string =>
116
+ shown < names.length ? `, and ${names.length - shown} more that list_skills names.` : '.';
117
+ const head = 'Currently available skills: ';
118
+ // The complete line first: it ends in a full stop, not in a count, so it
119
+ // can fit where a shorter list plus its "and N more" tail would not.
120
+ const complete = `${head}${names.join(', ')}.`;
121
+ if (complete.length <= budget) return complete;
122
+ // Otherwise one pass, accumulating: the cut is where the next name — with
123
+ // the separator before it and the tail that would follow it — no longer
124
+ // fits. (Rebuilding the joined prefix per candidate made this quadratic in
125
+ // the catalog's size, on every catalog listing.)
126
+ let shown = 0;
127
+ let length = head.length;
128
+ for (const name of names) {
129
+ const added = (shown > 0 ? 2 : 0) + name.length;
130
+ if (length + added + rest(shown + 1).length > budget) break;
131
+ length += added;
132
+ shown += 1;
133
+ }
134
+ if (shown === 0) {
135
+ return names.length === 1
136
+ ? '1 skill is currently available; list_skills names it.'
137
+ : `${names.length} skills are currently available; list_skills names them.`;
138
+ }
139
+ return `${head}${names.slice(0, shown).join(', ')}${rest(shown)}`;
140
+ }
141
+
142
+ /** What the fixed part of a description leaves the skills line, with the guide-first sentence counted. */
143
+ function skillsLineBudget(fixed: string): number {
144
+ return TOOL_DESCRIPTION_CAP - GUIDE_FIRST_SENTENCE.length - 1 - fixed.length;
105
145
  }
106
146
 
147
+ const LIST_SKILLS_DESCRIPTION =
148
+ 'List the available skills (reusable specialist instructions) with their names, descriptions and, ' +
149
+ 'for a skill that declares one, its current `version` (its SKILL.md `metadata.version`, else a ' +
150
+ 'top-level `version`, else `lifecycle.version`). ' +
151
+ 'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
152
+ 'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
153
+ 'what you need to try a skill you just wrote there; each skill that differs from the released ' +
154
+ 'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ';
155
+
107
156
  async function buildListSkillsDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
108
157
  return toolDef({
109
158
  name: 'list_skills',
110
159
  description:
111
- 'List the available skills (reusable specialist instructions) with their names, descriptions and, ' +
112
- 'for a skill that declares one, its current `version` (its SKILL.md `metadata.version`, else a ' +
113
- 'top-level `version`, else `lifecycle.version`). ' +
114
- 'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
115
- 'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
116
- 'what you need to try a skill you just wrote there; each skill that differs from the released ' +
117
- 'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ' +
118
- (await availableSkillsLine(skillService, userEmail)),
160
+ LIST_SKILLS_DESCRIPTION +
161
+ (await availableSkillsLine(skillService, userEmail, skillsLineBudget(LIST_SKILLS_DESCRIPTION))),
119
162
  path: '/api/agent/tools/list_skills',
120
163
  inputs: {
121
164
  type: 'object',
@@ -156,15 +199,18 @@ async function buildListSkillsDef(skillService: ISkillService, userEmail?: strin
156
199
  });
157
200
  }
158
201
 
202
+ const GET_SKILL_DESCRIPTION =
203
+ 'Load a skill by name: returns its full instructions (SKILL.md body) to follow, plus the skill ' +
204
+ 'folder path and the list of bundled files. Pass `file` to fetch a bundled file’s content ' +
205
+ '(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
206
+ 'one the skill declared, or `branch` names a draft to load it from. ';
207
+
159
208
  async function buildGetSkillDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
160
209
  return toolDef({
161
210
  name: 'get_skill',
162
211
  description:
163
- 'Load a skill by name: returns its full instructions (SKILL.md body) to follow, plus the skill ' +
164
- 'folder path and the list of bundled files. Pass `file` to fetch a bundled file’s content ' +
165
- '(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
166
- 'one the skill declared, or `branch` names a draft to load it from. ' +
167
- (await availableSkillsLine(skillService, userEmail)),
212
+ GET_SKILL_DESCRIPTION +
213
+ (await availableSkillsLine(skillService, userEmail, skillsLineBudget(GET_SKILL_DESCRIPTION))),
168
214
  path: '/api/agent/tools/get_skill',
169
215
  inputs: {
170
216
  type: 'object',
@@ -34,6 +34,7 @@ const DETAIL: ToolManualDetail = {
34
34
  type: 'inline',
35
35
  description: 'Read and write GitHub issues and PRs.',
36
36
  capabilities: [{ name: 'create_issue', description: 'Open an issue in a repo.' }],
37
+ hiddenTools: [],
37
38
  };
38
39
 
39
40
  let httpServer: HttpServer | undefined;
@@ -205,6 +206,62 @@ describe('ToolManualService.getDetail — capabilities + access', () => {
205
206
  });
206
207
  });
207
208
 
209
+ /**
210
+ * The tool page's half of the marker. The finding comes from the MCP proxy,
211
+ * which is the only thing that loads a connected server's tools; what this
212
+ * endpoint decides is WHO sees it — the people who may write the `.tool`,
213
+ * and nobody else.
214
+ */
215
+ describe('a tool of this server hidden for an invalid schema', () => {
216
+ const HIDDEN = {
217
+ manual: 'remote',
218
+ name: 'remote_srv_query',
219
+ path: '/properties/value/anyOf/0/required/0',
220
+ reason: 'must be a string',
221
+ marker:
222
+ 'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
223
+ };
224
+
225
+ const writer = (allowed: boolean): IAccessControl =>
226
+ ({
227
+ canRead: async () => true,
228
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
229
+ canWrite: async () => allowed,
230
+ }) as unknown as IAccessControl;
231
+
232
+ test('is reported to a caller who may write the tool', async () => {
233
+ await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
234
+ const service = svc(writer(true));
235
+ service.setHiddenTools({ hiddenFor: (manual) => (manual === 'remote' ? [HIDDEN] : []) });
236
+ expect((await service.getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([HIDDEN]);
237
+ });
238
+
239
+ test('is withheld from a caller who may only read it', async () => {
240
+ await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
241
+ const service = svc(writer(false));
242
+ service.setHiddenTools({ hiddenFor: () => [HIDDEN] });
243
+ expect((await service.getDetail('reader@x.eu', 'remote'))?.hiddenTools).toEqual([]);
244
+ });
245
+
246
+ test('is empty, and costs no write check, when the server is healthy', async () => {
247
+ await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
248
+ const canWrite = vi.fn(async () => true);
249
+ const service = svc({
250
+ canRead: async () => true,
251
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
252
+ canWrite,
253
+ } as unknown as IAccessControl);
254
+ service.setHiddenTools({ hiddenFor: () => [] });
255
+ expect((await service.getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([]);
256
+ expect(canWrite).not.toHaveBeenCalled();
257
+ });
258
+
259
+ test('is empty when no MCP surface has loaded a server yet', async () => {
260
+ await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
261
+ expect((await svc().getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([]);
262
+ });
263
+ });
264
+
208
265
  test('description is null (not undefined) when the file declares none', async () => {
209
266
  await withTool('bare.tool', JSON.stringify({ name: 'bare', type: 'inline', tools: [] }));
210
267
  const detail = await svc().getDetail('user@x.eu', 'bare');
@@ -9,6 +9,7 @@ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
9
9
  import { registerToolManualsTools } from '../tool-manuals.tools.js';
10
10
  import type { IToolManualService } from '../tool-manuals.contract.js';
11
11
  import { testKbContext } from '../../../__tests__/kb-context.js';
12
+ import type { HiddenTool, HiddenToolSource } from '../../../shared/hidden-tools.js';
12
13
 
13
14
  /**
14
15
  * `list_tool_setup` MUST respect the same access controls as every other tool
@@ -31,7 +32,10 @@ const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
31
32
  const CATALOG = [
32
33
  {
33
34
  slug: 'weather',
34
- name: 'weather',
35
+ // The catalog NAME differs from the slug on purpose: the hidden-tool
36
+ // source is keyed by name, and a lookup keyed by slug would pass a
37
+ // fixture where the two are the same string.
38
+ name: 'Weather Service',
35
39
  path: 'Plugins/weather.tool',
36
40
  type: 'mcp' as const,
37
41
  setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
@@ -93,7 +97,16 @@ const externalApiKeyService = {
93
97
  t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
94
98
  } as never;
95
99
 
96
- async function start(): Promise<string> {
100
+ /** What the proxy's schema check found for `weather`, when a test wires one in. */
101
+ const HIDDEN_WEATHER_TOOL: HiddenTool = {
102
+ manual: 'Weather Service',
103
+ name: 'weather_srv_forecast',
104
+ path: '/properties/value/anyOf/0/required/0',
105
+ reason: 'must be a string',
106
+ marker: 'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
107
+ };
108
+
109
+ async function start(hiddenTools?: HiddenToolSource): Promise<string> {
97
110
  const registry = new ToolRegistry();
98
111
  const internalToken = new InternalTokenService({ secret: 'test-secret' });
99
112
  const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
@@ -116,6 +129,7 @@ async function start(): Promise<string> {
116
129
  accessControl,
117
130
  variableStatus: { statusFor },
118
131
  kb: testKbContext(),
132
+ hiddenTools,
119
133
  });
120
134
  app.use('/api', router);
121
135
 
@@ -158,8 +172,10 @@ describe('list_tool_setup — access controls resolved for the caller', () => {
158
172
  // Bob can't read billing — it must be absent, not just canWrite=false.
159
173
  expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
160
174
  expect(bob.tools[0].canWrite).toBe(false);
161
- // Status was resolved for BOB's user id, not leaked from Alice's.
162
- expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['weather_SHARED_KEY']);
175
+ // Status was resolved for BOB's user id, not leaked from Alice's — under
176
+ // the key the vault derives from the manual's NAME (`Weather Service`:
177
+ // the space becomes `_`, every `_` is doubled, then the variable).
178
+ expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['Weather__Service_SHARED_KEY']);
163
179
  expect(bob.tools[0].variables[0].userConfigured).toBe(false);
164
180
  });
165
181
 
@@ -182,6 +198,59 @@ describe('list_tool_setup — access controls resolved for the caller', () => {
182
198
  expect(bob.invalid).toEqual([]);
183
199
  });
184
200
 
201
+ it('marks a tool hidden for an invalid schema, to the caller who may manage the server', async () => {
202
+ const base = await start({
203
+ hiddenFor: (manual) => (manual === 'Weather Service' ? [HIDDEN_WEATHER_TOOL] : []),
204
+ });
205
+
206
+ const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
207
+ tools: { slug: string; hiddenTools: HiddenTool[] }[];
208
+ };
209
+ // Alice writes `weather`, so she is the one who can get the schema fixed.
210
+ expect(alice.tools.find((t) => t.slug === 'weather')!.hiddenTools).toEqual([HIDDEN_WEATHER_TOOL]);
211
+ // The marker names the tool, the place and the reason, in one sentence.
212
+ expect(alice.tools.find((t) => t.slug === 'weather')!.hiddenTools[0].marker).toBe(
213
+ 'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
214
+ );
215
+ // Nothing is wrong with `billing`, which Alice cannot write anyway.
216
+ expect(alice.tools.find((t) => t.slug === 'billing')!.hiddenTools).toEqual([]);
217
+
218
+ // Bob READS `weather` and cannot write it: the marker is not his to see.
219
+ // He has no way to fix the schema, and the hidden tool is simply not among
220
+ // the ones he can call.
221
+ const bob = (await (await callSetup(base, 'bevel_bob')).json()) as {
222
+ tools: { slug: string; hiddenTools: HiddenTool[] }[];
223
+ };
224
+ expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
225
+ expect(bob.tools[0].hiddenTools).toEqual([]);
226
+ });
227
+
228
+ it('reports no hidden tool on a deployment with no MCP surface at all — no source wired', async () => {
229
+ const base = await start(); // no source wired, as a deployment without the proxy
230
+ const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
231
+ tools: { slug: string; hiddenTools: HiddenTool[] }[];
232
+ };
233
+ for (const tool of alice.tools) expect(tool.hiddenTools).toEqual([]);
234
+ });
235
+
236
+ it('reports no hidden tool to a manager whose server is healthy, or has not loaded yet — source wired, nothing found', async () => {
237
+ // The distinct case from the one above: the proxy IS there and has nothing
238
+ // against `weather`, which Alice manages. `[]` is the finding, not the
239
+ // absence of a finder.
240
+ const hiddenFor = vi.fn((): HiddenTool[] => []);
241
+ const base = await start({ hiddenFor });
242
+ const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
243
+ tools: { slug: string; canWrite: boolean; hiddenTools: HiddenTool[] }[];
244
+ };
245
+ const weather = alice.tools.find((t) => t.slug === 'weather')!;
246
+ expect(weather.canWrite).toBe(true);
247
+ expect(weather.hiddenTools).toEqual([]);
248
+ // Asked by the manual's catalog NAME, which is how the proxy knows it —
249
+ // not by its slug — and the answer came from the source.
250
+ expect(hiddenFor).toHaveBeenCalledWith('Weather Service');
251
+ expect(hiddenFor).not.toHaveBeenCalledWith('weather');
252
+ });
253
+
185
254
  it('rejects an unauthenticated call outright', async () => {
186
255
  const base = await start();
187
256
  const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
@@ -1,4 +1,5 @@
1
1
  import type { CallTemplate } from '@utcp/sdk';
2
+ import type { HiddenTool, HiddenToolSource } from '../../shared/hidden-tools.js';
2
3
 
3
4
  /**
4
5
  * Tool manuals — user-authored `*.tool` files under `Plugins/` in the DEFAULT
@@ -329,10 +330,11 @@ export interface ToolCapability {
329
330
 
330
331
  /**
331
332
  * A single tool manual for the BROWSER tool page (`GET /api/tools/:slug`): the
332
- * summary plus the two human-facing fields the catalog listing has no use for.
333
- * Both are normalized to `null` rather than left optional — the page renders a
334
- * definite "nothing here" state, so an absent field and an empty one are the
335
- * same thing to it.
333
+ * summary plus the three human-facing fields the catalog listing has no use
334
+ * for — the description, the capabilities and the tools hidden for an invalid
335
+ * schema. Each is normalized to a definite empty (`null`, `[]`) rather than
336
+ * left optional — the page renders a definite "nothing here" state, so an
337
+ * absent field and an empty one are the same thing to it.
336
338
  */
337
339
  export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'> {
338
340
  description: string | null;
@@ -342,6 +344,16 @@ export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'>
342
344
  * round-trip this endpoint deliberately does not make), so they report `[]`.
343
345
  */
344
346
  capabilities: ToolCapability[];
347
+ /**
348
+ * Tools of this manual that Hexis keeps off every agent surface because
349
+ * their input schema is not valid JSON Schema as the server sent it, each
350
+ * with the place and the reason.
351
+ *
352
+ * Only for a caller who may WRITE this manual's file: the marker is for the
353
+ * people who manage the server, who are the only ones who can get the schema
354
+ * fixed. `[]` for everyone else, and for a server with nothing wrong.
355
+ */
356
+ hiddenTools: HiddenTool[];
345
357
  }
346
358
 
347
359
  export interface IToolManualService {
@@ -374,6 +386,14 @@ export interface IToolManualService {
374
386
  */
375
387
  getDetail(userEmail: string, slug: string): Promise<ToolManualDetail | null>;
376
388
 
389
+ /**
390
+ * Wire where a hidden tool's schema finding comes from (the MCP proxy, which
391
+ * is constructed after this service — setter injection for the same reason
392
+ * `setMcpAuthDiscovery` is one). Without it `getDetail` reports no hidden
393
+ * tool, which is the honest answer for a deployment with no MCP surface.
394
+ */
395
+ setHiddenTools(source: HiddenToolSource): void;
396
+
377
397
  /**
378
398
  * One line per manual the caller can read, each carrying the manual's
379
399
  * identity AND a digest of the file it was parsed from — the input the
@@ -32,6 +32,7 @@ import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/v
32
32
  import { extractFrontmatter, resolveDeclaredId, isValidId, dedupeById } from '../../shared/frontmatter-id.js';
33
33
  import type { ITreeWalker } from '../../shared/fs.contract.js';
34
34
  import { TtlCache } from '../../shared/ttl-cache.js';
35
+ import type { HiddenToolSource } from '../../shared/hidden-tools.js';
35
36
  import {
36
37
  utcpNamespacePrefix,
37
38
  utcpNamespacedKey,
@@ -239,6 +240,8 @@ export type McpAuthDiscoveryResult =
239
240
  export class ToolManualService implements IToolManualService {
240
241
  private readonly cache: TtlCache<ScanResult>;
241
242
  private mcpAuthDiscovery?: McpAuthDiscoveryPort;
243
+ /** Where a hidden tool's schema finding comes from; absent until wired. */
244
+ private hiddenTools?: HiddenToolSource;
242
245
  /**
243
246
  * The refused set as it was last written to the log, so the warnings are
244
247
  * logged ONCE PER CHANGE rather than once per scan. A broken `.tool` sits
@@ -288,6 +291,10 @@ export class ToolManualService implements IToolManualService {
288
291
  this.mcpAuthDiscovery = discovery;
289
292
  }
290
293
 
294
+ setHiddenTools(source: HiddenToolSource): void {
295
+ this.hiddenTools = source;
296
+ }
297
+
291
298
  invalidate(): void {
292
299
  this.cache.invalidate();
293
300
  this.inFlightScan = null;
@@ -317,10 +324,20 @@ export class ToolManualService implements IToolManualService {
317
324
  // model, no second place for the access rules to drift.
318
325
  const found = (await this.accessibleManuals(userEmail)).find((m) => m.slug === slug);
319
326
  if (!found) return null;
327
+ // The marker is for whoever manages the server: the same per-file write
328
+ // verdict that gates setting the tool's shared secrets. A reader sees
329
+ // nothing of it — they can neither fix the schema nor do anything with the
330
+ // knowledge that one of the server's tools is off. The write check is
331
+ // asked only when there is something to show, so the healthy case (every
332
+ // tool of every server) costs no ACL round-trip.
333
+ const hidden = this.hiddenTools?.hiddenFor(found.name) ?? [];
334
+ const mayManage =
335
+ hidden.length > 0 && (await this.accessControl.canWrite(this.kb.defaultWorkspaceId(), userEmail, found.path));
320
336
  return {
321
337
  ...toSummary(found),
322
338
  description: found.description ?? null,
323
339
  capabilities: capabilitiesOf(found),
340
+ hiddenTools: mayManage ? hidden : [],
324
341
  };
325
342
  }
326
343