@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,196 @@
1
+ /**
2
+ * The schema check for connected tools, and the memory of what it found.
3
+ *
4
+ * An AI client that is handed a tool whose input schema is not valid JSON
5
+ * Schema drops that tool and says nothing: it is simply missing for the agent,
6
+ * with nothing in Hexis to look at. So Hexis runs the same check itself, when a
7
+ * server's tools are LOADED, and:
8
+ *
9
+ * - keeps such a tool off every agent surface, so the agent's picture of what
10
+ * it can do matches what it can actually call;
11
+ * - remembers the finding for the people who manage that server, who are the
12
+ * only ones who can get it fixed (the tool page, `list_tool_setup`).
13
+ *
14
+ * WHEN it runs is the point. The verdict is remembered per DISTINCT SCHEMA, so
15
+ * a server whose tools are unchanged costs one hash and one map lookup per
16
+ * tool: the JSON Schema check happens when a server's tools are first loaded
17
+ * and when a refresh brings a schema this process has not seen, and never on a
18
+ * tool call — which re-derives the surface from those same unchanged schemas.
19
+ *
20
+ * It never REPAIRS anything. The proxy passes a connected server's schema
21
+ * through as sent; an invalid one is reported, and the server's owner decides.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto';
25
+ import { inputSchemaDefect, schemaDefectMarker, type SchemaDefect } from '@bevel-software/platform-mcp-core';
26
+ import type { HiddenTool, HiddenToolSource } from '../../shared/hidden-tools.js';
27
+
28
+ /** What {@link ToolSchemaGuard.screen} needs of a freshly loaded tool. */
29
+ export interface ScreenedTool {
30
+ /** The UTCP name (`<manual>.<server>.<tool>`) — how the tool repository knows it. */
31
+ utcpName: string;
32
+ /** The flattened name an agent addresses it by. */
33
+ mcpName: string;
34
+ /** The input schema exactly as the connected server sent it. */
35
+ inputSchema: unknown;
36
+ }
37
+
38
+ /**
39
+ * A finding as the LOADING side needs it. The owner-facing `HiddenTool` is
40
+ * about a tool by the name an agent would have called it; the proxy also has to
41
+ * take the tool out of its repository and record it in the audit trail, and
42
+ * both of those know a tool by its UTCP name. Kept off `HiddenTool` so the
43
+ * owner-facing payload stays exactly what its surfaces declare.
44
+ */
45
+ export interface ScreenedHiddenTool extends HiddenTool {
46
+ /** The UTCP name (`<manual>.<server>.<tool>`) the tool repository knows it by. */
47
+ utcpName: string;
48
+ }
49
+
50
+ /**
51
+ * How many schema verdicts to remember. Each is a hash and a verdict, so the
52
+ * cap is about a deployment that edits servers all day rather than about size;
53
+ * past it the whole table is cleared instead of evicted entry by entry, since
54
+ * the next load simply re-checks.
55
+ */
56
+ const MAX_REMEMBERED_SCHEMAS = 5000;
57
+
58
+ /** How many callers' pictures to hold at once — see `screen` for what happens past it. Exported for the tests that fill it. */
59
+ export const MAX_REMEMBERED_CALLERS = 2000;
60
+
61
+ export class ToolSchemaGuard implements HiddenToolSource {
62
+ /** Verdict per distinct schema: `null` means valid, so `undefined` means unchecked. */
63
+ private readonly verdicts = new Map<string, SchemaDefect | null>();
64
+ /**
65
+ * The findings, per CALLER and then per manual by catalog name.
66
+ *
67
+ * Keyed by caller because that is what a load is: discovery runs on the
68
+ * requesting user's own connection to the server, and two callers can be
69
+ * shown different tools by the same server. A single table keyed by manual
70
+ * would let one caller's load erase another's finding — the owner page would
71
+ * then show a defect from whichever request happened to be last, or none at
72
+ * all. Keyed by caller, a load replaces only what that caller can see, and
73
+ * `hiddenFor` reports the union, since the finding is about the server.
74
+ */
75
+ private readonly hidden = new Map<string, Map<string, ScreenedHiddenTool[]>>();
76
+ /** The newest load whose picture has been applied, per caller — see {@link beginLoad}. */
77
+ private readonly applied = new Map<string, number>();
78
+ /** Hands out {@link beginLoad} tickets; monotonic for the life of the process. */
79
+ private loads = 0;
80
+
81
+ /** `check` is injected only by tests, to observe how often the check runs. */
82
+ constructor(private readonly check: (schema: unknown) => SchemaDefect | null = inputSchemaDefect) {}
83
+
84
+ /**
85
+ * A ticket for a load that is ABOUT TO READ a server's tools, to be handed
86
+ * back to {@link screen} with what it found.
87
+ *
88
+ * Requests overlap: a caller's surface is rebuilt on every one of them, and
89
+ * two can be dialling the same server at once. Without an order, a load that
90
+ * started while a schema was still broken could land after the load that saw
91
+ * it corrected, and put the marker back on a tool that is fine — until some
92
+ * later request happened to clear it again. The ticket is taken BEFORE the
93
+ * read, so it ranks loads by the freshness of what they saw, and `screen`
94
+ * ignores a picture older than the one already applied.
95
+ */
96
+ beginLoad(): number {
97
+ this.loads += 1;
98
+ return this.loads;
99
+ }
100
+
101
+ /**
102
+ * Screen everything one caller's request just loaded — every manual on their
103
+ * surface, with the tools that manual advertised — and replace that caller's
104
+ * whole picture. Returns the tools to keep off the agent surfaces, keyed by
105
+ * UTCP name.
106
+ *
107
+ * WHOLE is the point, and why this takes every manual rather than one. A
108
+ * manual whose group is EMPTY has nothing hidden: its server dropped the
109
+ * offending tool, or it failed to attach at all and its tools are not loaded.
110
+ * A manual absent from `groups` is no longer on this caller's surface. Either
111
+ * way the marker goes, with nothing to clear by hand — and nothing claims a
112
+ * tool is hidden for a schema this process can no longer see.
113
+ *
114
+ * `loadId` comes from {@link beginLoad}, taken before the tools were read. A
115
+ * load that is already out of date still gets its own answer — the request
116
+ * that ran it must not offer a tool it has just judged invalid — but it does
117
+ * not write that answer into what everyone else reads.
118
+ */
119
+ screen(
120
+ userId: string,
121
+ loadId: number,
122
+ groups: ReadonlyMap<string, readonly ScreenedTool[]>,
123
+ ): Map<string, ScreenedHiddenTool> {
124
+ const found = new Map<string, ScreenedHiddenTool>();
125
+ const picture = new Map<string, ScreenedHiddenTool[]>();
126
+ for (const [manual, tools] of groups) {
127
+ const ofManual: ScreenedHiddenTool[] = [];
128
+ for (const tool of tools) {
129
+ const defect = this.verdict(tool.inputSchema);
130
+ if (!defect) continue;
131
+ const hidden: ScreenedHiddenTool = {
132
+ manual,
133
+ name: tool.mcpName,
134
+ utcpName: tool.utcpName,
135
+ path: defect.path,
136
+ reason: defect.reason,
137
+ marker: schemaDefectMarker(defect),
138
+ };
139
+ found.set(tool.utcpName, hidden);
140
+ ofManual.push(hidden);
141
+ }
142
+ if (ofManual.length > 0) picture.set(manual, ofManual);
143
+ }
144
+ // Stale: a load that read the server earlier than one already applied for
145
+ // THIS caller. Its own answer stands, the shared picture does not move.
146
+ //
147
+ // Per caller, and nothing wider. An evicted caller's watermark goes with
148
+ // their picture, and that loses nothing: once nothing of theirs is held,
149
+ // whichever of their loads lands next is the newest picture this process
150
+ // has of them, and it restores the watermark that rejects any older one
151
+ // landing after it. A floor shared across callers would instead reject
152
+ // some OTHER caller's newest load for having begun before an unrelated
153
+ // eviction, and the owner's page would miss that caller's finding.
154
+ if (loadId < (this.applied.get(userId) ?? 0)) return found;
155
+ // Not about size — each entry is a handful of strings — but about a
156
+ // deployment with many callers never growing these without bound. Evicted
157
+ // one caller at a time, the longest unseen first, so every other caller's
158
+ // findings stay on the owner's page; the next load of the evicted surface
159
+ // puts its own back. (Re-inserted on every load, so a Map's insertion
160
+ // order is the order of last sight.)
161
+ while (this.applied.size >= MAX_REMEMBERED_CALLERS && !this.applied.has(userId)) {
162
+ const oldest = this.applied.keys().next().value as string;
163
+ this.applied.delete(oldest);
164
+ this.hidden.delete(oldest);
165
+ }
166
+ this.applied.delete(userId);
167
+ this.applied.set(userId, loadId);
168
+ if (picture.size > 0) this.hidden.set(userId, picture);
169
+ else this.hidden.delete(userId);
170
+ return found;
171
+ }
172
+
173
+ hiddenFor(manual: string): HiddenTool[] {
174
+ const union = new Map<string, HiddenTool>();
175
+ for (const picture of this.hidden.values()) {
176
+ for (const { manual: of, name, path, reason, marker } of picture.get(manual) ?? []) {
177
+ // One entry per distinct defect: two callers shown the same broken tool
178
+ // have found one thing, and its owner should read it once. Projected to
179
+ // the owner-facing shape, so nothing of the loading side rides along.
180
+ union.set(`${name}\u0000${path}\u0000${reason}`, { manual: of, name, path, reason, marker });
181
+ }
182
+ }
183
+ return [...union.values()];
184
+ }
185
+
186
+ /** The check itself, once per distinct schema. */
187
+ private verdict(schema: unknown): SchemaDefect | null {
188
+ const key = createHash('sha1').update(JSON.stringify(schema) ?? 'undefined').digest('hex');
189
+ const remembered = this.verdicts.get(key);
190
+ if (remembered !== undefined) return remembered;
191
+ const defect = this.check(schema);
192
+ if (this.verdicts.size >= MAX_REMEMBERED_SCHEMAS) this.verdicts.clear();
193
+ this.verdicts.set(key, defect);
194
+ return defect;
195
+ }
196
+ }
@@ -6,7 +6,11 @@ import { InternalTokenService } from '../../tool-auth/internal-token.service.js'
6
6
  import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
