@bevel-software/platform-core-backend 0.7.5 → 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 (230) 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 +30 -18
  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/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -0,0 +1,479 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ HEXIS_EXTENSION_NS,
5
+ HEXIS_TOOLS_DIR,
6
+ LEGACY_GROUPS_DIR,
7
+ PLUGINS_DIR,
8
+ PLUGIN_MANIFEST_FILE,
9
+ PLUGIN_MCP_FILE,
10
+ PLUGIN_MCP_SCHEMA,
11
+ PLUGIN_SKILLS_DIR,
12
+ renderPluginManifest,
13
+ } from '@bevel-software/platform-shared';
14
+ import type { ToolManualDescriptor } from '../tool-manuals/tool-manuals.contract.js';
15
+ import { normalizeToolManual } from '../tool-manuals/tool-manuals.service.js';
16
+ import { parseOwnAccessEntries } from '../access/access-control.service.js';
17
+ import { containsVariableReference } from '../../shared/variable-refs.js';
18
+ import { IGNORE_FILENAME } from './bevel-ignore.js';
19
+
20
+ /**
21
+ * One-way migration of a knowledge base from `Groups/` to the Agent Plugins
22
+ * layout under `Plugins/` (https://agent-plugins.org, v1.0.0).
23
+ *
24
+ * Runs from the seed top-up, so every deployment self-heals on the next load of
25
+ * a protected branch. Idempotent: a KB already on the new layout is untouched,
26
+ * and a half-finished run is completed by the next one.
27
+ *
28
+ * What moves — and what CONVERTS:
29
+ *
30
+ * Groups/GTM/access.md → Plugins/GTM/access.md (stays put)
31
+ * Groups/GTM/deploy/SKILL.md → Plugins/GTM/skills/deploy/SKILL.md
32
+ * Groups/GTM/web-search.tool → Plugins/GTM/software.bevel.hexis/tools/web-search.tool
33
+ * Groups/GTM/notion.tool (mcp) → an entry in Plugins/GTM/mcp.json, and the
34
+ * `.tool` file is DELETED — mcp.json is
35
+ * authoritative for MCP servers now.
36
+ * Plugins/GTM/plugin.json (written)
37
+ *
38
+ * The mcp.json entry is keyed by the `.tool`'s manual id: that id is the
39
+ * namespace vault secrets bind to (`<id>_<VAR>`), so keeping it is what keeps
40
+ * every configured secret and completed OAuth grant bound. What mcp.json
41
+ * cannot carry — auth headers with `${VAR}` references, variable declarations,
42
+ * the local-only flag — moves into `plugin.json`'s
43
+ * `extensions["software.bevel.hexis"].mcpServers[<id>]` block, the reverse-DNS
44
+ * namespace the spec reserves for client-specific data. `http`/`inline`
45
+ * manuals still MOVE as `.tool` files: nothing but this platform can run them.
46
+ */
47
+
48
+ export interface PluginsMigrationResult {
49
+ /**
50
+ * Whether this run CHANGED FILES. Notes alone (a manual that could not be
51
+ * converted, say) do not set it — the caller stages and commits on this
52
+ * flag, and a note-only run has nothing to commit.
53
+ */
54
+ migrated: boolean;
55
+ /** Whether the `Groups/` → `Plugins/` root rename itself happened this run. */
56
+ renamed: boolean;
57
+ /** Whether the KB's own `.bevelignore` had its `Groups/` rule rewritten (a repo-root file, staged separately). */
58
+ ignoreRewritten: boolean;
59
+ /** Human-readable summary lines (plugin names, tool moves) for the seed log. */
60
+ notes: string[];
61
+ }
62
+
63
+ // lstat, both helpers: SYMLINKS ARE NOT SUPPORTED IN PLUGINS, anywhere, so a
64
+ // link never counts as the thing it points at — following one here would let
65
+ // a symlinked directory pull files from outside the plugin into the sweep
66
+ // (and delete them from wherever they really live).
67
+ async function exists(p: string): Promise<boolean> {
68
+ try {
69
+ await fs.lstat(p);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async function isDir(p: string): Promise<boolean> {
77
+ try {
78
+ return (await fs.lstat(p)).isDirectory();
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Move `from` to `to`, creating the parent. Never overwrites: a destination
86
+ * that already exists means a previous run got there first (or a human did),
87
+ * and clobbering it would destroy the newer copy.
88
+ */
89
+ async function moveIfAbsent(from: string, to: string): Promise<boolean> {
90
+ if (await exists(to)) return false;
91
+ await fs.mkdir(path.dirname(to), { recursive: true });
92
+ await fs.rename(from, to);
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * A header value referencing a vault variable rather than carrying a literal.
98
+ * The substitutor's own grammar decides (shared/variable-refs.ts): both
99
+ * spellings it expands — `${VAR}` and bare `$VAR`, digit-leading names
100
+ * included — count, because either one copied into mcp.json would be
101
+ * transmitted verbatim by a conformant client. That means a prose `$5` in a
102
+ * header routes to the non-portable half too: over-classifying costs a header
103
+ * its portability, under-classifying leaks whatever `$5TOKEN` expands to.
104
+ */
105
+ const isCredentialReference = containsVariableReference;
106
+
107
+ /** Split a manual's headers into what mcp.json may carry and what may not. */
108
+ function splitHeaders(headers: Record<string, string> | undefined): {
109
+ literal: Record<string, string>;
110
+ credential: Record<string, string>;
111
+ } {
112
+ const literal: Record<string, string> = {};
113
+ const credential: Record<string, string> = {};
114
+ for (const [k, v] of Object.entries(headers ?? {})) {
115
+ (isCredentialReference(v) ? credential : literal)[k] = v;
116
+ }
117
+ return { literal, credential };
118
+ }
119
+
120
+ /** Read+parse a JSON file, or `null` when absent or unparsable. */
121
+ async function readJson(p: string): Promise<Record<string, unknown> | null> {
122
+ try {
123
+ const parsed: unknown = JSON.parse(await fs.readFile(p, 'utf-8'));
124
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
125
+ ? (parsed as Record<string, unknown>)
126
+ : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Fold converted mcp manuals into the plugin's mcp.json and plugin.json.
134
+ *
135
+ * MERGE, never clobber: an entry already present under a manual's key — hand
136
+ * written or from a previous run — wins, because overwriting it would discard
137
+ * the newer intent. The extension block merges the same way. A plugin.json
138
+ * that does not parse costs the extension write (logged), not the migration.
139
+ */
140
+ interface ConvertedManual {
141
+ manual: ToolManualDescriptor;
142
+ /** The source `.tool`, deleted only once the fold has landed. */
143
+ abs: string;
144
+ note: string;
145
+ }
146
+
147
+ async function foldIntoPluginFiles(
148
+ pluginDir: string,
149
+ folderName: string,
150
+ manuals: ConvertedManual[],
151
+ notes: string[],
152
+ ): Promise<boolean> {
153
+ if (manuals.length === 0) return false;
154
+
155
+ const mcpPath = path.join(pluginDir, PLUGIN_MCP_FILE);
156
+ const mcp = (await readJson(mcpPath)) ?? { $schema: PLUGIN_MCP_SCHEMA, mcpServers: {} };
157
+ // An array (or any non-object) here would take property assignments and then
158
+ // drop them at stringify — normalize to an object before merging into it.
159
+ if (typeof mcp.mcpServers !== 'object' || mcp.mcpServers === null || Array.isArray(mcp.mcpServers)) {
160
+ mcp.mcpServers = {};
161
+ }
162
+ const servers = mcp.mcpServers as Record<string, unknown>;
163
+
164
+ const manifestPath = path.join(pluginDir, PLUGIN_MANIFEST_FILE);
165
+ const manifest = await readJson(manifestPath);
166
+ if (manifest === null) {
167
+ console.warn(
168
+ `[plugins-migration] ${folderName}/${PLUGIN_MANIFEST_FILE} is missing or unparsable — ` +
169
+ 'mcp manuals convert only when they carry NOTHING for the extensions block; any ' +
170
+ 'non-portable half (auth headers, variables, a description, or the local-only flag) ' +
171
+ 'keeps the manual a `.tool` until the manifest is fixed.',
172
+ );
173
+ }
174
+
175
+ let wroteMcp = false;
176
+ let wroteManifest = false;
177
+ const folded: ConvertedManual[] = [];
178
+ for (const item of manuals.sort((a, b) => a.manual.name.localeCompare(b.manual.name))) {
179
+ const m = item.manual;
180
+ const { literal, credential } = splitHeaders(m.headers);
181
+ // A manual whose non-portable half (auth headers, variables, local flag)
182
+ // has nowhere to go — the manifest is missing or unparsable — is NOT
183
+ // converted at all: writing only its portable half and deleting the
184
+ // source would silently discard the credential wiring. It stays a
185
+ // `.tool` until the manifest is fixed.
186
+ const extEntryPreview = {
187
+ ...(Object.keys(credential).length > 0 ? { headers: credential } : {}),
188
+ ...(m.variables && m.variables.length > 0 ? { variables: m.variables } : {}),
189
+ ...(typeof m.description === 'string' ? { description: m.description } : {}),
190
+ ...(m.remote === false ? { local: true } : {}),
191
+ };
192
+ if (manifest === null && Object.keys(extEntryPreview).length > 0) {
193
+ notes.push(
194
+ `${folderName}: ${item.note} NOT converted — ${PLUGIN_MANIFEST_FILE} is missing/unparsable ` +
195
+ 'and the manual carries declarations (auth headers, variables, a description, or the ' +
196
+ 'local-only flag) that would be lost; fix the manifest first.',
197
+ );
198
+ continue;
199
+ }
200
+ folded.push(item);
201
+ // Own-property check: `in` sees `constructor` and friends on the prototype,
202
+ // which would silently skip a legitimately named server.
203
+ if (!Object.prototype.hasOwnProperty.call(servers, m.name)) {
204
+ servers[m.name] = {
205
+ type: 'streamable-http',
206
+ url: m.url,
207
+ ...(Object.keys(literal).length > 0 ? { headers: literal } : {}),
208
+ };
209
+ wroteMcp = true;
210
+ notes.push(`${folderName}: ${m.name} → ${PLUGIN_MCP_FILE}`);
211
+ }
212
+
213
+ // The non-portable half: auth headers, variable declarations, local-only.
214
+ const extEntry = extEntryPreview;
215
+ if (manifest !== null && Object.keys(extEntry).length > 0) {
216
+ // Normalize each level: a parseable manifest can still carry a string or
217
+ // array where an object belongs, and mutating that would throw mid-run.
218
+ if (typeof manifest.extensions !== 'object' || manifest.extensions === null || Array.isArray(manifest.extensions)) {
219
+ manifest.extensions = {};
220
+ }
221
+ const ext = manifest.extensions as Record<string, unknown>;
222
+ if (typeof ext[HEXIS_EXTENSION_NS] !== 'object' || ext[HEXIS_EXTENSION_NS] === null || Array.isArray(ext[HEXIS_EXTENSION_NS])) {
223
+ ext[HEXIS_EXTENSION_NS] = {};
224
+ }
225
+ const ns = ext[HEXIS_EXTENSION_NS] as Record<string, unknown>;
226
+ if (typeof ns.mcpServers !== 'object' || ns.mcpServers === null || Array.isArray(ns.mcpServers)) {
227
+ ns.mcpServers = {};
228
+ }
229
+ const extServers = ns.mcpServers as Record<string, unknown>;
230
+ if (!Object.prototype.hasOwnProperty.call(extServers, m.name)) {
231
+ extServers[m.name] = extEntry;
232
+ wroteManifest = true;
233
+ }
234
+ }
235
+ }
236
+
237
+ if (wroteMcp) await fs.writeFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, 'utf8');
238
+ if (wroteManifest) {
239
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
240
+ notes.push(`${folderName}: wrote mcp-server declarations into ${PLUGIN_MANIFEST_FILE}`);
241
+ }
242
+ // Sources go LAST, once everything they carried is on disk elsewhere. A
243
+ // failure anywhere above leaves every `.tool` in place for the next run —
244
+ // which re-converts idempotently, since the fold never clobbers a key.
245
+ for (const item of folded) {
246
+ await fs.rm(item.abs, { force: true });
247
+ notes.push(`${folderName}: ${item.note} converted to an ${PLUGIN_MCP_FILE} entry`);
248
+ }
249
+ return wroteMcp || wroteManifest || folded.length > 0;
250
+ }
251
+
252
+ /**
253
+ * Parse a `.tool`; a convertible MCP manual (has a url) parses to a
254
+ * descriptor. A non-candidate (other types, a file that will not parse)
255
+ * is `null` and moves as a plain `.tool`; an MCP manual REFUSED conversion
256
+ * comes back as the reason string, so the migration log distinguishes a
257
+ * deliberately-retained integration from one that silently failed — the
258
+ * same courtesy the manifest-missing refusal already extends.
259
+ */
260
+ async function asMcpManual(abs: string, repoRel: string): Promise<ToolManualDescriptor | string | null> {
261
+ try {
262
+ const content = await fs.readFile(abs, 'utf-8');
263
+ const d = normalizeToolManual(path.basename(abs).replace(/\.tool$/i, ''), repoRel, content);
264
+ // The mcp.json loader accepts only names it can serve as a namespace and
265
+ // route slug — converting a manual whose id fails that shape would DELETE
266
+ // a working integration and write an entry discovery then skips. Such a
267
+ // manual stays a `.tool`.
268
+ if (d.type !== 'mcp' || !d.url) return null;
269
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(d.name)) {
270
+ return `its id "${d.name}" is not a valid mcp.json server name`;
271
+ }
272
+ // Same refusal for the url: the mcp.json loader accepts only a directly
273
+ // parseable http(s) url, so a templated one (`${BASE}/mcp` — legal in a
274
+ // `.tool`, where the substitutor expands it) would convert into an entry
275
+ // discovery then skips, with the source already deleted. And a parseable
276
+ // url carrying `user:pass@` may not land in the PORTABLE file at all —
277
+ // stripping it would break the server, so the manual keeps its `.tool`
278
+ // form, where the credential stays platform-internal.
279
+ let url: URL;
280
+ try {
281
+ url = new URL(d.url);
282
+ } catch {
283
+ return 'its url is not directly parseable (a templated url only a .tool can carry)';
284
+ }
285
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
286
+ return `its url scheme "${url.protocol}" cannot land in mcp.json (http(s) only)`;
287
+ }
288
+ if (url.username || url.password) {
289
+ return 'its url embeds credentials, which may not land in the portable mcp.json';
290
+ }
291
+ // A `.tool` can gate ITSELF with frontmatter access verbs, read by the
292
+ // access resolver from the file's own path. An mcp.json entry has no
293
+ // per-server home for those — the file's ACL governs every server in it —
294
+ // so converting would silently widen who may configure and run the
295
+ // server. Such a manual stays a `.tool`, verbs and all.
296
+ if (parseOwnAccessEntries(content) !== null) {
297
+ return 'it gates itself with frontmatter access verbs, which mcp.json cannot carry';
298
+ }
299
+ return d;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /** Reorganise ONE plugin folder in place. Returns notes + whether files changed. */
306
+ async function migratePluginFolder(
307
+ pluginDir: string,
308
+ folderName: string,
309
+ ): Promise<{ notes: string[]; changed: boolean }> {
310
+ const notes: string[] = [];
311
+ let changed = false;
312
+
313
+ if (!(await exists(path.join(pluginDir, PLUGIN_MANIFEST_FILE)))) {
314
+ await fs.writeFile(
315
+ path.join(pluginDir, PLUGIN_MANIFEST_FILE),
316
+ renderPluginManifest(folderName),
317
+ 'utf8',
318
+ );
319
+ notes.push(`${folderName}: wrote ${PLUGIN_MANIFEST_FILE}`);
320
+ changed = true;
321
+ }
322
+
323
+ const entries = await fs.readdir(pluginDir, { withFileTypes: true });
324
+ const converted: ConvertedManual[] = [];
325
+
326
+ const convertOrMove = async (abs: string, name: string, note: string): Promise<void> => {
327
+ const manual = await asMcpManual(abs, `${PLUGINS_DIR}/${folderName}/${name}`);
328
+ if (manual !== null && typeof manual !== 'string') {
329
+ // QUEUED for conversion — the `.tool` is deleted only AFTER its entry
330
+ // has actually landed in the output files (see foldIntoPluginFiles).
331
+ // Deleting first left a window where a failed fold stranded the
332
+ // non-portable half (auth headers, variables) with no source to retry
333
+ // from: the file IS the recovery path until the fold succeeds.
334
+ converted.push({ manual, abs, note });
335
+ return;
336
+ }
337
+ if (typeof manual === 'string') {
338
+ // The operator's answer to "why is this integration not in mcp.json":
339
+ // a refused conversion must read as the deliberate retention it is.
340
+ notes.push(`${folderName}: ${note} NOT converted — ${manual}; kept as a .tool`);
341
+ }
342
+ const dest = path.join(pluginDir, ...HEXIS_TOOLS_DIR.split('/'), path.basename(abs));
343
+ if (abs !== dest && (await moveIfAbsent(abs, dest))) {
344
+ notes.push(`${folderName}: ${note} → ${HEXIS_TOOLS_DIR}/${path.basename(abs)}`);
345
+ changed = true;
346
+ }
347
+ };
348
+
349
+ for (const entry of entries) {
350
+ if (entry.name.startsWith('.')) continue;
351
+ if (entry.name === PLUGIN_SKILLS_DIR || entry.name === PLUGIN_MANIFEST_FILE) continue;
352
+ if (entry.name === PLUGIN_MCP_FILE || entry.name === 'access.md') continue;
353
+ if (entry.name === HEXIS_TOOLS_DIR.split('/')[0]) continue;
354
+
355
+ const abs = path.join(pluginDir, entry.name);
356
+
357
+ // A skill is a folder carrying SKILL.md — the same rule the catalog uses.
358
+ if (entry.isDirectory() && (await exists(path.join(abs, 'SKILL.md')))) {
359
+ const dest = path.join(pluginDir, PLUGIN_SKILLS_DIR, entry.name);
360
+ if (await moveIfAbsent(abs, dest)) {
361
+ notes.push(`${folderName}: ${entry.name}/ → ${PLUGIN_SKILLS_DIR}/${entry.name}/`);
362
+ changed = true;
363
+ }
364
+ continue;
365
+ }
366
+
367
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.tool')) {
368
+ await convertOrMove(abs, entry.name, entry.name);
369
+ }
370
+ }
371
+
372
+ // Second sweep: mcp `.tool`s an EARLIER run moved into the extension dir
373
+ // (when mcp.json was a projection, not the authority). Converting them here
374
+ // is what makes the migration complete itself rather than strand a twin.
375
+ // `isDir` is lstat-based, so a SYMLINK planted at this path is not swept:
376
+ // this sweep DELETES what it converts, and following a link would delete
377
+ // `.tool` files from wherever the link really points.
378
+ const extToolsDir = path.join(pluginDir, ...HEXIS_TOOLS_DIR.split('/'));
379
+ if (await isDir(extToolsDir)) {
380
+ for (const entry of await fs.readdir(extToolsDir, { withFileTypes: true })) {
381
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.tool')) continue;
382
+ const abs = path.join(extToolsDir, entry.name);
383
+ const manual = await asMcpManual(abs, `${PLUGINS_DIR}/${folderName}/${HEXIS_TOOLS_DIR}/${entry.name}`);
384
+ // A refusal reason here is a SETTLED resident of the tools dir — it
385
+ // was named the run it moved in, and this sweep repeats every boot,
386
+ // so re-noting it would be log spam. Only a convertible manual queues.
387
+ if (manual !== null && typeof manual !== 'string') {
388
+ converted.push({ manual, abs, note: `${HEXIS_TOOLS_DIR}/${entry.name}` });
389
+ }
390
+ }
391
+ }
392
+
393
+ changed = (await foldIntoPluginFiles(pluginDir, folderName, converted, notes)) || changed;
394
+ return { notes, changed };
395
+ }
396
+
397
+ /**
398
+ * Rewrite the KB's `.bevelignore` rule for the renamed root: the exact line
399
+ * `Groups/` becomes `Plugins/`. Without this a migrated KB is left with a
400
+ * stale rule for a folder that no longer exists and NO rule for the new one,
401
+ * so plugin internals start showing up in the file tree and agent view.
402
+ *
403
+ * The file is the operator's — this touches ONE line, the one the platform's
404
+ * own rename invalidated, and only when `Plugins/` is not already listed
405
+ * (in which case the stale line is harmlessly dead and left alone).
406
+ */
407
+ async function rewriteIgnoreRootRule(repoDir: string, notes: string[]): Promise<boolean> {
408
+ const ignorePath = path.join(repoDir, IGNORE_FILENAME);
409
+ let current: string;
410
+ try {
411
+ current = await fs.readFile(ignorePath, 'utf-8');
412
+ } catch {
413
+ return false; // no ignore file — nothing went stale
414
+ }
415
+ const legacyRule = `${LEGACY_GROUPS_DIR}/`;
416
+ const newRule = `${PLUGINS_DIR}/`;
417
+ const lines = current.split('\n');
418
+ if (lines.some((l) => l.trim() === newRule)) return false;
419
+ const idx = lines.findIndex((l) => l.trim() === legacyRule);
420
+ if (idx === -1) return false;
421
+ lines[idx] = newRule;
422
+ await fs.writeFile(ignorePath, lines.join('\n'), 'utf-8');
423
+ notes.push(`${IGNORE_FILENAME}: ${legacyRule} → ${newRule}`);
424
+ return true;
425
+ }
426
+
427
+ /**
428
+ * Migrate `repoDir` in place. Safe to call on every top-up.
429
+ *
430
+ * Returns `migrated: false` when no FILE changed — which is the steady state,
431
+ * so the caller commits nothing. Advisory `notes` may still be present (a
432
+ * manual that refuses to convert reports itself every run) and set nothing.
433
+ */
434
+ export async function migrateGroupsToPlugins(repoDir: string): Promise<PluginsMigrationResult> {
435
+ const legacyDir = path.join(repoDir, LEGACY_GROUPS_DIR);
436
+ const pluginsDir = path.join(repoDir, PLUGINS_DIR);
437
+ const notes: string[] = [];
438
+
439
+ const hasLegacy = await isDir(legacyDir);
440
+ const hasPlugins = await isDir(pluginsDir);
441
+
442
+ if (hasLegacy && hasPlugins) {
443
+ // Both present: somebody is mid-migration by hand, or two branches merged
444
+ // badly. Merging them here would guess at which copy of a same-named
445
+ // plugin wins, so we refuse and say so — loudly, because the KB is in a
446
+ // state a human needs to look at.
447
+ console.warn(
448
+ `[plugins-migration] both ${LEGACY_GROUPS_DIR}/ and ${PLUGINS_DIR}/ exist — leaving both alone. ` +
449
+ `Merge ${LEGACY_GROUPS_DIR}/ into ${PLUGINS_DIR}/ by hand; nothing is being migrated automatically.`,
450
+ );
451
+ return { migrated: false, renamed: false, ignoreRewritten: false, notes };
452
+ }
453
+
454
+ let renamed = false;
455
+ let ignoreRewritten = false;
456
+ if (hasLegacy) {
457
+ await fs.rename(legacyDir, pluginsDir);
458
+ renamed = true;
459
+ notes.push(`${LEGACY_GROUPS_DIR}/ → ${PLUGINS_DIR}/`);
460
+ // Rides WITH the rename, not on every run: the rename is what turned the
461
+ // ignore rule stale, so the run that renames is the run that heals it.
462
+ ignoreRewritten = await rewriteIgnoreRootRule(repoDir, notes);
463
+ } else if (!hasPlugins) {
464
+ return { migrated: false, renamed: false, ignoreRewritten: false, notes };
465
+ }
466
+
467
+ // Runs whether or not the rename just happened, so a KB already on
468
+ // `Plugins/` still gets missing manifests and any half-done reorganisation
469
+ // finished. That is what makes this idempotent rather than once-only.
470
+ let changed = renamed || ignoreRewritten;
471
+ for (const entry of await fs.readdir(pluginsDir, { withFileTypes: true })) {
472
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
473
+ const folder = await migratePluginFolder(path.join(pluginsDir, entry.name), entry.name);
474
+ notes.push(...folder.notes);
475
+ changed = changed || folder.changed;
476
+ }
477
+
478
+ return { migrated: changed, renamed, ignoreRewritten, notes };
479
+ }
@@ -1,25 +1,25 @@
1
- import { randomUUID } from 'node:crypto';
2
-
3
- /**
4
- * Port behind `start_session` (workspace.tools.ts): mints the session id an
5
- * external agent scopes its ontology boundary under. The workspace module owns
6
- * the port; WHAT backs the id is the composition root's choice:
7
- *
8
- * - Core default ({@link UuidSessionSink}): a bare random id — sufficient for
9
- * the session-ontology gate, with no app-side session record.
10
- * - The enterprise app substitutes a sink that mints a REAL chat thread, so the
11
- * same id also works end to end with `ask` (its sessionId IS a chat thread).
12
- * See the `start_session` comment in workspace.tools.ts for why that
13
- * unification matters.
14
- */
15
- export interface ISessionSink {
16
- /** Mint a session id for an external agent run started at `startedAt`. */
17
- createSession(userId: string, startedAt: Date): Promise<{ sessionId: string }>;
18
- }
19
-
20
- /** Core default: a bare random id — no app-side session record. */
21
- export class UuidSessionSink implements ISessionSink {
22
- async createSession(): Promise<{ sessionId: string }> {
23
- return { sessionId: randomUUID() };
24
- }
25
- }
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * Port behind `start_session` (workspace.tools.ts): mints the session id an
5
+ * external agent scopes its ontology boundary under. The workspace module owns
6
+ * the port; WHAT backs the id is the composition root's choice:
7
+ *
8
+ * - Core default ({@link UuidSessionSink}): a bare random id — sufficient for
9
+ * the session-ontology gate, with no app-side session record.
10
+ * - The enterprise app substitutes a sink that mints a REAL chat thread, so the
11
+ * same id also works end to end with `ask` (its sessionId IS a chat thread).
12
+ * See the `start_session` comment in workspace.tools.ts for why that
13
+ * unification matters.
14
+ */
15
+ export interface ISessionSink {
16
+ /** Mint a session id for an external agent run started at `startedAt`. */
17
+ createSession(userId: string, startedAt: Date): Promise<{ sessionId: string }>;
18
+ }
19
+
20
+ /** Core default: a bare random id — no app-side session record. */
21
+ export class UuidSessionSink implements ISessionSink {
22
+ async createSession(): Promise<{ sessionId: string }> {
23
+ return { sessionId: randomUUID() };
24
+ }
25
+ }
@@ -11,7 +11,7 @@ import {
11
11
  KNOWLEDGE_BASE_DIR,
12
12
  KNOWLEDGE_DIR,
13
13
  PIPELINES_DIR,
14
- GROUPS_DIR,
14
+ PLUGINS_DIR,
15
15
  } from '@bevel-software/platform-shared';
16
16
  import { branchForWorkspaceId, FolderTooLargeError, type ReadTreeFilter } from './workspace.service.js';
17
17
  import type { WorkspaceService } from './workspace.service.js';
@@ -465,7 +465,7 @@ export function createWorkspaceRoutes(
465
465
  // The structural top-level folders are always shown as folders, even to
466
466
  // a user who can't read into them. Their existence isn't sensitive
467
467
  // (every KB has them), and keeping them visible lets the explorer render
468
- // its Knowledge/Groups section view instead of collapsing to an empty
468
+ // its Knowledge/Plugins section view instead of collapsing to an empty
469
469
  // flat tree. A ROOT-LEVEL `Knowledge/` is the legacy pre-split layout's
470
470
  // knowledge root (kb-layout.ts calls it the neutral bucket) and gets the
471
471
  // same treatment so legacy clones don't collapse. Only the folders
@@ -476,7 +476,7 @@ export function createWorkspaceRoutes(
476
476
  DATA_DIR,
477
477
  AGENTS_DIR,
478
478
  PIPELINES_DIR,
479
- GROUPS_DIR,
479
+ PLUGINS_DIR,
480
480
  KNOWLEDGE_DIR,
481
481
  ]);
482
482
  for (const wp of wsRelPaths) {