@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,13 @@
1
+ /**
2
+ * A connected tool Hexis keeps off every agent surface because its input
3
+ * schema is not valid JSON Schema as its server sent it — and the read port
4
+ * the surfaces that SHOW that to the server's owner use.
5
+ *
6
+ * Here rather than in either module because two of them meet over it: the MCP
7
+ * proxy finds these when a server's tools are loaded (`modules/mcp`), and the
8
+ * tool catalog reports them to the people who manage the server
9
+ * (`modules/tool-manuals`, on the tool page and in `list_tool_setup`). Neither
10
+ * has any business importing the other.
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=hidden-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden-tools.js","sourceRoot":"","sources":["../../src/shared/hidden-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
@@ -11,13 +11,8 @@
11
11
  # Repo hygiene files that clutter the tree without being node content.
12
12
  .gitignore
13
13
  .gitattributes
14
- # The platform's agent guide.
15
- {{agentsFile}}
16
14
  # The deployment preamble is edited from External agent access, not as a KB page.
17
15
  /mcp-description.md
18
- # The pre-rename name. Listed so a knowledge base carrying both files hides
19
- # both — top-up adds AGENTS.md but never deletes the CLAUDE.md beside it.
20
- CLAUDE.md
21
16
 
22
17
  # Empty-folder placeholders used only for committing empty dirs.
23
18
  **/.gitkeep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,6 +23,7 @@
23
23
  "src",
24
24
  "migrations",
25
25
  "kb-template",
26
+ "agent-guide",
26
27
  "THIRD-PARTY-NOTICES.md"
27
28
  ],