7
7
  import { keyOrSessionAuth } from '../../tool-auth/key-or-session.middleware.js';
8
8
  import { createAuthMiddleware } from '../../auth/auth.middleware.js';
9
- import { registerPluginsTools, CREATE_PLUGIN, MY_PLUGIN } from '../plugins.tools.js';
9
+ import { DEFAULT_KB_LAYOUT, validateKbLayout } from '@bevel-software/platform-shared';
10
+ import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { CLIENT_SHORT_CUT, TOOL_DESCRIPTION_CAP, clientVisibleLength } from '../../tool-registry/description-length.js';
12
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
13
+ import { registerPluginsTools, CREATE_PLUGIN, myPluginDef, myPluginDescription } from '../plugins.tools.js';
10
14
  import { createPluginCreationRoutes } from '../plugins.routes.js';
11
15
  import { PluginProvisionError } from '../plugin-provision.service.js';
12
16
 
@@ -20,18 +24,164 @@ import { PluginProvisionError } from '../plugin-provision.service.js';
20
24
 
21
25
  const ALICE = { id: 'user-alice', email: 'alice@x.com', name: 'Alice' };
22
26
 
27
+ /** `my_plugin` as the catalog lists it for `layout`, on the external surface. */
28
+ async function listedMyPlugin(layout = DEFAULT_KB_LAYOUT): Promise<{ description: string }> {
29
+ const registry = new ToolRegistry();
30
+ registerPluginsTools(registry, testKbContext({ layout }));
31
+ const listed = (await registry.listExternal({ userEmail: ALICE.email })).find((t) => t.name === 'my_plugin');
32
+ expect(listed, 'my_plugin is not in the external catalog').toBeDefined();
33
+ return listed as { description: string };
34
+ }
35
+
36
+ /** The rule, in the words a client must be handed before any cut. */
37
+ const RULE = [
38
+ 'personal plugin',
39
+ 'their own skills and tools',
40
+ 'they go under the knowledge root',
41
+ 'ask where under the knowledge root',
42
+ 'restricted so only they can read it',
43
+ 'never write it here, even if asked',
44
+ ];
45
+
23
46
  describe('the tool definitions', () => {
24
47
  it('describe the creation endpoints, and reach every surface', async () => {
25
48
  expect((CREATE_PLUGIN.tool_call_template as { url: string }).url).toBe('${API_URL}/api/plugins');
26
- expect((MY_PLUGIN.tool_call_template as { url: string }).url).toBe('${API_URL}/api/plugins/personal');
49
+ expect((myPluginDef(DEFAULT_KB_LAYOUT).tool_call_template as { url: string }).url).toBe(
50
+ '${API_URL}/api/plugins/personal',
51
+ );
27
52
  const registry = new ToolRegistry();
28
- registerPluginsTools(registry);
53
+ registerPluginsTools(registry, testKbContext());
29
54
  for (const tools of [await registry.listExternal({ userEmail: ALICE.email }), await registry.listInternal({ userEmail: ALICE.email })]) {
30
55
  expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(['my_plugin', 'create_plugin']));
31
56
  }
32
57
  });
33
58
  });
