@bevel-software/platform-core-backend 0.7.4 → 0.8.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 (246) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +38 -21
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  104. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/pending-commits.service.js +37 -9
  106. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  107. package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
  108. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  109. package/dist/modules/workflow/pending-commits.worker.js +63 -9
  110. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  111. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  112. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  113. package/dist/modules/workflow/workflow.service.js +6 -1
  114. package/dist/modules/workflow/workflow.service.js.map +1 -1
  115. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  116. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  117. package/dist/modules/workspace/kb-seed.service.js +43 -10
  118. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  119. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  120. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  121. package/dist/modules/workspace/plugins-migration.js +379 -0
  122. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  123. package/dist/modules/workspace/workspace.routes.js +3 -3
  124. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  125. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  126. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.service.js +83 -12
  128. package/dist/modules/workspace/workspace.service.js.map +1 -1
  129. package/dist/shared/kb-layout.test.js +3 -3
  130. package/dist/shared/kb-layout.test.js.map +1 -1
  131. package/dist/shared/utcp-namespace.d.ts +6 -27
  132. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  133. package/dist/shared/utcp-namespace.js +6 -63
  134. package/dist/shared/utcp-namespace.js.map +1 -1
  135. package/dist/shared/variable-refs.d.ts +42 -0
  136. package/dist/shared/variable-refs.d.ts.map +1 -0
  137. package/dist/shared/variable-refs.js +60 -0
  138. package/dist/shared/variable-refs.js.map +1 -0
  139. package/kb-template/.bevelignore +1 -1
  140. package/kb-template/AGENTS.md +88 -35
  141. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  142. package/kb-template/access.md +36 -36
  143. package/migrations/meta/0000_snapshot.json +1479 -1479
  144. package/package.json +5 -4
  145. package/src/assets.ts +25 -25
  146. package/src/core/core-ports.ts +106 -106
  147. package/src/core/create-core-server.ts +55 -9
  148. package/src/core/create-core-services.ts +48 -24
  149. package/src/index.ts +69 -69
  150. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  151. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  152. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  153. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  154. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  155. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  156. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  157. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  158. package/src/modules/access/access-control.interface.ts +8 -7
  159. package/src/modules/access/access-control.service.ts +7 -7
  160. package/src/modules/access/access-declarations.ts +5 -5
  161. package/src/modules/access/access-mutation.service.ts +3 -3
  162. package/src/modules/access/access-splice.ts +4 -4
  163. package/src/modules/access/access.routes.ts +20 -20
  164. package/src/modules/access/creator-access.ts +5 -5
  165. package/src/modules/access/roles-admin.service.ts +13 -2
  166. package/src/modules/admin/admin-access.routes.ts +29 -29
  167. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  168. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  169. package/src/modules/auth/rate-limit.ts +45 -45
  170. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  171. package/src/modules/code-mode/code-mode-names.ts +10 -36
  172. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  173. package/src/modules/database/connection.ts +15 -15
  174. package/src/modules/database/schema.ts +11 -11
  175. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  176. package/src/modules/mcp/mcp.service.ts +57 -435
  177. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  178. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  179. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  180. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  181. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  182. package/src/modules/plugins/index.ts +17 -0
  183. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  184. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  185. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  186. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  187. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  188. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  189. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  190. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  191. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  192. package/src/modules/skills/pending-skills.service.ts +7 -7
  193. package/src/modules/skills/skills.contract.ts +4 -4
  194. package/src/modules/skills/skills.service.ts +5 -5
  195. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  196. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  197. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  198. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  199. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  200. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  201. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  203. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  204. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  205. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  206. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  207. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  208. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  209. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +118 -0
  210. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  211. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  212. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  213. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  214. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  215. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  216. package/src/modules/workflow/git/git.service.ts +2 -2
  217. package/src/modules/workflow/pending-commits.service.ts +48 -12
  218. package/src/modules/workflow/pending-commits.worker.ts +64 -8
  219. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  220. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  221. package/src/modules/workflow/workflow-hooks.ts +101 -101
  222. package/src/modules/workflow/workflow.service.ts +13 -2
  223. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  224. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  225. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  226. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  227. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  228. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  229. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  230. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  231. package/src/modules/workspace/bevel-ignore.ts +66 -66
  232. package/src/modules/workspace/kb-seed.service.ts +38 -9
  233. package/src/modules/workspace/plugins-migration.ts +479 -0
  234. package/src/modules/workspace/session-sink.ts +25 -25
  235. package/src/modules/workspace/workspace.routes.ts +3 -3
  236. package/src/modules/workspace/workspace.service.ts +85 -14
  237. package/src/modules/workspace/workspace.tools.ts +922 -922
  238. package/src/shared/__tests__/join-request.test.ts +13 -13
  239. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  240. package/src/shared/kb-layout.test.ts +3 -3
  241. package/src/shared/utcp-namespace.ts +10 -68
  242. package/src/shared/variable-refs.ts +64 -0
  243. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  244. package/src/modules/groups/index.ts +0 -17
  245. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  246. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -119,7 +119,7 @@ describe('KbSeedService', () => {
119
119
  expect(await exists(path.join(dir, '.gitignore'))).toBe(true);
120
120
  // Core's two roots are seeded (kept present via their .gitkeep).
121
121
  expect(await exists(path.join(dir, 'KnowledgeBase/.gitkeep'))).toBe(true);
122
- expect(await exists(path.join(dir, 'Groups/.gitkeep'))).toBe(true);
122
+ expect(await exists(path.join(dir, 'Plugins/.gitkeep'))).toBe(true);
123
123
  // …and only those two. `Data/`, `Agents/` and `Pipelines/` scaffold an
124
124
  // agentic execution layer this platform does not have, so seeding them
125
125
  // would hand every operator three empty folders nothing can fill.
@@ -156,7 +156,7 @@ describe('KbSeedService', () => {
156
156
  '.bevelignore': 'x',
157
157
  '.gitignore': 'x',
158
158
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
159
- 'Groups/.gitkeep': '',
159
+ 'Plugins/.gitkeep': '',
160
160
  });
161
161
 
162
162
  await makeSeeder(upstream).ensureRemoteSeeded();
@@ -172,7 +172,7 @@ describe('KbSeedService', () => {
172
172
  'access.md': 'CUSTOM ACCESS RULES',
173
173
  // AGENTS.md / .bevelignore / .gitignore are absent → should be added.
174
174
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
175
- 'Groups/.gitkeep': '',
175
+ 'Plugins/.gitkeep': '',
176
176
  });
177
177
 
178
178
  // Simulate the app loading the branch: a fresh clone, then top-up on it.
@@ -201,7 +201,7 @@ describe('KbSeedService', () => {
201
201
  '.gitignore': '',
202
202
  'access.md': 'CUSTOM ACCESS RULES',
203
203
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
204
- 'Groups/.gitkeep': '',
204
+ 'Plugins/.gitkeep': '',
205
205
  });
206
206
 
207
207
  const repoDir = await checkout(root, upstream, DEFAULT_BRANCH);
@@ -240,7 +240,7 @@ describe('KbSeedService', () => {
240
240
  '.gitignore': '',
241
241
  'access.md': 'CUSTOM ACCESS RULES',
242
242
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
243
- 'Groups/.gitkeep': '',
243
+ 'Plugins/.gitkeep': '',
244
244
  });
245
245
 
246
246
  const first = await checkout(root, upstream, DEFAULT_BRANCH);
@@ -254,7 +254,7 @@ describe('KbSeedService', () => {
254
254
  const upstream = await seededUpstream(PROTECTED, {
255
255
  'roles.yaml': 'roles:\n Admin:\n - keep@example.com\n',
256
256
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
257
- 'Groups/.gitkeep': '',
257
+ 'Plugins/.gitkeep': '',
258
258
  });
259
259
 
260
260
  const first = await checkout(root, upstream, DEFAULT_BRANCH);
@@ -270,6 +270,74 @@ describe('KbSeedService', () => {
270
270
  expect(after2).toBe(after1);
271
271
  });
272
272
 
273
+ it('a note-only migration outcome stages nothing and raises no top-up warning', async () => {
274
+ const template = await fs.readFile(
275
+ path.join(__dirname, '../../../../kb-template/AGENTS.md'),
276
+ 'utf8',
277
+ );
278
+ const upstream = await seededUpstream(PROTECTED, {
279
+ 'roles.yaml': 'roles:\n Admin:\n - keep@example.com\n',
280
+ 'access.md': 'x',
281
+ 'AGENTS.md': template,
282
+ '.bevelignore': 'AGENTS.md\nPlugins/\n',
283
+ '.gitignore': '',
284
+ 'KnowledgeBase/Real/Knowledge/.gitkeep': '',
285
+ 'Plugins/GTM/access.md': 'write:\n - Admin\n',
286
+ // An unparsable manifest keeps this manual a `.tool` — its variables
287
+ // have nowhere to go. The migration NOTES the refusal every run and
288
+ // changes nothing; staging on the note used to reach `git commit`
289
+ // with an empty index and warn about it on every boot.
290
+ 'Plugins/GTM/plugin.json': '{ not json at all',
291
+ 'Plugins/GTM/vendor.tool': JSON.stringify({
292
+ name: 'vendor',
293
+ type: 'mcp',
294
+ url: 'https://mcp.vendor.example/mcp',
295
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
296
+ }),
297
+ });
298
+ const repoDir = await checkout(root, upstream, DEFAULT_BRANCH);
299
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
300
+ try {
301
+ await makeSeeder(upstream).topUpWorkspace(repoDir, DEFAULT_BRANCH);
302
+ expect(warn.mock.calls.flat().join(' ')).not.toMatch(/top-up/i);
303
+ } finally {
304
+ warn.mockRestore();
305
+ }
306
+ // Only the pre-seed commit — a note is not a change.
307
+ expect(await headCommitCount(root, upstream, DEFAULT_BRANCH)).toBe(1);
308
+ });
309
+
310
+ it('the root rename rewrites the stale .bevelignore rule and commits it along', async () => {
311
+ const template = await fs.readFile(
312
+ path.join(__dirname, '../../../../kb-template/AGENTS.md'),
313
+ 'utf8',
314
+ );
315
+ const upstream = await seededUpstream(PROTECTED, {
316
+ 'roles.yaml': 'roles:\n Admin:\n - keep@example.com\n',
317
+ 'access.md': 'x',
318
+ 'AGENTS.md': template,
319
+ // The pre-rename rule: without the rewrite the migrated KB has a rule
320
+ // for a root that no longer exists and none for the one that does.
321
+ '.bevelignore': 'AGENTS.md\nGroups/\n',
322
+ '.gitignore': '',
323
+ 'KnowledgeBase/Real/Knowledge/.gitkeep': '',
324
+ 'Groups/GTM/access.md': 'write:\n - Admin\n',
325
+ });
326
+ const repoDir = await checkout(root, upstream, DEFAULT_BRANCH);
327
+ await makeSeeder(upstream).topUpWorkspace(repoDir, DEFAULT_BRANCH);
328
+
329
+ // An independent clone proves the rewrite was STAGED (the ignore file
330
+ // is a repo-root path outside the two roots' pathspecs) and pushed.
331
+ const dir = await checkout(root, upstream, DEFAULT_BRANCH);
332
+ expect(await exists(path.join(dir, 'Plugins/GTM/access.md'))).toBe(true);
333
+ const ignore = await fs.readFile(path.join(dir, '.bevelignore'), 'utf8');
334
+ expect(ignore.split('\n').map((l) => l.trim())).toContain('Plugins/');
335
+ expect(ignore).not.toContain('Groups/');
336
+ expect(ignore).toContain('AGENTS.md');
337
+ const log = await git(dir, ['log', '-1', '--pretty=%s']);
338
+ expect(log.trim()).toBe('Move Groups/ to Plugins/ (Agent Plugins layout)');
339
+ });
340
+
273
341
  });
274
342
 
275
343
  /**
@@ -292,7 +360,7 @@ describe('KbSeedService', () => {
292
360
  describe('the pre-rename ignore file', () => {
293
361
  const legacy = (ignore: string): Record<string, string> => ({
294
362
  'KnowledgeBase/.gitkeep': '',
295
- 'Groups/.gitkeep': '',
363
+ 'Plugins/.gitkeep': '',
296
364
  'access.md': '---\nwrite:\n - Admin\n---\n',
297
365
  'roles.yaml': 'roles:\n Admin:\n - a@example.com\n',
298
366
  '.gitignore': '',
@@ -335,7 +403,7 @@ describe('KbSeedService', () => {
335
403
  const dir = await checkout(root, upstream, DEFAULT_BRANCH);
336
404
  // Core's two, still there.
337
405
  expect(await exists(path.join(dir, 'KnowledgeBase/.gitkeep'))).toBe(true);
338
- expect(await exists(path.join(dir, 'Groups/.gitkeep'))).toBe(true);
406
+ expect(await exists(path.join(dir, 'Plugins/.gitkeep'))).toBe(true);
339
407
  // …and the three this distribution asked for.
340
408
  for (const claimed of ['Data', 'Agents', 'Pipelines']) {
341
409
  expect(await exists(path.join(dir, `${claimed}/.gitkeep`))).toBe(true);
@@ -379,7 +447,7 @@ describe('KbSeedService', () => {
379
447
  });
380
448
 
381
449
  /**
382
- * A FILE named `Groups` used to satisfy the presence check — `fs.access`
450
+ * A FILE named `Plugins` used to satisfy the presence check — `fs.access`
383
451
  * answers "is there something here?" — so the seeder skipped it, said
384
452
  * nothing, and left a knowledge base permanently missing a root it claims
385
453
  * to guarantee. Now it says so.
@@ -396,18 +464,18 @@ describe('KbSeedService', () => {
396
464
  '.gitignore': '',
397
465
  'KnowledgeBase/.gitkeep': '',
398
466
  // Not a folder.
399
- Groups: 'this is a file, not the groups root',
467
+ Plugins: 'this is a file, not the plugins root',
400
468
  });
401
469
  const repoDir = await checkout(root, upstream, DEFAULT_BRANCH);
402
470
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
403
471
  try {
404
472
  await makeSeeder(upstream).topUpWorkspace(repoDir, DEFAULT_BRANCH);
405
- expect(warn.mock.calls.flat().join(' ')).toMatch(/Groups.*not a directory/);
473
+ expect(warn.mock.calls.flat().join(' ')).toMatch(/Plugins.*not a directory/);
406
474
  } finally {
407
475
  warn.mockRestore();
408
476
  }
409
477
  // …and it did not quietly replace the file either.
410
- expect((await fs.lstat(path.join(repoDir, 'Groups'))).isFile()).toBe(true);
478
+ expect((await fs.lstat(path.join(repoDir, 'Plugins'))).isFile()).toBe(true);
411
479
  });
412
480
 
413
481
  /**
@@ -422,7 +490,7 @@ describe('KbSeedService', () => {
422
490
  const upstream = await seededUpstream(PROTECTED, {
423
491
  'roles.yaml': 'roles:\n Admin:\n - keep@example.com\n',
424
492
  'KnowledgeBase/Real/Knowledge/.gitkeep': '',
425
- 'Groups/.gitkeep': '',
493
+ 'Plugins/.gitkeep': '',
426
494
  });
427
495
  // Branch a suggestion off the default and load it.
428
496
  const seed = await checkout(root, upstream, DEFAULT_BRANCH);
@@ -0,0 +1,427 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { migrateGroupsToPlugins } from '../plugins-migration.js';
6
+
7
+ let repo: string;
8
+
9
+ async function write(rel: string, content: string): Promise<void> {
10
+ const abs = path.join(repo, rel);
11
+ await fs.mkdir(path.dirname(abs), { recursive: true });
12
+ await fs.writeFile(abs, content, 'utf-8');
13
+ }
14
+
15
+ async function read(rel: string): Promise<string> {
16
+ return fs.readFile(path.join(repo, rel), 'utf-8');
17
+ }
18
+
19
+ async function exists(rel: string): Promise<boolean> {
20
+ try {
21
+ await fs.stat(path.join(repo, rel));
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** A knowledge base as every deployment looks today, pre-migration. */
29
+ async function seedLegacyKb(): Promise<void> {
30
+ await write('Groups/GTM/access.md', '---\nread:\n - everyone\n---\nwrite:\n - Admin\n');
31
+ await write('Groups/GTM/outreach/SKILL.md', '---\ndescription: Outreach.\n---\n# Outreach\n');
32
+ await write('Groups/GTM/outreach/scripts/run.py', 'print("hi")\n');
33
+ await write(
34
+ 'Groups/GTM/notion.tool',
35
+ JSON.stringify({ name: 'notion', type: 'mcp', url: 'https://mcp.notion.com/mcp' }, null, 2),
36
+ );
37
+ await write(
38
+ 'Groups/GTM/web-search.tool',
39
+ JSON.stringify(
40
+ { name: 'web_search', type: 'inline', tools: [{ name: 'search' }] },
41
+ null,
42
+ 2,
43
+ ),
44
+ );
45
+ }
46
+
47
+ beforeEach(async () => {
48
+ repo = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-plugins-'));
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await fs.rm(repo, { recursive: true, force: true });
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ describe('migrateGroupsToPlugins', () => {
57
+ it('does nothing to a knowledge base that has neither root', async () => {
58
+ await write('KnowledgeBase/.gitkeep', '');
59
+ expect(await migrateGroupsToPlugins(repo)).toEqual({
60
+ migrated: false,
61
+ renamed: false,
62
+ ignoreRewritten: false,
63
+ notes: [],
64
+ });
65
+ });
66
+
67
+ it('moves the root, the skills and the tools, and writes the manifest', async () => {
68
+ await seedLegacyKb();
69
+ const result = await migrateGroupsToPlugins(repo);
70
+ expect(result.migrated).toBe(true);
71
+
72
+ expect(await exists('Groups')).toBe(false);
73
+ // access.md stays at the PLUGIN ROOT — one level down it would govern only
74
+ // its own subtree, silently narrowing what it protects.
75
+ expect(await exists('Plugins/GTM/access.md')).toBe(true);
76
+ expect(await exists('Plugins/GTM/skills/outreach/SKILL.md')).toBe(true);
77
+ // A skill folder moves whole; its bundled assets are part of the skill.
78
+ expect(await exists('Plugins/GTM/skills/outreach/scripts/run.py')).toBe(true);
79
+ // The mcp manual CONVERTED (mcp.json is authoritative); http/inline moved.
80
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/notion.tool')).toBe(false);
81
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/web-search.tool')).toBe(true);
82
+ });
83
+
84
+ it('writes a manifest a conformant client accepts', async () => {
85
+ await seedLegacyKb();
86
+ await migrateGroupsToPlugins(repo);
87
+ const manifest = JSON.parse(await read('Plugins/GTM/plugin.json'));
88
+ expect(manifest.$schema).toBe('https://agent-plugins.org/schemas/1.0.0/plugin.schema.json');
89
+ expect(manifest.name).toBe('gtm');
90
+ // The closed field set: anything else is a top-level field the spec does
91
+ // not permit, and inventing metadata about a folder is asserting things
92
+ // nobody said.
93
+ expect(Object.keys(manifest).sort()).toEqual(['$schema', 'name']);
94
+ });
95
+
96
+ it('converts mcp-type manuals into mcp.json entries and leaves the others out of it', async () => {
97
+ await seedLegacyKb();
98
+ await migrateGroupsToPlugins(repo);
99
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
100
+ expect(mcp.$schema).toBe('https://agent-plugins.org/schemas/1.0.0/mcp.schema.json');
101
+ expect(mcp.mcpServers).toEqual({
102
+ notion: { type: 'streamable-http', url: 'https://mcp.notion.com/mcp' },
103
+ });
104
+ // `inline` has no representation in the spec, so it exists only as a
105
+ // `.tool` under our namespace.
106
+ expect(mcp.mcpServers.web_search).toBeUndefined();
107
+ });
108
+
109
+ it('splits headers: literals into mcp.json, credential references into plugin.json extensions', async () => {
110
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
111
+ await write(
112
+ 'Groups/GTM/vendor.tool',
113
+ JSON.stringify({
114
+ name: 'vendor',
115
+ type: 'mcp',
116
+ url: 'https://mcp.vendor.example/mcp',
117
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}', 'X-Api-Version': '2' },
118
+ }),
119
+ );
120
+ await migrateGroupsToPlugins(repo);
121
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
122
+ // The spec forbids credentials in `headers` and forbids expanding anything
123
+ // but ${PLUGIN_ROOT}/${PLUGIN_DATA} — a copied ${VENDOR_KEY} would be sent
124
+ // literally by a conformant client.
125
+ expect(mcp.mcpServers.vendor.headers).toEqual({ 'X-Api-Version': '2' });
126
+ // The reference lives on in the extensions block, which is ours to interpret.
127
+ const manifest = JSON.parse(await read('Plugins/GTM/plugin.json'));
128
+ expect(manifest.extensions['software.bevel.hexis'].mcpServers.vendor.headers).toEqual({
129
+ Authorization: 'Bearer ${VENDOR_KEY}',
130
+ });
131
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(false);
132
+ });
133
+
134
+ it('omits headers entirely when every one of them was a credential reference', async () => {
135
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
136
+ await write(
137
+ 'Groups/GTM/vendor.tool',
138
+ JSON.stringify({
139
+ name: 'vendor',
140
+ type: 'mcp',
141
+ url: 'https://mcp.vendor.example/mcp',
142
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}' },
143
+ }),
144
+ );
145
+ await migrateGroupsToPlugins(repo);
146
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
147
+ // An empty `headers` object would assert "this server needs no auth".
148
+ // Saying nothing is the honest shape: where it is, not how to reach it.
149
+ expect(mcp.mcpServers.vendor).toEqual({
150
+ type: 'streamable-http',
151
+ url: 'https://mcp.vendor.example/mcp',
152
+ });
153
+ });
154
+
155
+ it('converts an mcp .tool a previous run parked in the extension dir, second sweep', async () => {
156
+ // The earlier migration shape MOVED mcp .tools here and projected mcp.json;
157
+ // now that mcp.json is authoritative, a parked twin must convert away.
158
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
159
+ await write('Plugins/GTM/plugin.json', '{ "name": "gtm" }');
160
+ await write(
161
+ 'Plugins/GTM/software.bevel.hexis/tools/notion.tool',
162
+ JSON.stringify({ name: 'notion', type: 'mcp', url: 'https://mcp.notion.com/mcp' }),
163
+ );
164
+ const result = await migrateGroupsToPlugins(repo);
165
+ expect(result.migrated).toBe(true);
166
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/notion.tool')).toBe(false);
167
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
168
+ expect(mcp.mcpServers.notion.url).toBe('https://mcp.notion.com/mcp');
169
+ });
170
+
171
+ it('moves declared variables and the local flag into the plugin.json extensions block', async () => {
172
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
173
+ await write('Plugins/GTM/plugin.json', '{ "name": "gtm" }');
174
+ await write(
175
+ 'Plugins/GTM/vendor.tool',
176
+ JSON.stringify({
177
+ name: 'vendor',
178
+ type: 'mcp',
179
+ url: 'http://localhost:9000/mcp',
180
+ remote: false,
181
+ variables: [{ name: 'VENDOR_KEY', scope: 'user', label: 'Your key' }],
182
+ }),
183
+ );
184
+ await migrateGroupsToPlugins(repo);
185
+ const manifest = JSON.parse(await read('Plugins/GTM/plugin.json'));
186
+ expect(manifest.extensions['software.bevel.hexis'].mcpServers.vendor).toEqual({
187
+ variables: [{ name: 'VENDOR_KEY', scope: 'user', label: 'Your key' }],
188
+ local: true,
189
+ });
190
+ });
191
+
192
+ it('is idempotent — a second run changes nothing', async () => {
193
+ await seedLegacyKb();
194
+ await migrateGroupsToPlugins(repo);
195
+ const before = await read('Plugins/GTM/plugin.json');
196
+ expect(await migrateGroupsToPlugins(repo)).toEqual({
197
+ migrated: false,
198
+ renamed: false,
199
+ ignoreRewritten: false,
200
+ notes: [],
201
+ });
202
+ expect(await read('Plugins/GTM/plugin.json')).toBe(before);
203
+ });
204
+
205
+ it('finishes a half-done migration rather than skipping it', async () => {
206
+ // The rename landed but the process died before the reorganisation.
207
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
208
+ await write('Plugins/GTM/outreach/SKILL.md', '# Outreach\n');
209
+ const result = await migrateGroupsToPlugins(repo);
210
+ expect(result.migrated).toBe(true);
211
+ expect(await exists('Plugins/GTM/skills/outreach/SKILL.md')).toBe(true);
212
+ expect(await exists('Plugins/GTM/plugin.json')).toBe(true);
213
+ });
214
+
215
+ it('refuses to guess when both roots exist, and says so', async () => {
216
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
217
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
218
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
219
+ expect(await migrateGroupsToPlugins(repo)).toEqual({
220
+ migrated: false,
221
+ renamed: false,
222
+ ignoreRewritten: false,
223
+ notes: [],
224
+ });
225
+ // Both left exactly as they were — merging would pick a winner nobody chose.
226
+ expect(await exists('Groups/GTM/access.md')).toBe(true);
227
+ expect(await exists('Plugins/GTM/access.md')).toBe(true);
228
+ expect(warn.mock.calls.flat().join(' ')).toMatch(/both .*exist/i);
229
+ });
230
+
231
+ it('moves a malformed .tool without letting it stop the migration', async () => {
232
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
233
+ await write('Groups/GTM/broken.tool', '{ not json at all');
234
+ const result = await migrateGroupsToPlugins(repo);
235
+ expect(result.migrated).toBe(true);
236
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/broken.tool')).toBe(true);
237
+ // Nothing to project, so no mcp.json is invented.
238
+ expect(await exists('Plugins/GTM/mcp.json')).toBe(false);
239
+ });
240
+
241
+ it('merges into an existing mcp.json without clobbering what is already there', async () => {
242
+ await seedLegacyKb();
243
+ await write(
244
+ 'Groups/GTM/mcp.json',
245
+ JSON.stringify({ mcpServers: { notion: { type: 'streamable-http', url: 'https://hand.example/mcp' } } }),
246
+ );
247
+ await migrateGroupsToPlugins(repo);
248
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
249
+ // The hand-written notion entry WINS; the converted .tool is gone either way.
250
+ expect(mcp.mcpServers.notion.url).toBe('https://hand.example/mcp');
251
+ expect(await exists('Plugins/GTM/notion.tool')).toBe(false);
252
+ });
253
+
254
+ it('leaves a personal folder a valid plugin', async () => {
255
+ await write('Groups/personal-u-123/access.md', 'write:\n - Ali <ali@x.com>\n');
256
+ await migrateGroupsToPlugins(repo);
257
+ const manifest = JSON.parse(await read('Plugins/personal-u-123/plugin.json'));
258
+ expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
259
+ });
260
+
261
+ it('routes anything the substitutor would expand to the extensions block — non-name ${…} stays literal', async () => {
262
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
263
+ await write(
264
+ 'Groups/GTM/vendor.tool',
265
+ JSON.stringify({
266
+ name: 'vendor',
267
+ type: 'mcp',
268
+ url: 'https://mcp.vendor.example/mcp',
269
+ // The substitutor's grammar decides: bare `$VENDOR_KEY` and the
270
+ // digit-leading `$5` in the price BOTH expand, so both route to the
271
+ // non-portable half; `${not-a-name}` is not expandable and stays.
272
+ headers: {
273
+ Authorization: 'Bearer $VENDOR_KEY',
274
+ 'X-Price': '$5 per call',
275
+ 'X-Tag': 'v ${not-a-name}',
276
+ },
277
+ }),
278
+ );
279
+ await migrateGroupsToPlugins(repo);
280
+ const mcp = JSON.parse(await read('Plugins/GTM/mcp.json'));
281
+ expect(mcp.mcpServers.vendor.headers).toEqual({ 'X-Tag': 'v ${not-a-name}' });
282
+ const manifest = JSON.parse(await read('Plugins/GTM/plugin.json'));
283
+ expect(manifest.extensions['software.bevel.hexis'].mcpServers.vendor.headers).toEqual({
284
+ Authorization: 'Bearer $VENDOR_KEY',
285
+ 'X-Price': '$5 per call',
286
+ });
287
+ });
288
+
289
+ it('does not follow a symlinked extension tools dir in the second sweep', async () => {
290
+ // The second sweep DELETES what it converts — following a link would
291
+ // convert-and-delete `.tool` files from wherever the link really points.
292
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
293
+ await write('Plugins/GTM/plugin.json', '{ "name": "gtm" }');
294
+ await write(
295
+ 'elsewhere/tools/notion.tool',
296
+ JSON.stringify({ name: 'notion', type: 'mcp', url: 'https://mcp.notion.com/mcp' }),
297
+ );
298
+ await fs.mkdir(path.join(repo, 'Plugins/GTM/software.bevel.hexis'), { recursive: true });
299
+ // 'junction' so the link is creatable without privileges on Windows; on
300
+ // POSIX the type argument is ignored and a plain dir symlink is made.
301
+ await fs.symlink(
302
+ path.join(repo, 'elsewhere/tools'),
303
+ path.join(repo, 'Plugins/GTM/software.bevel.hexis/tools'),
304
+ 'junction',
305
+ );
306
+ const result = await migrateGroupsToPlugins(repo);
307
+ // The linked-to file is untouched — not converted, not deleted.
308
+ expect(await exists('elsewhere/tools/notion.tool')).toBe(true);
309
+ expect(await exists('Plugins/GTM/mcp.json')).toBe(false);
310
+ expect(result.notes.join(' ')).not.toContain('notion');
311
+ });
312
+
313
+ it('refuses to convert an mcp .tool whose url is not directly parseable http(s)', async () => {
314
+ // A templated url (`${VENDOR_BASE}/mcp`) is legal in a `.tool`, where the
315
+ // substitutor expands it — but the mcp.json loader validates `new URL`
316
+ // and skips the entry, so converting would delete the source and write a
317
+ // dead entry: the integration would simply vanish.
318
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
319
+ await write(
320
+ 'Groups/GTM/vendor.tool',
321
+ JSON.stringify({ name: 'vendor', type: 'mcp', url: '${VENDOR_BASE}/mcp' }),
322
+ );
323
+ await write(
324
+ 'Groups/GTM/socket.tool',
325
+ JSON.stringify({ name: 'socket', type: 'mcp', url: 'wss://mcp.vendor.example/mcp' }),
326
+ );
327
+ const result = await migrateGroupsToPlugins(repo);
328
+ // Both stay `.tool` files (moved with the other unconvertibles), and no
329
+ // mcp.json is invented for them.
330
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(true);
331
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/socket.tool')).toBe(true);
332
+ expect(await exists('Plugins/GTM/mcp.json')).toBe(false);
333
+ // The refusal is NAMED in the log — an operator must be able to tell a
334
+ // deliberately-retained integration from one that silently failed.
335
+ // (socket.tool never reaches the named refusals: its wss url fails
336
+ // `.tool` normalization itself, the not-a-candidate path.)
337
+ expect(result.notes.some((n) => /vendor\.tool.*NOT converted/.test(n))).toBe(true);
338
+ });
339
+
340
+ it('refuses to convert an mcp .tool whose url carries userinfo', async () => {
341
+ // `https://user:pass@…` copied into mcp.json would put a credential in
342
+ // the PORTABLE file — the exact thing the header split exists to prevent
343
+ // — and stripping it would break the server. The manual keeps its
344
+ // `.tool` form, where the credential stays platform-internal.
345
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
346
+ await write(
347
+ 'Groups/GTM/vendor.tool',
348
+ JSON.stringify({ name: 'vendor', type: 'mcp', url: 'https://user:pass@mcp.vendor.example/mcp' }),
349
+ );
350
+ await migrateGroupsToPlugins(repo);
351
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(true);
352
+ expect(await exists('Plugins/GTM/mcp.json')).toBe(false);
353
+ });
354
+
355
+ it('refuses to convert an mcp .tool that gates itself with frontmatter access verbs', async () => {
356
+ // The access resolver reads a `.tool`'s own verbs from the file itself;
357
+ // an mcp.json entry has no per-server home for them, so converting would
358
+ // silently widen who may configure and run the server.
359
+ await write('Groups/GTM/access.md', 'write:\n - Admin\n');
360
+ await write(
361
+ 'Groups/GTM/gated.tool',
362
+ '---\nname: gated\ntype: mcp\nurl: https://mcp.vendor.example/mcp\nwrite:\n - Product Team\n---\n',
363
+ );
364
+ await migrateGroupsToPlugins(repo);
365
+ expect(await exists('Plugins/GTM/software.bevel.hexis/tools/gated.tool')).toBe(true);
366
+ expect(await exists('Plugins/GTM/mcp.json')).toBe(false);
367
+ // The verbs travel with the file — the moved copy still declares them.
368
+ expect(await read('Plugins/GTM/software.bevel.hexis/tools/gated.tool')).toContain('Product Team');
369
+ });
370
+
371
+ it('reports a note-only run as not migrated — there is nothing to commit', async () => {
372
+ // An unparsable manifest blocks conversion of a manual that carries
373
+ // extension data; the refusal is a NOTE, not a file change, and saying
374
+ // `migrated` anyway would send the caller into an empty commit every boot.
375
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
376
+ await write('Plugins/GTM/plugin.json', '{ not json at all');
377
+ await write(
378
+ 'Plugins/GTM/vendor.tool',
379
+ JSON.stringify({
380
+ name: 'vendor',
381
+ type: 'mcp',
382
+ url: 'https://mcp.vendor.example/mcp',
383
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
384
+ }),
385
+ );
386
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
387
+ const result = await migrateGroupsToPlugins(repo);
388
+ warn.mockRestore();
389
+ expect(result.migrated).toBe(false);
390
+ expect(result.notes.join(' ')).toContain('NOT converted');
391
+ // The source stays, ready for the run after the manifest is fixed.
392
+ expect(await exists('Plugins/GTM/vendor.tool')).toBe(true);
393
+ });
394
+
395
+ describe('the .bevelignore root rule', () => {
396
+ it('follows the rename, preserving every other line', async () => {
397
+ await seedLegacyKb();
398
+ await write('.bevelignore', '# mine\nGroups/\nMy-Own-Rule/\n');
399
+ const result = await migrateGroupsToPlugins(repo);
400
+ expect(result.ignoreRewritten).toBe(true);
401
+ const ignore = await read('.bevelignore');
402
+ expect(ignore.split('\n')).toContain('Plugins/');
403
+ expect(ignore).not.toContain('Groups/');
404
+ expect(ignore).toContain('# mine');
405
+ expect(ignore).toContain('My-Own-Rule/');
406
+ });
407
+
408
+ it('is left alone when Plugins/ is already listed', async () => {
409
+ await seedLegacyKb();
410
+ await write('.bevelignore', 'Groups/\nPlugins/\n');
411
+ const result = await migrateGroupsToPlugins(repo);
412
+ expect(result.ignoreRewritten).toBe(false);
413
+ // The stale line is harmlessly dead; deleting it would be editing the
414
+ // operator's file beyond what the rename made stale.
415
+ expect(await read('.bevelignore')).toBe('Groups/\nPlugins/\n');
416
+ });
417
+
418
+ it('is not touched by a run that does not rename', async () => {
419
+ await write('Plugins/GTM/access.md', 'write:\n - Admin\n');
420
+ await write('Plugins/GTM/outreach/SKILL.md', '# Outreach\n');
421
+ await write('.bevelignore', 'Groups/\n');
422
+ const result = await migrateGroupsToPlugins(repo);
423
+ expect(result.ignoreRewritten).toBe(false);
424
+ expect(await read('.bevelignore')).toBe('Groups/\n');
425
+ });
426
+ });
427
+ });