@bevel-software/platform-core-backend 0.25.2 → 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 (248) 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/steps/seed-tree.d.ts.map +1 -1
  146. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  147. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  148. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  149. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  151. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  153. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  155. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  156. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  157. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  158. package/dist/modules/workspace/workspace.tools.js +211 -18
  159. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  160. package/dist/shared/domain-errors.d.ts +11 -0
  161. package/dist/shared/domain-errors.d.ts.map +1 -1
  162. package/dist/shared/domain-errors.js +14 -0
  163. package/dist/shared/domain-errors.js.map +1 -1
  164. package/dist/shared/hidden-tools.d.ts +44 -0
  165. package/dist/shared/hidden-tools.d.ts.map +1 -0
  166. package/dist/shared/hidden-tools.js +13 -0
  167. package/dist/shared/hidden-tools.js.map +1 -0
  168. package/kb-template/.bevelignore +0 -5
  169. package/package.json +4 -3
  170. package/src/__tests__/kb-layout-config.test.ts +10 -100
  171. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  172. package/src/assets.ts +10 -0
  173. package/src/core/core-ports.ts +11 -0
  174. package/src/core/create-core-server.ts +13 -2
  175. package/src/core/create-core-services.ts +28 -4
  176. package/src/index.ts +2 -2
  177. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  178. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  179. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  180. package/src/modules/access/access-control.interface.ts +15 -0
  181. package/src/modules/access/access-control.service.ts +21 -0
  182. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  183. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  184. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  185. package/src/modules/agent-guide/agent-guide.ts +291 -0
  186. package/src/modules/agent-guide/index.ts +21 -0
  187. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  188. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  189. package/src/modules/agent-instructions/compose.ts +9 -6
  190. package/src/modules/agent-instructions/index.ts +0 -3
  191. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  192. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  193. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  194. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  195. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  196. package/src/modules/mcp/mcp.service.ts +137 -19
  197. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  198. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  199. package/src/modules/plugins/plugins.tools.ts +75 -15
  200. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  201. package/src/modules/settings/deployment-settings.service.ts +13 -54
  202. package/src/modules/settings/setup.routes.ts +3 -6
  203. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  204. package/src/modules/skills/skills.tools.ts +62 -16
  205. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  206. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  207. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  208. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  209. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  210. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  211. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  212. package/src/modules/tool-registry/description-length.ts +24 -26
  213. package/src/modules/tool-registry/guide-first.ts +34 -0
  214. package/src/modules/tool-registry/tool-registry.ts +9 -2
  215. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  216. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  217. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  218. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  219. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  220. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  221. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  222. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  223. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  224. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  225. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  226. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  227. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  228. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  229. package/src/modules/workflow/git/git.service.ts +537 -94
  230. package/src/modules/workflow/git/merge-commit.ts +88 -0
  231. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  232. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  233. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  234. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  235. package/src/modules/workflow/workflow.routes.ts +7 -2
  236. package/src/modules/workflow/workflow.service.ts +7 -0
  237. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  238. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  239. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  240. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  241. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  242. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  243. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  244. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  245. package/src/modules/workspace/workspace.tools.ts +226 -16
  246. package/src/shared/domain-errors.ts +15 -0
  247. package/src/shared/hidden-tools.ts +45 -0
  248. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,328 @@