34
59
 
60
+ /**
61
+ * What `my_plugin` TELLS an agent. A personal plugin holds its owner's skills
62
+ * and tools; nothing under the plugins root is in the knowledge graph and a
63
+ * personal plugin is readable only by its owner, so a note filed there is
64
+ * never found as knowledge again. The old description opened by calling the
65
+ * folder "their personal space in the knowledge base", which read as an
66
+ * invitation to file one. Pinned here because the text IS the change: nothing
67
+ * refuses such a write, before or after (see the last test in this block).
68
+ */
69
+ describe('what my_plugin tells an agent about the personal plugin', () => {
70
+ it('states the rule, naming this deployment\'s knowledge root', async () => {
71
+ const { description } = await listedMyPlugin();
72
+ expect(description).toContain("The caller's personal plugin: their own skills and tools");
73
+ expect(description).toContain('Notes, knowledge and other documents do NOT go here; they go under the knowledge root.');
74
+ expect(description).toContain('The knowledge root here is `KnowledgeBase/`.');
75
+ // A private request gets a QUESTION and the restrictable folder, never the
76
+ // personal plugin — not even when the user asks for it outright.
77
+ expect(description).toContain('ask where under the knowledge root it should go');
78
+ expect(description).toContain('restricted so only they can read it');
79
+ expect(description).toContain('never write it here, even if asked');
80
+ // The name the app shows, and none of the three phrases that sent agents here.
81
+ for (const retired of ['personal space', 'private space', 'own space']) {
82
+ expect(description, retired).not.toContain(retired);
83
+ }
84
+ });
85
+
86
+ it('names a RENAMED knowledge root, and never the default one', async () => {
87
+ const renamed = { knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' };
88
+ const { description } = await listedMyPlugin(renamed);
89
+ expect(description).toContain('The knowledge root here is `Docs/`.');
90
+ expect(description).not.toContain('KnowledgeBase');
91
+ });
92
+
93
+ /**
94
+ * The save that completes first-run setup applies the names the admin just
95
+ * chose IN THAT REQUEST, with no restart, so a description built once at
96
+ * registration would go on naming a folder the deployment no longer has.
97
+ * Rewritten in place, which is why both surfaces see it: they hold the same
98
+ * object.
99
+ */
100
+ it('follows a layout applied after registration, on both surfaces', async () => {
101
+ const registry = new ToolRegistry();
102
+ const kb = testKbContext();
103
+ registerPluginsTools(registry, kb);
104
+ kb.applyLayout({ knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' });
105
+ for (const tools of [await registry.listExternal(), await registry.listInternal()]) {
106
+ const description = tools.find((t) => t.name === 'my_plugin')!.description!;
107
+ expect(description).toContain('The knowledge root here is `Docs/`.');
108
+ expect(description).not.toContain('KnowledgeBase');
109
+ }
110
+ });
111
+
112
+ /**
113
+ * claude.ai cuts a tool description near `CLIENT_SHORT_CUT` characters, and
114
+ * it counts from the guide-first sentence the registry puts in front of every
115
+ * listed tool. So the rule has to be inside that window of the text A CLIENT
116
+ * IS HANDED, with the `skillsDir` mechanics — which the guide states in full
117
+ * anyway — as what a short client loses instead.
118
+ */
119
+ it(`states the whole rule within the first ${CLIENT_SHORT_CUT} characters a client is handed`, async () => {
120
+ for (const layout of [DEFAULT_KB_LAYOUT, { knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' }]) {
121
+ const { description } = await listedMyPlugin(layout);
122
+ expect(description.startsWith(`${GUIDE_FIRST_SENTENCE} `), 'the opener is counted too').toBe(true);
123
+ const cut = description.slice(0, CLIENT_SHORT_CUT);
124
+ const root = layout.knowledgeBaseDir;
125
+ for (const phrase of [...RULE, `The knowledge root here is \`${root}/\`.`]) {
126
+ expect(cut, `"${phrase}" falls past the ${CLIENT_SHORT_CUT}-character cut`).toContain(phrase);
127
+ }
128
+ }
129
+ });
130
+
131
+ /**
132
+ * A folder name may run to 255 bytes. The root is named once, after the rule,
133
+ * so even the longest name a deployment can choose leaves the whole rule
134
+ * inside the cut and the whole text inside the cap.
135
+ */
136
+ it('keeps the rule inside the cut, and the text inside the cap, for the longest root name', async () => {
137
+ const longest = { knowledgeBaseDir: 'K'.repeat(255), skillsDir: 'Skills', pluginsDir: 'Plugins' };
138
+ expect(validateKbLayout(longest), 'the name is one a deployment may choose').toBeNull();
139
+ const listed = await listedMyPlugin(longest);
140
+ const cut = listed.description.slice(0, CLIENT_SHORT_CUT);
141
+ for (const phrase of RULE) {
142
+ expect(cut, `"${phrase}" falls past the ${CLIENT_SHORT_CUT}-character cut`).toContain(phrase);
143
+ }
144
+ expect(listed.description).toContain(`The knowledge root here is \`${longest.knowledgeBaseDir}/\`.`);
145
+ expect(clientVisibleLength({ name: 'my_plugin', description: listed.description })).toBeLessThanOrEqual(
146
+ TOOL_DESCRIPTION_CAP,
147
+ );
148
+ });
149
+
150
+ /** Only the description changed: the call an agent makes is the call it made. */
151
+ it('keeps its endpoint, inputs, outputs and tags exactly as they were', () => {
152
+ const def = myPluginDef(DEFAULT_KB_LAYOUT);
153
+ expect((def.tool_call_template as { url: string; http_method: string }).url).toBe('${API_URL}/api/plugins/personal');
154
+ expect((def.tool_call_template as { http_method: string }).http_method).toBe('POST');
155
+ // The flat inputs are wrapped under `body` by `toolDef`; `my_plugin` takes none.
156
+ expect(def.inputs).toEqual({
157
+ type: 'object',
158
+ properties: { body: { type: 'object', properties: {}, additionalProperties: false } },
159
+ required: ['body'],
160
+ additionalProperties: false,
161
+ });
162
+ expect(Object.keys((def.outputs as { properties: Record<string, unknown> }).properties)).toEqual([
163
+ 'path',
164
+ 'skillsDir',
165
+ 'folder',
166
+ 'name',
167
+ 'displayName',
168
+ 'created',
169
+ ]);
170
+ expect(def.tags).toEqual(['plugins', 'skills', 'write']);
171
+ // Byte-identical on every layout but the description.
172
+ const renamed = myPluginDef({ knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' });
173
+ expect({ ...renamed, description: '' }).toEqual({ ...def, description: '' });
174
+ expect(renamed.description).not.toBe(def.description);
175
+ });
176
+
177
+ /** A layout given with untrimmed names is read the way every other reader reads it. */
178
+ it('reads the layout through the same resolver the rest of the platform uses', () => {
179
+ expect(myPluginDescription({ knowledgeBaseDir: ' Docs ', skillsDir: 'Skills', pluginsDir: 'Plugins' })).toContain(
180
+ '`Docs/`',
181
+ );
182
+ });
183
+ });
184
+
35
185
  describe('the creation endpoints behind the key-or-session gate', () => {
36
186
  const ensurePersonalPlugin = vi.fn(async (user: { id: string }) => ({
37
187
  folder: `personal-${user.id}`,
@@ -112,7 +262,7 @@ describe('the creation endpoints behind the key-or-session gate', () => {
112
262
  expect(createPlugin).toHaveBeenLastCalledWith(expect.objectContaining({ id: ALICE.id }), 'Ops', undefined);
113
263
  });
114
264
 
115
- it("my_plugin's endpoint ensures the caller's own space for a key holder", async () => {
265
+ it("my_plugin's endpoint ensures the caller's own personal plugin for a key holder", async () => {
116
266
  const base = await start();
117
267
  const res = await post(base, '/api/plugins/personal', {}, 'bevel_alice');
118
268
  expect(res.status).toBe(200);
@@ -1,3 +1,5 @@
1
+ import { resolveKbLayout, type KbLayout } from '@bevel-software/platform-shared';
2
+ import type { KbContext } from '../../shared/kb-context.js';
1
3
  import type { IToolRegistry, UtcpTool } from '../tool-registry/tool.contract.js';
2
4
  import { toolDef } from '../tool-helpers/tool-def.js';
3
5
 
@@ -21,9 +23,24 @@ import { toolDef } from '../tool-helpers/tool-def.js';
21
23
  * agent exactly as for a person; a refusal comes back as the endpoint's own
22
24
  * 4xx with its message.
23
25
  */
24
- export function registerPluginsTools(registry: IToolRegistry): void {
25
- registry.registerExternalTool(MY_PLUGIN);
26
- registry.registerInternalTool(MY_PLUGIN);
26
+ export function registerPluginsTools(registry: IToolRegistry, kb: KbContext): void {
27
+ // ONE object on both surfaces, so the rewrite below reaches both of them.
28
+ // The registry holds this object, and re-registering under the same name
29
+ // would throw as a duplicate.
30
+ const myPlugin = myPluginDef(kb.layout);
31
+ registry.registerExternalTool(myPlugin);
32
+ registry.registerInternalTool(myPlugin);
33
+ /**
34
+ * `my_plugin`'s description NAMES the knowledge root, so its text has to
35
+ * follow the layout: the save that completes first-run setup applies the
36
+ * names the admin just chose in that same request, without a restart, and a
37
+ * description built once at registration would go on sending notes to a
38
+ * folder this deployment no longer has. Rewritten in place, exactly as the
39
+ * workspace tools' descriptions are (see `workspace.tools.ts`).
40
+ */
41
+ kb.onLayoutApplied(() => {
42
+ myPlugin.description = myPluginDescription(kb.layout);
43
+ });
27
44
  registry.registerExternalTool(CREATE_PLUGIN);
28
45
  registry.registerInternalTool(CREATE_PLUGIN);
29
46
  }
@@ -41,21 +58,64 @@ const PROVISIONED_OUTPUT = {
41
58
  },
42
59
  } as const;
43
60
 
44
- export const MY_PLUGIN: UtcpTool = toolDef({
45
- name: 'my_plugin',
46
- description:
47
- "The caller's own private plugin — their personal space in the knowledge base, created on first use. " +
61
+ /**
62
+ * What an agent reads about the personal plugin, for the layout in effect.
63
+ *
64
+ * THE RULE COMES FIRST, and the order is the substance of it rather than a
65
+ * matter of style. A personal plugin holds its owner's skills and tools;
66
+ * nothing under the plugins root is part of the knowledge graph, and a
67
+ * personal plugin is readable only by its owner — so a note filed there is
68
+ * never found as knowledge again, by anyone, including its author's next
69
+ * agent. Agents asked to "save this for me" were reading the old opening,
70
+ * "their personal space in the knowledge base", as an invitation to do
71
+ * precisely that.
72
+ *
73
+ * Stated ahead of the mechanics of writing a skill, not after them, because
74
+ * claude.ai cuts a tool description near `CLIENT_SHORT_CUT` characters (see
75
+ * `tool-registry/description-length.ts`) counted from the guide-first sentence
76
+ * the registry puts in front of every listed tool. What survives that cut is
77
+ * decided by ORDER, so the rule sits inside it and the `skillsDir` detail —
78
+ * which the guide states in full anyway — is what a short client loses.
79
+ * The root is named ONCE, after the rule rather than inside it: a folder name
80
+ * may run to 255 bytes, and repeated within the rule it could push the rule
81
+ * itself past the cut and the whole text past `TOOL_DESCRIPTION_CAP`.
82
+ * `plugins.tools.test.ts` pins that placement rather than trusting this note.
83
+ *
84
+ * It REFUSES nothing. The write gate accepts every file in a personal plugin
85
+ * exactly as it did before; this text is the whole of the change.
86
+ */
87
+ export function myPluginDescription(layout: KbLayout): string {
88
+ const knowledge = `\`${resolveKbLayout(layout).knowledgeBaseDir}/\``;
89
+ return (
90
+ "The caller's personal plugin: their own skills and tools, created on first use. " +
91
+ 'Notes, knowledge and other documents do NOT go here; they go under the knowledge root. ' +
92
+ 'For something the user wants kept private, ask where under the knowledge root it should go and say a ' +
93
+ 'folder there can be restricted so only they can read it; never write it here, even if asked. ' +
94
+ `The knowledge root here is ${knowledge}. ` +
48
95
  'Returns its folder and where skills go inside it (`skillsDir`); write a skill there as ' +
49
96
  '`<skillsDir>/<skill-name>/SKILL.md` with the file tools, opening with the Agent Skills frontmatter ' +
50
97
  '(`name`, `description`, and `metadata.version` such as `"1.0.0"`). Readable only by its owner — not even admins — and ' +
51
- 'never listed as a shared plugin. Idempotent: calling it again returns the same folder.',
52
- path: '/api/plugins/personal',
53
- inputs: { type: 'object', properties: {}, additionalProperties: false },
54
- outputs: PROVISIONED_OUTPUT,
55
- // `write`: both make folders and commit — a read-scoped caller's manual
56
- // must not advertise them (see `isWriteTool` in the manual routes).
57
- tags: ['plugins', 'skills', 'write'],
58
- });
98
+ 'never listed as a shared plugin. Idempotent: calling it again returns the same folder.'
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The `my_plugin` definition for `layout`. Everything but the description is
104
+ * the same on every deployment and byte-identical to what it has always been:
105
+ * the same endpoint, the same (empty) inputs, the same outputs, the same tags.
106
+ */
107
+ export function myPluginDef(layout: KbLayout): UtcpTool {
108
+ return toolDef({
109
+ name: 'my_plugin',
110
+ description: myPluginDescription(layout),
111
+ path: '/api/plugins/personal',
112
+ inputs: { type: 'object', properties: {}, additionalProperties: false },
113
+ outputs: PROVISIONED_OUTPUT,
114
+ // `write`: both make folders and commit — a read-scoped caller's manual
115
+ // must not advertise them (see `isWriteTool` in the manual routes).
116
+ tags: ['plugins', 'skills', 'write'],
117
+ });
118
+ }
59
119
 
60
120
  export const CREATE_PLUGIN: UtcpTool = toolDef({
61
121
  name: 'create_plugin',
@@ -191,8 +191,6 @@ describe('DeploymentSettingsService — KB layout', () => {
191
191
  pluginsDir: 'Plugins',
192
192
  agentsFile: 'AGENTS.md',
193
193
  });
194
- // The pointer is on until someone says otherwise.
195
- expect(settings.resolveAgentsFileLink()).toBe(true);
196
194
  });
197
195
 
198
196
  /**
@@ -225,9 +223,9 @@ describe('DeploymentSettingsService — KB layout', () => {
225
223
  it('ignores the environment for the four layout names', async () => {
226
224
  const { db } = makeDb();
227
225
  const settings = new DeploymentSettingsService(db, ENC_KEY);
228
- await settings.save({ skillsDir: 'skills', pluginsDir: 'plugins', agentsFile: 'HEXIS.md' }, null);
226
+ await settings.save({ skillsDir: 'skills', pluginsDir: 'plugins' }, null);
229
227
  process.env.KB_SKILLS_DIR = 'capabilities';
230
- expect(settings.resolveKbLayout()).toMatchObject({ skillsDir: 'skills', agentsFile: 'HEXIS.md' });
228
+ expect(settings.resolveKbLayout()).toMatchObject({ skillsDir: 'skills' });
231
229
  expect(settings.sourceOf('skillsDir')).toBe('stored');
232
230
  // Not locked: the field stays editable in the app, and a save of it is
233
231
  // accepted rather than refused as 'set by the environment'.
@@ -284,68 +282,41 @@ describe('DeploymentSettingsService — KB layout', () => {
284
282
  });
285
283
 
286
284
  /**
287
- * The guide's file name is saved beside the folders and judged with them:
288
- * the four must differ, and a name that is not one markdown file is refused
289
- * with the rule it broke.
285
+ * The guide's name was a setting while the guide was written to disk. It is
286
+ * gone: the setup screen no longer offers it, a save naming it is refused
287
+ * as unknown, and the guide is read as `AGENTS.md` on every deployment
288
+ * whatever a deployment saved before.
290
289
  */
291
- it('refuses a guide name that is not one markdown file of its own', async () => {
292
- const { db } = makeDb();
293
- const settings = new DeploymentSettingsService(db, ENC_KEY);
294
- for (const bad of ['guides/HEXIS.md', 'HEXIS.txt', 'CLAUDE.md', 'access.md', 'roles.yaml']) {
295
- await expect(settings.save({ agentsFile: bad }, null)).rejects.toBeInstanceOf(
296
- SettingsValidationError,
297
- );
298
- }
299
- await expect(settings.save({ agentsFile: 'HEXIS.md' }, null)).resolves.toBeTruthy();
300
- });
301
-
302
- it('refuses a guide named after a root folder, from either side of the pair', async () => {
303
- const { db } = makeDb();
304
- const settings = new DeploymentSettingsService(db, ENC_KEY);
305
- // The plugins folder already in effect, named by the guide alone.
306
- await settings.save({ pluginsDir: 'Guide.md' }, null);
307
- await expect(settings.save({ agentsFile: 'guide.md' }, null)).rejects.toBeInstanceOf(
308
- SettingsValidationError,
309
- );
310
- // And the other way round, in one batch.
311
- await expect(
312
- settings.save({ agentsFile: 'HEXIS.md', skillsDir: 'hexis.md' }, null),
313
- ).rejects.toBeInstanceOf(SettingsValidationError);
314
- });
315
-
316
- it('marks the guide name and its pointer setting as restart-to-apply', async () => {
317
- const { db } = makeDb();
290
+ it('knows no guide name any more: a save naming one is refused, and the layout always says AGENTS.md', async () => {
291
+ const { db, rows } = makeDb();
292
+ // The row a deployment saved while the setting existed is still in its
293
+ // database. It is loaded like any other row, and changes nothing.
294
+ rows.push({ key: 'agentsFile', value: 'HEXIS.md', encrypted: false });
318
295
  const settings = new DeploymentSettingsService(db, ENC_KEY);
319
- expect((await settings.save({ agentsFile: 'HEXIS.md' }, null)).restartKeys).toContain('agentsFile');
320
- expect((await settings.save({ agentsFileLink: 'false' }, null)).restartKeys).toContain(
321
- 'agentsFileLink',
322
- );
323
- expect(settings.resolveAgentsFileLink()).toBe(false);
296
+ await settings.load();
297
+ expect(settings.resolveKbLayout().agentsFile).toBe('AGENTS.md');
298
+ await expect(settings.save({ agentsFile: 'HEXIS.md' }, null)).rejects.toMatchObject({
299
+ problems: { agentsFile: 'Unknown setting.' },
300
+ });
301
+ await expect(settings.save({ agentsFile: 'HEXIS.md' }, null, 'deployment')).rejects.toMatchObject({
302
+ problems: { agentsFile: 'Unknown setting.' },
303
+ });
304
+ expect(settings.resolveKbLayout().agentsFile).toBe('AGENTS.md');
305
+ expect(settings.describe().map((s) => s.key)).not.toContain('agentsFile');
324
306
  });
325
307
 
326
308
  /**
327
309
  * A restart is owed for a CHANGE, and saving what a deployment is already
328
- * running on is not one. Both of these settings mean something while unset —
329
- * the guide is `AGENTS.md`, the pointer is on — so the first save of that
330
- * same answer changes nothing the process would pick up at a restart.
310
+ * running on is not one. A root name means something while unset — the
311
+ * default — so the first save of that same answer changes nothing the
312
+ * process would pick up at a restart.
331
313
  */
332
314
  it('owes no restart for saving the value an unset setting already meant', async () => {
333
315
  const { db } = makeDb();
334
316
  const settings = new DeploymentSettingsService(db, ENC_KEY);
335
- // The checkbox arrives ticked, and ticked is what an unset one already is.
336
- expect((await settings.save({ agentsFileLink: 'true' }, null)).restartKeys).not.toContain(
337
- 'agentsFileLink',
338
- );
339
- expect((await settings.save({ agentsFile: 'AGENTS.md' }, null)).restartKeys).not.toContain(
340
- 'agentsFile',
341
- );
342
317
  expect((await settings.save({ skillsDir: 'Skills' }, null)).restartKeys).not.toContain('skillsDir');
343
- // And the setting still reads as it did.
344
- expect(settings.resolveAgentsFileLink()).toBe(true);
345
- // Turning it off from there IS a change, and still reports one.
346
- expect((await settings.save({ agentsFileLink: 'false' }, null)).restartKeys).toContain(
347
- 'agentsFileLink',
348
- );
318
+ // Renaming it from there IS a change, and still reports one.
319
+ expect((await settings.save({ skillsDir: 'Abilities' }, null)).restartKeys).toContain('skillsDir');
349
320
  });
350
321
 
351
322
  /**