28
29
  "engines": {
@@ -52,8 +53,8 @@
52
53
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
53
54
  "yaml": "^2.9.0",
54
55
  "zod": "^3.24.0",
55
- "@bevel-software/platform-mcp-core": "0.25.2",
56
- "@bevel-software/platform-shared": "0.25.2"
56
+ "@bevel-software/platform-mcp-core": "0.26.0",
57
+ "@bevel-software/platform-shared": "0.26.0"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/adm-zip": "^0.5.8",
@@ -5,15 +5,12 @@ import {
5
5
  KNOWLEDGE_BASE_DIR,
6
6
  PLUGINS_DIR,
7
7
  SKILLS_DIR,
8
- agentsFilePointerSentence,
9
8
  configureKbLayout,
10
9
  currentKbLayout,
11
10
  isPlatformFile,
12
- mentionsAgentsFile,
13
11
  platformFileNames,
14
12
  pluginOfPath,
15
13
  renderKbLayoutPlaceholders,
16
- retargetAgentsFilePointer,
17
14
  reservedRootDirNames,
18
15
  validateAgentsFileName,
19
16
  validateKbLayout,
@@ -193,106 +190,19 @@ describe('KB layout — the agent guide\'s file name', () => {
193
190
  expect(renderKbLayoutPlaceholders('see {{agentsFile}}', DEFAULT_KB_LAYOUT)).toBe('see AGENTS.md');
194
191
  });
195
192
 
196
- test('the platform-file gate follows the name: ours is managed, theirs is content', () => {
197
- expect(platformFileNames(DEFAULT_KB_LAYOUT)).toEqual([
198
- 'access.md',
199
- 'roles.yaml',
200
- '.bevelignore',
201
- 'AGENTS.md',
202
- ]);
203
- expect(platformFile('AGENTS.md')).toBe(true);
204
-
205
- configureKbLayout({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
206
- expect(platformFileNames(currentKbLayout())).toEqual(['access.md', 'roles.yaml', '.bevelignore', 'HEXIS.md']);
207
- // The guide the platform writes is immovable and undeletable…
208
- expect(platformFile('HEXIS.md')).toBe(true);
209
- // …and the customer's own AGENTS.md is a page like any other.
193
+ test('the guide is no platform file under any name: a root AGENTS.md is the organisation\'s own page', () => {
194
+ // The platform's guide is served from code and never written to the
195
+ // repository, so nothing under its name is the platform's to protect.
196
+ expect(platformFileNames(DEFAULT_KB_LAYOUT)).toEqual(['access.md', 'roles.yaml', '.bevelignore']);
210
197
  expect(platformFile('AGENTS.md')).toBe(false);
211
- // Still root-only, as `roles.yaml` is: a nested copy is content.
212
- expect(platformFile('KnowledgeBase/HEXIS.md')).toBe(false);
213
- });
198
+ expect(platformFile('roles.yaml')).toBe(true);
214
199
 
215
- test('the pointer sentence is one sentence, naming the guide twice, from one place', () => {
216
- expect(agentsFilePointerSentence('HEXIS.md')).toBe(
217
- 'Read [HEXIS.md](./HEXIS.md) before working in this knowledge base — ' +
218
- "it is the platform's guide to its layout, files and rules.",
219
- );
200
+ // A name a deployment saved for the written guide is a read alias now,
201
+ // not a file, and makes no platform file either.
220
202
  configureKbLayout({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
221
- expect(agentsFilePointerSentence(currentKbLayout().agentsFile)).toContain('HEXIS.md');
222
- });
223
-
224
- /**
225
- * A guide name is a FILE NAME: spaces, brackets, parentheses, `#` and `%`
226
- * all pass `validateFilename`, and every one of them means something in an
227
- * inline link. The label must not end early and the destination must still
228
- * point at the file.
229
- */
230
- test('the pointer sentence links correctly for a name full of markdown punctuation', () => {
231
- const name = 'Our [Agent] Guide (v2).md';
232
- expect(validateGuideName(name)).toBeNull();
233
- const sentence = agentsFilePointerSentence(name);
234
- // The label cannot end early: the brackets in it are escaped…
235
- expect(sentence).toContain('[Our \\[Agent\\] Guide (v2).md]');
236
- // …and the destination is percent-encoded, parentheses included — a bare
237
- // `)` would close the link half way through the name.
238
- expect(sentence).toContain('(./Our%20%5BAgent%5D%20Guide%20%28v2%29.md)');
239
- // The ordinary name reads as it always has — no escapes, nothing encoded.
240
- expect(agentsFilePointerSentence('AGENTS.md')).toContain('[AGENTS.md](./AGENTS.md)');
241
- });
242
-
243
- test('the pointer sentence encodes a name that would otherwise open a URL fragment', () => {
244
- // `#` is legal in a filename and opens a fragment in a URL: `./#2 Guide.md`
245
- // links to the customer's OWN file with a fragment, not to the guide.
246
- expect(validateGuideName('#2 Guide.md')).toBeNull();
247
- expect(agentsFilePointerSentence('#2 Guide.md')).toContain('(./%232%20Guide.md)');
248
- // `%` is legal too, and an unencoded one is a malformed escape.
249
- expect(agentsFilePointerSentence('100%.md')).toContain('(./100%25.md)');
250
- });
251
-
252
- /**
253
- * The startup step asks this before appending, and it has to recognise the
254
- * sentence the LAST boot wrote — whose spelling of the name is escaped in
255
- * the label and encoded in the destination. Asking for the raw name alone
256
- * would append a second copy on every boot after the first.
257
- */
258
- test('a file already carrying the pointer sentence counts as mentioning the guide', () => {
259
- for (const name of ['AGENTS.md', 'Our [Agent] Guide (v2).md', '#2 Guide.md', '100%.md']) {
260
- const appended = `# Acme\n\n${agentsFilePointerSentence(name)}\n`;
261
- expect(mentionsAgentsFile(appended, name), name).toBe(true);
262
- }
263
- // A file that says nothing about the guide still reads as silent…
264
- expect(mentionsAgentsFile('# Acme\n\nWrite tickets in the present tense.\n', 'HEXIS.md')).toBe(false);
265
- // …and the customer's own plain mention counts, in their own words.
266
- expect(mentionsAgentsFile('See HEXIS.md for the platform.', 'HEXIS.md')).toBe(true);
267
- });
268
-
269
- /**
270
- * A second rename. The sentence written for the previous guide points at a
271
- * file that is no longer there, and the new name appears nowhere in the
272
- * text — so it has to be recognised by its SHAPE and aimed again.
273
- */
274
- test('a pointer sentence the platform wrote is retargeted, whatever guide it named', () => {
275
- for (const before of ['HEXIS.md', 'Our [Agent] Guide (v2).md', '#2 Guide.md']) {
276
- const text = `# Acme\n\n${agentsFilePointerSentence(before)}\n`;
277
- expect(retargetAgentsFilePointer(text, 'GUIDE.md'), before).toBe(
278
- `# Acme\n\n${agentsFilePointerSentence('GUIDE.md')}\n`,
279
- );
280
- }
281
- // Already aimed right: ours, and unchanged — which is not the same answer
282
- // as "none of ours here", and the caller tells them apart.
283
- const current = `# Acme\n\n${agentsFilePointerSentence('GUIDE.md')}\n`;
284
- expect(retargetAgentsFilePointer(current, 'GUIDE.md')).toBe(current);
203
+ expect(platformFileNames(currentKbLayout())).toEqual(['access.md', 'roles.yaml', '.bevelignore']);
204
+ expect(platformFile('HEXIS.md')).toBe(false);
205
+ expect(platformFile('AGENTS.md')).toBe(false);
285
206
  });
286
207
 
287
- test('leaves a file holding nothing of the platform\'s alone', () => {
288
- // Null, not the text: there is nothing of ours to aim, so the caller goes
289
- // on to ask whether the customer mentioned the guide themselves.
290
- expect(retargetAgentsFilePointer('# Acme\n\nWrite tickets in the present tense.\n', 'GUIDE.md')).toBeNull();
291
- // Their own link to their own file is not our sentence.
292
- expect(retargetAgentsFilePointer('Read [notes](./notes.md) before working here.', 'GUIDE.md')).toBeNull();
293
- // Our opening words, their sentence.
294
- expect(
295
- retargetAgentsFilePointer('Read [HEXIS.md](./HEXIS.md) when you have a moment.', 'GUIDE.md'),
296
- ).toBeNull();
297
- });
298
208
  });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { agentGuideDir, coreMigrationsDir, defaultKbTemplateDir } from '../assets.js';
6
+
7
+ /**
8
+ * Every asset folder this package reads at run time has to reach the places
9
+ * the package is shipped from: the npm tarball (`files` in package.json) and
10
+ * the Docker image (the runtime stage of the repository's Dockerfile, which
11
+ * copies each folder by name).
12
+ *
13
+ * The second list is the one nothing else checks. `agent-guide/` was added to
14
+ * `assets.ts` and to `files`, but not to the Dockerfile, and no test or CI job
15
+ * builds the image: the folder was simply absent from every image built from
16
+ * dev. It is read while the tool list is composed, so the first request for
17
+ * any tool on the deployment failed, and every agent surface with it
18
+ * (2026-10-06). A folder named here and missing from either list fails this
19
+ * test instead.
20
+ */
21
+ const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
22
+ const repoRoot = path.resolve(packageRoot, '..', '..');
23
+
24
+ /** The folders assets.ts locates, as names under the package root. */
25
+ const ASSET_DIRS = [coreMigrationsDir(), defaultKbTemplateDir(), agentGuideDir()].map((dir) =>
26
+ path.basename(dir),
27
+ );
28
+
29
+ describe('the asset folders this package reads at run time', () => {
30
+ it('are the three assets.ts names', () => {
31
+ expect(ASSET_DIRS.sort()).toEqual(['agent-guide', 'kb-template', 'migrations']);
32
+ });
33
+
34
+ it('exist in the source tree', () => {
35
+ for (const dir of ASSET_DIRS) {
36
+ expect(fs.statSync(path.join(packageRoot, dir)).isDirectory(), dir).toBe(true);
37
+ }
38
+ });
39
+
40
+ it('are in the npm tarball', () => {
41
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')) as { files: string[] };
42
+ for (const dir of ASSET_DIRS) {
43
+ expect(pkg.files, `${dir} missing from package.json "files"`).toContain(dir);
44
+ }
45
+ });
46
+
47
+ it('are copied into the Docker image by the runtime stage', () => {
48
+ const dockerfile = fs.readFileSync(path.join(repoRoot, 'Dockerfile'), 'utf8');
49
+ for (const dir of ASSET_DIRS) {
50
+ const line = `COPY --from=builder /app/packages/core-backend/${dir} packages/core-backend/${dir}`;
51
+ expect(dockerfile, `Dockerfile does not copy ${dir} into the image`).toContain(line);
52
+ }
53
+ });
54
+ });
package/src/assets.ts CHANGED
@@ -23,3 +23,13 @@ export function coreMigrationsDir(): string {
23
23
  export function defaultKbTemplateDir(): string {
24
24
  return path.join(packageRoot(), 'kb-template');
25
25
  }
26
+
27
+ /**
28
+ * The sections of the platform's agent guide, one markdown file each — the
29
+ * text `get_agent_guide` and a `read_file` of the guide's name serve, composed
30
+ * by `modules/agent-guide`. Beside `kb-template/` rather than under `src/`,
31
+ * so a build and the published source find it at the same place.
32
+ */
33
+ export function agentGuideDir(): string {
34
+ return path.join(packageRoot(), 'agent-guide');
35
+ }
@@ -9,6 +9,7 @@ import type { IErasureParticipant } from '../modules/auth/account-erasure.servic
9
9
  import type { IAccountAdmission } from '../modules/auth/account-admission.js';
10
10
  import type { IWriteAccess } from '../modules/write-access/write-access.js';
11
11
  import type { OnServerStart } from '../modules/workspace/startup/on-server-start.js';
12
+ import type { AgentGuideHook } from '../modules/agent-guide/agent-guide.js';
12
13
 
13
14
  /**
14
15
  * Every seam the enterprise overlay can fill in the CORE composition
@@ -100,6 +101,16 @@ export interface CorePorts {
100
101
  * contract each step signs up to). Core default: `[]`.
101
102
  */
102
103
  kbStartupSteps?: readonly OnServerStart[];
104
+ /**
105
+ * THIS distribution's say over the agent guide — the text `get_agent_guide`
106
+ * and a `read_file` of the guide's name serve. Called with core's sections
107
+ * and the layout in effect on every composition; returns the sections the
108
+ * guide is made of, so a distribution appends its own, replaces one of
109
+ * core's by id, or drops one, and every change core makes to the rest still
110
+ * reaches it. Core default: core's sections as they are. See
111
+ * `modules/agent-guide`.
112
+ */
113
+ agentGuide?: AgentGuideHook;
103
114
  /**
104
115
  * Mirror this composition's branch model and knowledge-base layout onto the
105
116
  * shared package's PROCESS-WIDE live bindings (`DEFAULT_BRANCH`,
@@ -26,7 +26,9 @@ import {
26
26
  registerToolManualsTools,
27
27
  } from '../modules/tool-manuals/index.js';
28
28
  import { registerWorkflowTools } from '../modules/workflow/agent-tools/workflow.tools.js';
29
+ import { registerChangeRequestReadTools } from '../modules/workflow/agent-tools/change-request-read.tools.js';
29
30
  import { registerWorkspaceTools } from '../modules/workspace/workspace.tools.js';
31
+ import { registerAgentGuideTool } from '../modules/agent-guide/index.js';
30
32
  import { RECOVERY_BOT_EMAIL } from '../modules/workflow/recovery-bot.js';
31
33
  import {
32
34
  registerSkillsTools,
@@ -456,7 +458,13 @@ export async function createCoreServer(
456
458
  // `get_skill`. Warnings only; it never refuses a save.
457
459
  const allowedToolsChecker = new AllowedToolsChecker(core.toolRegistry, core.toolManualService, core.kb);
458
460
  registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th, core.kb);
459
- registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore);
461
+ // The five read tools over change requests. Separate from the workflow tools
462
+ // because they are the only ones that gate their whole payload on the
463
+ // caller's read access, so they take the access service and nothing else.
464
+ registerChangeRequestReadTools(core.toolRegistry, toolsRouter, ta, th, core.accessControl, core.kb);
465
+ registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore, core.agentGuide);
466
+ // The guide on its own, beside the file tools that serve it by name.
467
+ registerAgentGuideTool(core.toolRegistry, toolsRouter, ta, th, core.agentGuideSections);
460
468
  // The agent upload route, on the same router as the tool endpoints so it
461
469
  // mounts ahead of the JWT `/api` mounts below — but WITHOUT `toolAuth`: its
462
470
  // whole credential is the single-use token in its path, which is the point
@@ -467,13 +475,16 @@ export async function createCoreServer(
467
475
  registerSkillsTools(core.toolRegistry, toolsRouter, ta, th, core.skillService, allowedToolsChecker);
468
476
  // Definitions only: the endpoints they describe are the app's own plugin
469
477
  // creation routes, mounted below behind the key-or-session gate.
470
- registerPluginsTools(core.toolRegistry);
478
+ registerPluginsTools(core.toolRegistry, core.kb);
471
479
  registerToolManualsTools(core.toolRegistry, toolsRouter, ta, th, core.toolManualService, {
472
480
  accessControl: core.accessControl,
473
481
  // The vault satisfies the module's local VariableStatusPort — `list_tool_setup`
474
482
  // reports configuration booleans only; secret values never ride through tools.
475
483
  variableStatus: core.secretsVaultService,
476
484
  kb: core.kb,
485
+ // What the proxy's schema check found when each server's tools were last
486
+ // loaded — reported to a caller who may write the tool, nobody else.
487
+ hiddenTools: core.mcpService.hiddenTools,
477
488
  });
478
489
  // Overlay tool registrations (defs + module-hosted endpoints).
479
490
  ext.tools?.({
@@ -138,6 +138,12 @@ import { unmeteredLlmUsage, type ILlmUsageMeter } from '../modules/tool-auth/llm
138
138
  import { McpService } from '../modules/mcp/mcp.service.js';
139
139
  import { AgentAuditService, retentionDaysFrom } from '../modules/audit/agent-audit.service.js';
140
140
  import { readAgentPreamble, type AgentPreambleReader } from '../modules/agent-instructions/index.js';
141
+ import {
142
+ agentGuideSections,
143
+ joinGuideSections,
144
+ type AgentGuideReader,
145
+ type AgentGuideSectionsReader,
146
+ } from '../modules/agent-guide/index.js';
141
147
  import { createMcpAuthMiddleware } from '../modules/mcp/mcp-auth.middleware.js';
142
148
  import { BevelOAuthProvider } from '../modules/mcp/oauth/bevel-oauth-provider.js';
143
149
  import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
@@ -239,6 +245,14 @@ export interface CoreServices {
239
245
  * and `GET /api/agent/instructions`. See modules/agent-instructions.
240
246
  */
241
247
  readAgentPreamble: AgentPreambleReader;
248
+ /**
249
+ * Composes the platform's agent guide for the layout in effect, through
250
+ * the distribution's hook when it passed one: what `get_agent_guide` and a
251
+ * `read_file` of the guide's name answer. See modules/agent-guide.
252
+ */
253
+ agentGuide: AgentGuideReader;
254
+ /** The same guide as its sections, each with its title — what `get_agent_guide` lists and serves one of. */
255
+ agentGuideSections: AgentGuideSectionsReader;
242
256
  mcpServerEditService: McpServerEditService;
243
257
  /** Deleting one tool — the owner's verb (see ToolDeleteService). */
244
258
  toolDeleteService: ToolDeleteService;
@@ -606,10 +620,7 @@ export async function createCoreServices(
606
620
  // backfill would walk a tree still missing those manifests.
607
621
  new PluginDisplayNamesStep(disk, kb),
608
622
  new PersonalSpacesStep(disk, kb),
609
- // A getter, not a value: the step is built here, while the process may
610
- // still hold the defaults, and the save that completes first-run setup
611
- // applies the admin's answer afterwards.
612
- new TemplateFilesStep(disk, kb, extraDirs, () => settings.resolveAgentsFileLink()),
623
+ new TemplateFilesStep(disk, kb, extraDirs),
613
624
  new RolesYamlStep(disk, [config.adminEmail]),
614
625
  ...(ports.kbStartupSteps ?? []),
615
626
  ];
@@ -1168,6 +1179,12 @@ export async function createCoreServices(
1168
1179
  // the proxy below composes in-process per request; the agent-facing route
1169
1180
  // serves the same composition to the local bridge and the frontend card.
1170
1181
  const readPreamble: AgentPreambleReader = () => readAgentPreamble(workspaceService, kb, disk);
1182
+ // The guide every agent is told to read first, composed when asked for —
1183
+ // the layout is read per call, so a name the setup save applies lands
1184
+ // without a restart, and the distribution's hook sees every composition.
1185
+ const agentGuideSectionsReader: AgentGuideSectionsReader = () =>
1186
+ agentGuideSections(kb.layout, ports.agentGuide, { kbDirName });
1187
+ const agentGuide: AgentGuideReader = async () => joinGuideSections(await agentGuideSectionsReader());
1171
1188
  // The Audit log. Records through the proxy below (every call an external
1172
1189
  // agent makes), reads keys through the key service so their shape is
1173
1190
  // defined once, and prunes past the retention setting — read per sweep, so
@@ -1214,6 +1231,11 @@ export async function createCoreServices(
1214
1231
  // so a just-repaired credential is retried on the very next request instead
1215
1232
  // of waiting out the failure memo's TTL, and pooled downstream connections.
1216
1233
  secretsVaultService.onMutation((changedUserId) => mcpService.onSecretsChanged(changedUserId));
1234
+ // The proxy is the one place a connected server's tools are loaded, so it is
1235
+ // the one place their schemas are checked — and the tool catalog is where the
1236
+ // people who manage a server read what it found. Setter injection, like the
1237
+ // OAuth discovery above: the proxy is constructed after the catalog.
1238
+ toolManualService.setHiddenTools(mcpService.hiddenTools);
1217
1239
  // MCP OAuth 2.1 authorization server (our own AS): lets MCP clients with no
1218
1240
  // pre-shared connection key connect via the standard 401 → discovery → DCR →
1219
1241
  // authorize (PKCE) flow. The authorize step routes the browser to /connect
@@ -1440,6 +1462,8 @@ export async function createCoreServices(
1440
1462
  toolManualService,
1441
1463
  pendingToolsService,
1442
1464
  readAgentPreamble: readPreamble,
1465
+ agentGuide,
1466
+ agentGuideSections: agentGuideSectionsReader,
1443
1467
  pluginIndexService,
1444
1468
  pluginProvisionService,
1445
1469
  joinRequestsService,
package/src/index.ts CHANGED
@@ -47,8 +47,8 @@ export {
47
47
  type LeasedWorker,
48
48
  } from './core/lifecycle.js';
49
49
 
50
- // Packaged assets (migrations/, kb-template/) + the migration runners.
51
- export { coreMigrationsDir, defaultKbTemplateDir } from './assets.js';
50
+ // Packaged assets (migrations/, kb-template/, agent-guide/) + the migration runners.
51
+ export { agentGuideDir, coreMigrationsDir, defaultKbTemplateDir } from './assets.js';
52
52
  export {
53
53
  runCoreMigrations,
54
54
  runEnterpriseMigrations,
@@ -25,6 +25,8 @@ describe('AccessControlService — at-ref batch reads (git cat-file --batch)', (
25
25
  let svc: AccessControlService;
26
26
  const workspaceId = 'ws-atref-1';
27
27
  const admin = 'razvan@bevel.software';
28
+ /** In no role and named nowhere — read is default-deny for them. */
29
+ const stranger = 'mia@bevel.software';
28
30
 
29
31
  async function git(...args: string[]): Promise<void> {
30
32
  await execFileAsync('git', ['-C', repo, ...args]);
@@ -44,6 +46,10 @@ describe('AccessControlService — at-ref batch reads (git cat-file --batch)', (
44
46
  '---\nwrite:\n - deny razvan <razvan@bevel.software>\n---\n# denied\n',
45
47
  );
46
48
  await fs.writeFile(path.join(repo, 'Knowledge/with space.md'), '# spaced\n');
49
+ // For the read batch: a folder the whole organisation may open.
50
+ await fs.mkdir(path.join(repo, 'Knowledge/shared'), { recursive: true });
51
+ await fs.writeFile(path.join(repo, 'Knowledge/shared/access.md'), '---\nread:\n - everyone\n---\n');
52
+ await fs.writeFile(path.join(repo, 'Knowledge/shared/note.md'), '# shared\n');
47
53
 
48
54
  await execFileAsync('git', ['init', '-b', 'main', repo]);
49
55
  await git('config', 'user.email', 'test@example.com');
@@ -96,4 +102,56 @@ describe('AccessControlService — at-ref batch reads (git cat-file --batch)', (
96
102
  expect(map!.get('Knowledge/denied.md')!.emails.has(admin)).toBe(false);
97
103
  expect(map!.get('Knowledge/plain.md')!.emails.has(admin)).toBe(true);
98
104
  });
105
+
106
+ /**
107
+ * The read side of the same batch, used by the change-request read tools to
108
+ * decide which of a request's files their caller may see. The verdicts must
109
+ * be identical to the single-path `canReadAtRef` the app's own content routes
110
+ * gate on — a batch that answered even one path differently would show an
111
+ * agent a file the app refuses to open.
112
+ */
113
+ it('canReadBatchAtRef answers default-deny, an `everyone` grant, and write-implies-read in one pass', async () => {
114
+ const paths = [
115
+ 'Knowledge/plain.md',
116
+ 'Knowledge/shared/note.md',
117
+ 'Knowledge/with space.md',
118
+ 'Knowledge/not-there.md',
119
+ ];
120
+ const forStranger = await svc.canReadBatchAtRef(workspaceId, 'main', stranger, paths);
121
+ expect(forStranger).not.toBeNull();
122
+ // Nothing names them, so read is refused — except where the folder says everyone.
123
+ expect(forStranger!.get('Knowledge/plain.md')).toBe(false);
124
+ expect(forStranger!.get('Knowledge/shared/note.md')).toBe(true);
125
+ expect(forStranger!.get('Knowledge/with space.md')).toBe(false);
126
+ expect(forStranger!.get('Knowledge/not-there.md')).toBe(false);
127
+
128
+ // The Admin write grant folds into read.
129
+ const forAdmin = await svc.canReadBatchAtRef(workspaceId, 'main', admin, paths);
130
+ expect([...forAdmin!.values()]).toEqual([true, true, true, true]);
131
+ });
132
+
133
+ it('canReadBatchAtRef matches the single-path read verdict on every path', async () => {
134
+ const paths = [
135
+ 'Knowledge/plain.md',
136
+ 'Knowledge/shared/note.md',
137
+ 'Knowledge/with space.md',
138
+ 'Knowledge/not-there.md',
139
+ ];
140
+ for (const who of [admin, stranger]) {
141
+ const batch = await svc.canReadBatchAtRef(workspaceId, 'main', who, paths);
142
+ for (const path of paths) {
143
+ const single = await svc.canReadAtRef(workspaceId, 'main', who, path);
144
+ expect(batch!.get(path), `${who} on ${path}`).toBe(single);
145
+ }
146
+ }
147
+ });
148
+
149
+ it('canReadBatchAtRef answers an empty map for no paths, and null for a ref it cannot resolve', async () => {
150
+ expect(await svc.canReadBatchAtRef(workspaceId, 'main', admin, [])).toEqual(new Map());
151
+ expect(await svc.canReadBatchAtRef(workspaceId, 'no-such-ref', admin, ['Knowledge/plain.md'])).toBeNull();
152
+ // The null-semantics hold for the empty set too: the ref is validated
153
+ // whatever was asked about, so no caller can read an empty map as proof
154
+ // that the ref resolved.
155
+ expect(await svc.canReadBatchAtRef(workspaceId, 'no-such-ref', admin, [])).toBeNull();
156
+ });
99
157
  });
@@ -67,9 +67,10 @@ describe('restoring a platform file', () => {
67
67
  // only because it is the file that says who the admin is — the repository
68
68
  // that lost that one too is the last case in this file.
69
69
  const svc = service();
70
- for (const name of ['.bevelignore', 'AGENTS.md']) {
71
- expect(await svc.canRestorePlatformFile(WS, ADMIN, name)).toBe(true);
72
- }
70
+ expect(await svc.canRestorePlatformFile(WS, ADMIN, '.bevelignore')).toBe(true);
71
+ // The agent guide is not a platform file: it is served from code, never
72
+ // written to the root, so a root `AGENTS.md` is content and no restore.
73
+ expect(await svc.canRestorePlatformFile(WS, ADMIN, 'AGENTS.md')).toBe(false);
73
74
  // Nothing is missing at `roles.yaml`: the root has one. A move is a rename
74
75
  // on disk, so landing another there would REPLACE the admin model rather
75
76
  // than put it back, and no exception carries that.
@@ -95,9 +96,9 @@ describe('restoring a platform file', () => {
95
96
  expect(await svc.canRestorePlatformFile(WS, ENGINEER, 'Sales/Legal/access.md')).toBe(false);
96
97
  });
97
98
 
98
- it('the three root-only names are a restore at the root and nowhere else', async () => {
99
+ it('the root-only names are a restore at the root and nowhere else', async () => {
99
100
  const svc = service();
100
- for (const dest of ['Sales/roles.yaml', 'Sales/.bevelignore', 'Sales/AGENTS.md']) {
101
+ for (const dest of ['Sales/roles.yaml', 'Sales/.bevelignore']) {
101
102
  expect(await svc.canRestorePlatformFile(WS, ADMIN, dest)).toBe(false);
102
103
  }
103
104
  });
@@ -113,14 +114,14 @@ describe('restoring a platform file', () => {
113
114
 
114
115
  it('a non-admin never gets the exception, wherever it would land', async () => {
115
116
  const svc = service();
116
- for (const dest of ['roles.yaml', '.bevelignore', 'AGENTS.md', 'access.md', 'Legal/access.md']) {
117
+ for (const dest of ['roles.yaml', '.bevelignore', 'access.md', 'Legal/access.md']) {
117
118
  expect(await svc.canRestorePlatformFile(WS, ENGINEER, dest)).toBe(false);
118
119
  }
119
120
  });
120
121
 
121
122
  it('the deployment owner is an admin for it, and an ordinary destination is not a restore for anyone', async () => {
122
123
  const svc = service();
123
- expect(await svc.canRestorePlatformFile(WS, OWNER, 'AGENTS.md')).toBe(true);
124
+ expect(await svc.canRestorePlatformFile(WS, OWNER, '.bevelignore')).toBe(true);
124
125
  for (const email of [ADMIN, OWNER]) {
125
126
  expect(await svc.canRestorePlatformFile(WS, email, 'Sales/deal.md')).toBe(false);
126
127
  expect(await svc.canRestorePlatformFile(WS, email, 'Sales/notes.md')).toBe(false);
@@ -7,7 +7,6 @@ import os from 'node:os';
7
7
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
8
  import { AccessControlService } from '../access-control.service.js';
9
9
  import { personalAccessMd, pluginAccessMd } from '../../plugins/plugin-provision.service.js';
10
- import { defaultKbTemplateDir } from '../../../assets.js';
11
10
  import { closePersonalSpaceRules } from '../../workspace/startup/steps/personal-spaces.step.js';
12
11
 
13
12
  /**
@@ -17,10 +16,7 @@ import { closePersonalSpaceRules } from '../../workspace/startup/steps/personal-
17
16
  * - a personal space must stay private even after an administrator opens
18
17
  * the repository root with `read: everyone` (the usual way to let a new
19
18
  * joiner read anything), while its owner keeps reading it — and Admin,
20
- * who reads everything else, does not;
21
- * - the managed AGENTS.md must be readable by every signed-in person even
22
- * when the root grants read to nobody, because agents are told to read
23
- * it before their first action.
19
+ * who reads everything else, does not.
24
20
  */
25
21
 
26
22
  const KB_DIR = 'knowledge-base';
@@ -137,17 +133,4 @@ describe('the seeded access templates, resolved', () => {
137
133
  expect(await svc.canRead(workspaceId, 'admin@x.io', skill)).toBe(false);
138
134
  });
139
135
 
140
- it('the packaged AGENTS.md is readable by a non-admin even when the root grants read to nobody', async () => {
141
- const template = await fs.readFile(path.join(defaultKbTemplateDir(), 'AGENTS.md'), 'utf8');
142
- const svc = await makeService({
143
- 'roles.yaml': ROLES_YAML,
144
- 'access.md': '---\nowner:\n - Admin\n---\nwrite:\n - Admin\n',
145
- 'AGENTS.md': template,
146
- 'KnowledgeBase/Notes.md': '# Notes\n',
147
- });
148
- const bob = await svc.canReadBatch(workspaceId, 'bob@x.io', ['AGENTS.md', 'KnowledgeBase/Notes.md']);
149
- expect(bob.get('AGENTS.md')).toBe(true);
150
- // The grant is the FILE's own, not a widening of the root.
151
- expect(bob.get('KnowledgeBase/Notes.md')).toBe(false);
152
- });
153
136
  });
@@ -631,6 +631,21 @@ export interface IAccessControl {
631
631
  relativePaths: string[],
632
632
  ): Promise<Map<string, boolean> | null>;
633
633
 
634
+ /**
635
+ * Batched variant of `canReadAtRef`, with the same null-semantics (the whole
636
+ * call returns null when the ref or its `roles.yaml` can't be resolved, and
637
+ * the caller must read that as deny). One access-tree load and one
638
+ * `git cat-file --batch` for the path set, the way `canWriteBatchAtRef`
639
+ * does it — a change request with forty files would otherwise cost forty
640
+ * single-path lookups to decide which of them its reader may see.
641
+ */
642
+ canReadBatchAtRef(
643
+ workspaceId: string,
644
+ ref: string,
645
+ userEmail: string,
646
+ relativePaths: string[],
647
+ ): Promise<Map<string, boolean> | null>;
648
+
634
649
  /**
635
650
  * The set of principals with `write` on this path as of `ref`. Used by PR
636
651
  * reviewer routing and the per-file approval UI rendered against the PR
@@ -2103,6 +2103,27 @@ export class AccessControlService implements IAccessControl {
2103
2103
  return result;
2104
2104
  }
2105
2105
 
2106
+ async canReadBatchAtRef(
2107
+ workspaceId: string,
2108
+ ref: string,
2109
+ userEmail: string,
2110
+ relativePaths: string[],
2111
+ ): Promise<Map<string, boolean> | null> {
2112
+ // No shortcut for an empty path set: the null-semantics are the contract,
2113
+ // so an unresolvable ref must answer null however many paths were asked
2114
+ // about. Returning an empty map early would tell a caller the ref resolved.
2115
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
2116
+ if (!loaded) return null;
2117
+ const repoDir = await this.repoDir(workspaceId);
2118
+ // One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
2119
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2120
+ const result = new Map<string, boolean>();
2121
+ for (const p of relativePaths) {
2122
+ result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
2123
+ }
2124
+ return result;
2125
+ }
2126
+
2106
2127
  async eligibleWritersAtRef(
2107
2128
  workspaceId: string,
2108
2129
  ref: string,