1
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { mergeGroupsIntoRoles, parseRolesYaml } from '../../access-model/access-grammar.js';
4
+ import { sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
5
+ import {
6
+ CORE_SECTION_IDS,
7
+ PLATFORM_GUIDE_SEPARATOR,
8
+ WORKING_WITH_FILES_SECTION_ID,
9
+ agentGuideSections,
10
+ composeAgentGuide,
11
+ coreAgentGuideSections,
12
+ isAgentGuidePath,
13
+ isManagedGuide,
14
+ joinGuideSections,
15
+ withPlatformGuideAppended,
16
+ type AgentGuideSection,
17
+ } from '../agent-guide.js';
18
+
19
+ /**
20
+ * The guide is text the code owns, composed from sections when an agent asks
21
+ * for it. What is pinned here: that every section is there and in order, that
22
+ * the layout's names are rendered into it, that a distribution's hook shapes
23
+ * it, and what the guide says on the points agents have actually got wrong.
24
+ */
25
+
26
+ /** The section of `guide` whose heading line starts with `heading`, up to the next heading of the same or a higher level. */
27
+ function section(guide: string, heading: string): string {
28
+ const lines = guide.split('\n');
29
+ const at = lines.findIndex((line) => line.startsWith(heading));
30
+ expect(at, `the guide has no "${heading}" section`).toBeGreaterThan(-1);
31
+ const level = /^#+/.exec(heading)![0].length;
32
+ const body: string[] = [];
33
+ for (const line of lines.slice(at + 1)) {
34
+ if (new RegExp(`^#{1,${level}} `).test(line)) break;
35
+ body.push(line);
36
+ }
37
+ return body.join('\n');
38
+ }
39
+
40
+ describe('the guide is composed from the platform\'s sections', () => {
41
+ it('carries every core section, in order, the shared rules computed in their place', async () => {
42
+ const sections = await coreAgentGuideSections(DEFAULT_KB_LAYOUT);
43
+ expect(sections.map((s) => s.id)).toEqual(CORE_SECTION_IDS);
44
+ const rules = sections.find((s) => s.id === WORKING_WITH_FILES_SECTION_ID)!;
45
+ expect(rules.body).toBe(sharedFileRulesSection(DEFAULT_KB_LAYOUT));
46
+ expect(rules.literal).toBe(true);
47
+ // Every file section begins with its heading, so the guide reads as one document.
48
+ for (const s of sections) {
49
+ if (s.id === 'introduction') expect(s.body.startsWith('# Knowledge base')).toBe(true);
50
+ else expect(s.body.startsWith('## '), s.id).toBe(true);
51
+ }
52
+ // The order of the composed guide is the order of the sections — pinned
53
+ // exactly above by the id list, and by the join test below, which equates
54
+ // the composed guide with the sections joined.
55
+ const guide = await composeAgentGuide(DEFAULT_KB_LAYOUT);
56
+ expect(guide.endsWith('\n')).toBe(true);
57
+ expect(guide.endsWith('\n\n')).toBe(false);
58
+ });
59
+
60
+ it('renders the deployment\'s own root names and leaves no placeholder behind', async () => {
61
+ const guide = await composeAgentGuide({ knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' });
62
+ expect(guide).toContain('Extensions/<Plugin>/plugin.json');
63
+ expect(guide).toContain('`Docs/`');
64
+ expect(guide).toContain('`Abilities/`');
65
+ expect(guide).not.toContain('{{');
66
+ expect(guide).not.toContain('KnowledgeBase/');
67
+ // The shared rules are rendered once, by the code that builds them, and
68
+ // never passed through the renderer again: a folder literally named like a
69
+ // placeholder comes out as the folder it is.
70
+ const odd = await composeAgentGuide({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: '{{skillsDir}}', skillsDir: 'Playbooks' });
71
+ expect(odd).toContain(sharedFileRulesSection({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: '{{skillsDir}}', skillsDir: 'Playbooks' }));
72
+ });
73
+
74
+ it('says it is served, not written, and names the one file it is read as', async () => {
75
+ const intro = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '# Knowledge base').replace(/\n> ?/g, ' ');
76
+ expect(intro).toContain('**This guide is served by the platform.**');
77
+ expect(intro).toContain('`get_agent_guide` returns it, whole or one section at a time');
78
+ expect(intro).toContain('`read_file` on `AGENTS.md` at the repository root');
79
+ expect(intro).toContain('`grep` searches it there too');
80
+ expect(intro).toContain('never write this text into it');
81
+ // The old header, which proved a file on disk was the platform's, is gone
82
+ // from the served text — or every read would look like a stale copy.
83
+ expect(isManagedGuide(await composeAgentGuide(DEFAULT_KB_LAYOUT))).toBe(false);
84
+ // One name on every deployment: a name saved for the written guide changes nothing.
85
+ const stale = await composeAgentGuide({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
86
+ expect(stale).not.toContain('HEXIS.md');
87
+ expect(stale).toBe(await composeAgentGuide(DEFAULT_KB_LAYOUT));
88
+ // And points at the preamble first, before the platform mechanics.
89
+ const guide = await composeAgentGuide(DEFAULT_KB_LAYOUT);
90
+ expect(guide.indexOf('mcp-description.md')).toBeLessThan(guide.indexOf('## Directory Structure'));
91
+ // One line ending throughout, whatever the checkout wrote the files with.
92
+ expect(guide).not.toContain('\r');
93
+ });
94
+
95
+ it('serves the guide as sections with titles, the whole being the sections joined', async () => {
96
+ const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
97
+ expect(sections.map((s) => s.id)).toEqual(CORE_SECTION_IDS);
98
+ expect(sections.find((s) => s.id === 'introduction')!.title).toBe('Knowledge base');
99
+ expect(sections.find((s) => s.id === 'where-a-new-file-goes')!.title).toBe('Where a new file goes');
100
+ expect(sections.find((s) => s.id === 'skills')!.title).toContain('Skills (`Skills/');
101
+ expect(joinGuideSections(sections)).toBe(await composeAgentGuide(DEFAULT_KB_LAYOUT));
102
+ // A heading closed with its own run of `#` is titled without it, and a
103
+ // section that opens with no heading is titled by its id.
104
+ const added = await agentGuideSections(DEFAULT_KB_LAYOUT, (all) => [
105
+ ...all,
106
+ { id: 'custom', body: '## Custom ##\n\nX.\n' },
107
+ { id: 'bare', body: 'No heading here.\n' },
108
+ ]);
109
+ expect(added.find((s) => s.id === 'custom')!.title).toBe('Custom');
110
+ expect(added.find((s) => s.id === 'bare')!.title).toBe('bare');
111
+ });
112
+
113
+ /**
114
+ * A distribution's hook sees the sections and returns the sections. The
115
+ * three things it can do — append, replace by id, drop — each with its
116
+ * placeholders rendered like core's own, and the computed section left as
117
+ * it was built.
118
+ */
119
+ it('lets a distribution append, replace and drop sections by id, rendering its text like core\'s', async () => {
120
+ const hook = (sections: readonly AgentGuideSection[]) => [
121
+ ...sections
122
+ .filter((s) => s.id !== 'conventions')
123
+ .map((s) => (s.id === 'finding-things' ? { ...s, body: '## Finding things\n\nAsk the graph.\n' } : s)),
124
+ { id: 'knowledge-graph', body: '## Knowledge graph\n\nNodes live under `{{knowledgeBaseDir}}/<Ontology>/Knowledge/`.\n' },
125
+ ];
126
+ const guide = await composeAgentGuide({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }, hook);
127
+ expect(guide).not.toContain('## Conventions');
128
+ expect(section(guide, '## Finding things')).toContain('Ask the graph.');
129
+ expect(section(guide, '## Finding things')).not.toContain('grep');
130
+ expect(guide.trimEnd().endsWith('Nodes live under `Docs/<Ontology>/Knowledge/`.')).toBe(true);
131
+ expect(guide).toContain(sharedFileRulesSection({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }));
132
+ // An async hook is awaited, and the layout it is handed is the resolved one.
133
+ const seen: string[] = [];
134
+ await composeAgentGuide({ knowledgeBaseDir: ' Docs ', skillsDir: 'Skills', pluginsDir: 'Plugins' }, async (sections, layout) => {
135
+ seen.push(layout.knowledgeBaseDir, layout.agentsFile);
136
+ return sections;
137
+ });
138
+ expect(seen).toEqual(['Docs', 'AGENTS.md']);
139
+ });
140
+
141
+ it('refuses a hook that drops or empties the shared file rules, which every file tool points at', async () => {
142
+ await expect(
143
+ composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) => sections.filter((s) => s.id !== WORKING_WITH_FILES_SECTION_ID)),
144
+ ).rejects.toThrow(/dropped the "working-with-files" section/);
145
+ // Handing it back with nothing in it is the same loss: judged on the
146
+ // text that would be served, so whitespace counts as nothing.
147
+ for (const body of ['', ' \n\n']) {
148
+ await expect(
149
+ composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) =>
150
+ sections.map((s) => (s.id === WORKING_WITH_FILES_SECTION_ID ? { id: s.id, body } : s)),
151
+ ),
152
+ ).rejects.toThrow(/emptied the "working-with-files" section/);
153
+ }
154
+ // The id twice, one empty and one with the rules: judged over every
155
+ // section under the id, so the one with text satisfies it.
156
+ const twice = await composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) => [
157
+ { id: WORKING_WITH_FILES_SECTION_ID, body: '' },
158
+ ...sections,
159
+ ]);
160
+ expect(twice).toContain(sharedFileRulesSection(DEFAULT_KB_LAYOUT));
161
+ // Replacing it under the same id is the hook's right.
162
+ const replaced = await composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) =>
163
+ sections.map((s) => (s.id === WORKING_WITH_FILES_SECTION_ID ? { id: s.id, body: '## Working with files\n\nOurs.\n' } : s)),
164
+ );
165
+ expect(section(replaced, '## Working with files')).toContain('Ours.');
166
+ });
167
+
168
+ it('names the checkout folder this deployment uses, so the paths it shows are the paths the tools take', async () => {
169
+ const guide = await composeAgentGuide(DEFAULT_KB_LAYOUT, undefined, { kbDirName: 'repo' });
170
+ expect(guide).toContain('repository as the `repo/` folder');
171
+ expect(guide).toContain('`repo/KnowledgeBase/Foo.md`');
172
+ expect(guide).not.toContain('knowledge-base/');
173
+ expect(guide).not.toContain('{{kbDirName}}');
174
+ // Core's own default when none is given.
175
+ expect(await composeAgentGuide(DEFAULT_KB_LAYOUT)).toContain('`knowledge-base/KnowledgeBase/Foo.md`');
176
+ });
177
+
178
+ it('puts the knowledge base\'s own file first, then a separator, then the guide — and nothing before the guide when the file is empty', () => {
179
+ const joined = withPlatformGuideAppended('# Acme\r\n\r\nWrite tickets in the present tense.\r\n\r\n', 'THE GUIDE\n');
180
+ expect(joined).toBe(`# Acme\n\nWrite tickets in the present tense.\n\n${PLATFORM_GUIDE_SEPARATOR}\n\nTHE GUIDE\n`);
181
+ expect(PLATFORM_GUIDE_SEPARATOR.startsWith('---\n')).toBe(true);
182
+ // A file with nothing in it has nothing to put first.
183
+ expect(withPlatformGuideAppended('', 'THE GUIDE\n')).toBe('THE GUIDE\n');
184
+ expect(withPlatformGuideAppended('\n\n', 'THE GUIDE\n')).toBe('THE GUIDE\n');
185
+ // Their whitespace is markdown and stays: indentation is code, two
186
+ // trailing spaces are a hard break.
187
+ expect(withPlatformGuideAppended(' code\nline \nnext\n', 'THE GUIDE\n')).toBe(
188
+ ` code\nline \nnext\n\n${PLATFORM_GUIDE_SEPARATOR}\n\nTHE GUIDE\n`,
189
+ );
190
+ });
191
+
192
+ it('answers at AGENTS.md in the repository root, and nowhere else', () => {
193
+ expect(isAgentGuidePath('AGENTS.md')).toBe(true);
194
+ expect(isAgentGuidePath('/AGENTS.md')).toBe(true);
195
+ expect(isAgentGuidePath('Handbook/AGENTS.md')).toBe(false);
196
+ expect(isAgentGuidePath('agents.md')).toBe(false);
197
+ expect(isAgentGuidePath('HEXIS.md')).toBe(false);
198
+ expect(isAgentGuidePath('')).toBe(false);
199
+ });
200
+
201
+ it('recognises a copy an earlier release wrote to disk by its header line, and nothing else', () => {
202
+ // The header as every release wrote it: a blockquote under the title.
203
+ expect(isManagedGuide('# Knowledge base\n\n> **This file is managed by the platform.** It lives at…\n')).toBe(true);
204
+ expect(isManagedGuide('# Company Knowledge graph\n\nThis is a git-backed knowledge graph.\n\n> **This file is managed by the platform.** Every server restart\n> replaces it.\n')).toBe(true);
205
+ expect(isManagedGuide('# Acme conventions\n\nThis file is managed by us.\n')).toBe(false);
206
+ // The organisation's own note that QUOTES the platform's sentence in its
207
+ // body is theirs: the sentence is not in a header blockquote near the top.
208
+ expect(isManagedGuide('# On the old guide\n\nThe platform used to write a file that opened with **This file is managed by the platform.** and refreshed it.\n')).toBe(false);
209
+ const deep = `# Notes\n${'\n'.repeat(20)}> **This file is managed by the platform.** (quoted from the old guide)\n`;
210
+ expect(isManagedGuide(deep)).toBe(false);
211
+ });
212
+ });
213
+
214
+ /**
215
+ * What the guide says on the points agents have got wrong. Pinned on the
216
+ * served text, so a section that is dropped or rewritten fails here rather
217
+ * than in a deployment.
218
+ */
219
+ describe('what the guide tells an agent', () => {
220
+ /**
221
+ * The placement rule. A tester's agent filed a ticket into a plugin folder
222
+ * and explained itself: it had found no convention saying where tickets go,
223
+ * and Plugins was where it already held write rights.
224
+ */
225
+ it('says where a new file goes, named for the deployment\'s roots', async () => {
226
+ const guide = await composeAgentGuide({ knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' });
227
+ const placement = section(guide, '## Where a new file goes');
228
+ expect(placement).toContain('`Docs/`');
229
+ expect(placement).toContain('`Abilities/`');
230
+ expect(placement).toContain('Extensions/<Plugin>/skills/<skill>/SKILL.md');
231
+ expect(placement).toContain('Extensions/<Plugin>/software.bevel.hexis/tools/');
232
+ for (const kind of ['notes', 'reports', 'tickets', 'mcp.json', 'plugin.json']) {
233
+ expect(placement, kind).toContain(kind);
234
+ }
235
+ expect(placement).toMatch(/never holds a document/);
236
+ expect(placement).toMatch(/\bask\b/);
237
+ });
238
+
239
+ /**
240
+ * The personal plugin, where the placement rule was being read backwards.
241
+ * Nothing under the plugins root is in the knowledge graph, and a personal
242
+ * plugin is readable only by its owner — so a note filed there is never found
243
+ * as knowledge again, by anyone. Agents asked to "save this for me" were
244
+ * taking "a personal space" as the place to put it.
245
+ */
246
+ it('says a personal plugin holds only skills and tools, and what to do when a user wants a note private', async () => {
247
+ const guide = await composeAgentGuide({ knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' });
248
+ const placement = section(guide, '## Where a new file goes').replace(/\s+/g, ' ');
249
+ expect(placement).toContain("A personal plugin holds only its owner's skills and tools");
250
+ expect(placement).toContain('Extensions/personal-<id>/');
251
+ // A skill's own bundled files are part of the skill and stay welcome, so the
252
+ // rule does not deter an agent from writing a COMPLETE skill.
253
+ expect(placement).toContain("each skill's own bundled files");
254
+ expect(placement).toContain("inside that skill's folder included");
255
+ expect(placement).toContain('never a note or any other document');
256
+ // A private request gets a question and the restrictable folder, in the
257
+ // deployment's own root name.
258
+ expect(placement).toContain('ask where under `Docs/` it should go');
259
+ expect(placement).toContain('restricted so only they can read it');
260
+ // And when the user insists, the agent declines, says why, and offers again.
261
+ expect(placement).toContain('If they insist on the personal plugin, decline');
262
+ expect(placement).toContain('sits outside the knowledge graph, where it is never found as knowledge again');
263
+ expect(placement).toContain('offer a place under `Docs/` once more');
264
+ // Nothing tells the agent to move or flag documents already filed there.
265
+ expect(placement).not.toMatch(/\bmove (them|it|any)\b/);
266
+ });
267
+
268
+ /**
269
+ * Every agent-facing mention of the folder calls it the "personal plugin" —
270
+ * the name the app itself shows. The three phrases the guide used instead are
271
+ * what an agent matched "keep this private" against, so they are pinned out
272
+ * of the WHOLE guide rather than out of one section.
273
+ */
274
+ it('calls the folder the "personal plugin" everywhere, and no longer a "space"', async () => {
275
+ for (const layout of [DEFAULT_KB_LAYOUT, { knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' }]) {
276
+ const guide = await composeAgentGuide(layout);
277
+ for (const retired of ['personal space', 'private space', 'own space']) {
278
+ expect(guide, retired).not.toContain(retired);
279
+ }
280
+ const plugins = 'pluginsDir' in layout ? layout.pluginsDir : DEFAULT_KB_LAYOUT.pluginsDir;
281
+ // The four places that introduce it: the `my_plugin` bullet, the sentence
282
+ // on moving a skill, the placement rule, and the `everyone` note.
283
+ const prose = guide.replace(/\s+/g, ' ');
284
+ expect(prose).toContain("`my_plugin` — your user's personal plugin, holding their own skills and tools");
285
+ expect(prose).toContain('A skill moves from a personal plugin into a shared plugin by moving its folder.');
286
+ expect(prose).toContain("A person's private skill goes in their personal plugin");
287
+ expect(prose).toContain(
288
+ `A person's personal plugin (\`${plugins}/personal-<id>/\`) grants its owner access and denies \`everyone\` outright`,
289
+ );
290
+ }
291
+ });
292
+
293
+ it('says that roles are pre-set and a "new role" is usually a group', async () => {
294
+ const prose = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '### Roles are pre-set').replace(/\s+/g, ' ');
295
+ expect(prose).toContain('A role in `roles.yaml` is an app role');
296
+ expect(prose).toContain('**Agents never create roles.**');
297
+ expect(prose).toContain('**Is it really a group?**');
298
+ expect(prose).toContain('**What to do instead.**');
299
+ expect(prose).toContain('add people to existing roles, and use a GROUP for a task- or team-scoped set of people');
300
+ });
301
+
302
+ it('documents giving a role to a group, with an example that parses as a valid roles.yaml', async () => {
303
+ const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '### Giving a role to a group');
304
+ const prose = text.replace(/\s+/g, ' ');
305
+ expect(prose).toContain('`- group:<Name>`');
306
+ expect(prose).toContain('case- and whitespace-insensitively against the active group source');
307
+ expect(prose).toContain('validation error');
308
+ expect(prose).toContain("removes the role's contribution for everyone in the group");
309
+ expect(prose).toContain('`deny role/Reviewer`');
310
+ expect(prose).toContain('A group under `Admin` makes every member a full admin');
311
+ // A change request cannot carry a roles.yaml edit, so the guide says who
312
+ // changes roles and where, and never walks an agent through drafting one.
313
+ expect(prose).toContain('Only an Admin changes `roles.yaml`');
314
+ expect(prose).toContain('Do not propose one');
315
+ for (const tool of ['create_branch', 'commit_change', 'open_change_request']) {
316
+ expect(prose).not.toContain(`\`${tool}\``);
317
+ }
318
+ const example = /```yaml\n([\s\S]*?)```/.exec(text)?.[1] ?? '';
319
+ expect(example).toContain('- group:Platform Team');
320
+ const parsed = parseRolesYaml(example);
321
+ expect(parsed.ok).toBe(true);
322
+ if (!parsed.ok) return;
323
+ const groups = new Map([['platform team', { displayName: 'Platform Team', emails: new Set(['pat@example.com']) }]]);
324
+ expect(mergeGroupsIntoRoles(parsed.index, groups, 'groups.yaml')).toEqual([]);
325
+ expect(parsed.index.byEmail.get('pat@example.com')?.has('reviewer')).toBe(true);
326
+ expect(parsed.index.byEmail.get('pat@example.com')?.has('role/admin')).toBe(false);
327
+ });
328
+ });
@@ -0,0 +1,189 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
6
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
7
+ import { TOOL_DESCRIPTION_CAP } from '../../tool-registry/description-length.js';
8
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
9
+ import type { ToolContext } from '../../tool-helpers/tool.contract.js';
10
+ import { toolDef } from '../../tool-helpers/tool-def.js';
11
+ import { GET_AGENT_GUIDE_TOOL, registerAgentGuideTool } from '../agent-guide.tools.js';
12
+ import type { RenderedGuideSection } from '../agent-guide.js';
13
+
14
+ let server: Server | null = null;
15
+
16
+ afterEach(async () => {
17
+ if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
18
+ server = null;
19
+ });
20
+
21
+ const SECTIONS: RenderedGuideSection[] = [
22
+ { id: 'introduction', title: 'Knowledge base', body: '# Knowledge base\n\nRead me.' },
23
+ { id: 'access-control', title: 'Access control', body: '## Access control\n\nWho may do what.' },
24
+ { id: 'knowledge-graph', title: 'Knowledge graph', body: '## Knowledge graph\n\nA distribution added this one.' },
25
+ ];
26
+
27
+ describe('get_agent_guide', () => {
28
+ async function serve() {
29
+ const registry = new ToolRegistry();
30
+ const router = express.Router();
31
+ // Stands in for the connection-key auth: the caller is a signed-in reader.
32
+ const auth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
33
+ req.toolAuth = { source: 'internal', userId: 'u', scope: 'read' };
34
+ next();
35
+ };
36
+ const resolve = async (): Promise<ToolContext> =>
37
+ ({
38
+ user: { id: 'u', email: 'a@x.io', name: 'A' },
39
+ scope: 'read',
40
+ source: 'internal',
41
+ abortSignal: new AbortController().signal,
42
+ workspaceService: {} as never,
43
+ workflowService: {} as never,
44
+ events: {} as never,
45
+ getFilesystem: async () => {
46
+ throw new Error('the guide reads no file');
47
+ },
48
+ }) as unknown as ToolContext;
49
+ let reads = 0;
50
+ let current: RenderedGuideSection[] = SECTIONS;
51
+ registerAgentGuideTool(registry, router, auth, createToolHandlerFactory(resolve), async () => {
52
+ reads += 1;
53
+ return current;
54
+ });
55
+ const sectionsNow = (sections: RenderedGuideSection[]) => {
56
+ current = sections;
57
+ };
58
+ // Another tool beside it, to see what the catalog does to each.
59
+ registry.registerExternalTool(
60
+ toolDef({ name: 'read_file', description: 'Read a file.', path: '/api/agent/tools/read_file', inputs: { type: 'object', properties: {} }, tags: [] }),
61
+ );
62
+
63
+ const web = express();
64
+ web.use(express.json());
65
+ web.use('/api', router);
66
+ server = await new Promise<Server>((resolve) => {
67
+ const s = web.listen(0, '127.0.0.1', () => resolve(s));
68
+ });
69
+ const port = (server.address() as AddressInfo).port;
70
+ const call = async (body: Record<string, unknown> = {}) => {
71
+ const res = await fetch(`http://127.0.0.1:${port}/api/agent/tools/${GET_AGENT_GUIDE_TOOL}`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
74
+ body: JSON.stringify(body),
75
+ });
76
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> };
77
+ };
78
+ return { registry, call, reads: () => reads, sectionsNow };
79
+ }
80
+
81
+ it('is on both surfaces, says to call it first, and lists the sections the guide has now', async () => {
82
+ const { registry } = await serve();
83
+ for (const list of [registry.listExternal(), registry.listInternal()]) {
84
+ const def = (await list).find((t) => t.name === GET_AGENT_GUIDE_TOOL);
85
+ expect(def).toBeDefined();
86
+ expect(def!.description).toContain('ALWAYS call this first and read the guide before you do anything else');
87
+ expect(def!.description).toContain("`read_file` on `AGENTS.md` at the KB root");
88
+ // The sections, a distribution's own included, by id and title.
89
+ expect(def!.description).toContain(
90
+ 'Sections: `introduction` (Knowledge base), `access-control` (Access control), `knowledge-graph` (Knowledge graph).',
91
+ );
92
+ // The one tool that does not open with "call get_agent_guide first".
93
+ expect(def!.description.startsWith(GUIDE_FIRST_SENTENCE)).toBe(false);
94
+ // The def wraps a tool's inputs as its request `body`; this one takes `section` and nothing else.
95
+ const body = (def!.inputs as { properties: { body: { properties?: Record<string, unknown> } } }).properties.body;
96
+ expect(Object.keys(body.properties ?? {})).toEqual(['section']);
97
+ }
98
+ });
99
+
100
+ it('puts the guide-first sentence at the front of every other tool the catalog lists', async () => {
101
+ const { registry } = await serve();
102
+ const read = (await registry.listExternal()).find((t) => t.name === 'read_file')!;
103
+ expect(read.description).toBe(`${GUIDE_FIRST_SENTENCE} Read a file.`);
104
+ });
105
+
106
+ it('returns the whole guide, or one section by id, composed when asked', async () => {
107
+ const { call, reads, sectionsNow } = await serve();
108
+ expect(await call()).toEqual({
109
+ status: 200,
110
+ body: {
111
+ guide: '# Knowledge base\n\nRead me.\n\n## Access control\n\nWho may do what.\n\n## Knowledge graph\n\nA distribution added this one.\n',
112
+ // The complete list, whatever the description had room for.
113
+ sections: [
114
+ { id: 'introduction', title: 'Knowledge base' },
115
+ { id: 'access-control', title: 'Access control' },
116
+ { id: 'knowledge-graph', title: 'Knowledge graph' },
117
+ ],
118
+ },
119
+ });
120
+ expect(await call({ section: 'access-control' })).toEqual({
121
+ status: 200,
122
+ body: { guide: '## Access control\n\nWho may do what.', section: 'access-control', title: 'Access control' },
123
+ });
124
+ // A section the guide does not have is refused with the ones it has.
125
+ const unknown = await call({ section: 'nope' });
126
+ expect(unknown.status).toBe(400);
127
+ expect(String(unknown.body.error)).toContain('The guide has no section "nope". Sections: `introduction`, `access-control`, `knowledge-graph`.');
128
+ // Composed per call, never cached here: what the reader answers NOW is
129
+ // what the next call returns, so a layout applied later is seen.
130
+ expect(reads()).toBe(3);
131
+ sectionsNow([{ id: 'introduction', title: 'Renamed', body: '# Renamed\n\nThe layout changed.' }]);
132
+ expect(await call()).toEqual({
133
+ status: 200,
134
+ body: { guide: '# Renamed\n\nThe layout changed.\n', sections: [{ id: 'introduction', title: 'Renamed' }] },
135
+ });
136
+ expect(await call({ section: 'introduction' })).toMatchObject({ status: 200, body: { title: 'Renamed' } });
137
+ expect((await call({ section: 'access-control' })).status).toBe(400);
138
+ expect(reads()).toBe(6);
139
+ });
140
+
141
+ it('stays under the cap whatever a distribution adds: titles go first, then ids beyond what fits are counted', async () => {
142
+ // A client cuts a long description from the end, and an id cut off is a
143
+ // section an agent cannot ask for — so the description never carries a
144
+ // cut list. Three forms, by what fits: ids with titles; ids alone; as
145
+ // many ids as fit and the count of the rest, with the complete list on
146
+ // the whole-guide response's `sections`.
147
+ const { registry, call, sectionsNow } = await serve();
148
+ const describedAs = async () => (await registry.listExternal()).find((t) => t.name === GET_AGENT_GUIDE_TOOL)!.description!;
149
+
150
+ // Forty sections with long titles: the ids fit, the titles do not.
151
+ const many = Array.from({ length: 40 }, (_, i) => ({
152
+ id: `section-${i}`,
153
+ title: `A title long enough that forty of them do not fit, number ${i}`,
154
+ body: `## Title ${i}\n\nBody.`,
155
+ }));
156
+ sectionsNow([...SECTIONS, ...many]);
157
+ const idsOnly = await describedAs();
158
+ expect(idsOnly.length).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
159
+ for (const section of [...SECTIONS, ...many]) expect(idsOnly).toContain(`\`${section.id}\``);
160
+ expect(idsOnly).not.toContain('(Knowledge base)');
161
+ expect(idsOnly).not.toContain(' more');
162
+
163
+ // Two hundred sections with long ids: not even the ids fit. The ones
164
+ // that do are listed whole, the rest are counted, nothing is cut — and
165
+ // the whole-guide response names every one.
166
+ const flood = Array.from({ length: 200 }, (_, i) => ({
167
+ id: `a-section-id-long-enough-that-two-hundred-never-fit-${i}`,
168
+ title: `T${i}`,
169
+ body: `## T${i}\n\nBody.`,
170
+ }));
171
+ sectionsNow([...SECTIONS, ...flood]);
172
+ const counted = await describedAs();
173
+ expect(counted.length).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
174
+ expect(counted).toContain('`introduction`');
175
+ const shown = (counted.match(/`[^`]+`(?=, |\.$)/g) ?? []).filter((id) => id.startsWith('`a-section-id') || id === '`introduction`' || id === '`access-control`' || id === '`knowledge-graph`');
176
+ expect(shown.length).toBeGreaterThan(0);
177
+ expect(shown.length).toBeLessThan(203);
178
+ expect(counted).toMatch(new RegExp(`and ${203 - shown.length} more, all named under \`sections\` in the whole-guide response\\.$`));
179
+ // No id is ever cut mid-way: every listed id is one of the guide's.
180
+ const all = new Set([...SECTIONS, ...flood].map((s) => `\`${s.id}\``));
181
+ for (const id of shown) expect(all.has(id), id).toBe(true);
182
+ const whole = (await call()).body as { sections: { id: string }[] };
183
+ expect(whole.sections.map((s) => s.id)).toEqual([...SECTIONS, ...flood].map((s) => s.id));
184
+
185
+ // With the platform's few, the titles are there.
186
+ sectionsNow(SECTIONS);
187
+ expect(await describedAs()).toContain('`introduction` (Knowledge base)');
188
+ });
189
+ });
@@ -0,0 +1,122 @@
1
+ import type { Router, RequestHandler } from 'express';
2
+ import type { IToolRegistry, UtcpTool } from '../tool-registry/tool.contract.js';
3
+ import { GET_AGENT_GUIDE_TOOL } from '../tool-registry/guide-first.js';
4
+ import { TOOL_DESCRIPTION_CAP } from '../tool-registry/description-length.js';
5
+ import { ToolError } from '../tool-helpers/tool.contract.js';
6
+ import { toolDef } from '../tool-helpers/tool-def.js';
7
+ import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
8
+ import { AGENT_GUIDE_FILE, joinGuideSections, type AgentGuideSectionsReader } from './agent-guide.js';
9
+
10
+ export { GET_AGENT_GUIDE_TOOL };
11
+
12
+ /**
13
+ * The one tool that returns the guide on its own — whole, or one section by
14
+ * id. It takes nothing else: the guide is the platform's text for this
15
+ * deployment, the same for every caller and every branch, and reading it
16
+ * touches no file — so no `branch`, no `sessionId`, and no access gate. The
17
+ * other way to the same text is a `read_file` of `AGENTS.md` at the
18
+ * repository root, which also carries the knowledge base's own `AGENTS.md`
19
+ * when it has one (see `agent-guide.ts`).
20
+ *
21
+ * Registered as a PROVIDER on both surfaces, so the description names the
22
+ * sections the guide has NOW — a distribution's hook may add its own — and
23
+ * the in-process agent and an external one are told the same thing to read
24
+ * first. This is the one tool the catalog does not open with "call
25
+ * get_agent_guide first" (see `tool-registry/guide-first.ts`): it is what
26
+ * that sentence points at, and says so itself.
27
+ */
28
+ export function registerAgentGuideTool(
29
+ registry: IToolRegistry,
30
+ router: Router,
31
+ toolAuth: RequestHandler,
32
+ toolHandler: ToolHandlerFactory,
33
+ sections: AgentGuideSectionsReader,
34
+ ): void {
35
+ const build = async (): Promise<UtcpTool> => {
36
+ const list = await sections();
37
+ const describe = (sectionList: string) =>
38
+ "The platform's guide to this knowledge base: its layout, where a new file goes, the rules every file tool " +
39
+ 'shares, access control, skills and tool manuals. ALWAYS call this first and read the guide before you do ' +
40
+ 'anything else in the platform. Returns `{ guide }` as markdown: the whole guide, or one section when ' +
41
+ '`section` names one. The same text comes back from `read_file` on `' +
42
+ AGENT_GUIDE_FILE +
43
+ "` at the KB root, after the knowledge base's own " +
44
+ AGENT_GUIDE_FILE +
45
+ ` when it has one. Sections: ${sectionList}.`;
46
+ // The description stays under the cap WHATEVER a distribution adds, in
47
+ // three steps: every id with its title; every id alone; as many ids as
48
+ // fit and a count of the rest. An id a client cut off the end would be a
49
+ // section an agent cannot ask for — so nothing is ever cut: the one
50
+ // complete list of sections is `sections` on the whole-guide response,
51
+ // which the description names when it cannot carry them all itself.
52
+ const titled = describe(list.map((s) => `\`${s.id}\` (${s.title})`).join(', '));
53
+ const ids = list.map((s) => `\`${s.id}\``);
54
+ const counted = (shown: number) =>
55
+ describe(
56
+ shown === 0
57
+ ? `${ids.length}, named under \`sections\` in the whole-guide response`
58
+ : [...ids.slice(0, shown), `and ${ids.length - shown} more, all named under \`sections\` in the whole-guide response`].join(', '),
59
+ );
60
+ let description = titled.length <= TOOL_DESCRIPTION_CAP ? titled : describe(ids.join(', '));
61
+ for (let shown = ids.length; description.length > TOOL_DESCRIPTION_CAP && shown > 0; shown--) {
62
+ description = counted(shown - 1);
63
+ }
64
+ return toolDef({
65
+ name: GET_AGENT_GUIDE_TOOL,
66
+ description,
67
+ path: `/api/agent/tools/${GET_AGENT_GUIDE_TOOL}`,
68
+ inputs: {
69
+ type: 'object',
70
+ properties: {
71
+ section: {
72
+ type: 'string',
73
+ description:
74
+ 'Optional: the id of one section to read instead of the whole guide (the ids are listed in this ' +
75
+ 'description, and every one under `sections` in the whole-guide response). Omit it for the whole guide.',
76
+ },
77
+ },
78
+ additionalProperties: false,
79
+ },
80
+ outputs: {
81
+ type: 'object',
82
+ properties: {
83
+ guide: { type: 'string', description: 'The guide, or the one section asked for, as markdown.' },
84
+ sections: {
85
+ type: 'array',
86
+ description: 'Without `section`: every section of the guide, in order, by id and heading — the complete list.',
87
+ items: {
88
+ type: 'object',
89
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
90
+ required: ['id', 'title'],
91
+ },
92
+ },
93
+ section: { type: 'string', description: 'With `section`: the id of the section returned.' },
94
+ title: { type: 'string', description: 'With `section`: its heading.' },
95
+ },
96
+ required: ['guide'],
97
+ },
98
+ tags: ['workspace'],
99
+ });
100
+ };
101
+ registry.registerInternalTool(build);
102
+ registry.registerExternalTool(build);
103
+
104
+ router.post(
105
+ `/agent/tools/${GET_AGENT_GUIDE_TOOL}`,
106
+ toolAuth,
107
+ toolHandler(async (args) => {
108
+ const list = await sections();
109
+ const wanted = typeof args.section === 'string' ? args.section.trim() : '';
110
+ if (!wanted) return { guide: joinGuideSections(list), sections: list.map((s) => ({ id: s.id, title: s.title })) };
111
+ const one = list.find((s) => s.id === wanted);
112
+ if (!one) {
113
+ throw new ToolError(
114
+ `The guide has no section "${wanted}". Sections: ${list.map((s) => `\`${s.id}\``).join(', ')}. ` +
115
+ 'Omit `section` for the whole guide.',
116
+ 400,
117
+ );
118
+ }
119
+ return { guide: one.body, section: one.id, title: one.title };
120
+ }),
121
+ );
122
+ }