@bevel-software/platform-core-backend 0.8.0 → 0.9.1

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 (86) hide show
  1. package/dist/core/core-ports.d.ts +7 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +24 -11
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -2
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +39 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access.routes.d.ts +3 -1
  12. package/dist/modules/access/access.routes.d.ts.map +1 -1
  13. package/dist/modules/access/access.routes.js +4 -2
  14. package/dist/modules/access/access.routes.js.map +1 -1
  15. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  16. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  17. package/dist/modules/access/render-roles-yaml.js +56 -0
  18. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  19. package/dist/modules/access/roles-admin.service.d.ts +15 -1
  20. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  21. package/dist/modules/access/roles-admin.service.js +30 -15
  22. package/dist/modules/access/roles-admin.service.js.map +1 -1
  23. package/dist/modules/settings/setup.routes.d.ts +10 -1
  24. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  25. package/dist/modules/settings/setup.routes.js +111 -6
  26. package/dist/modules/settings/setup.routes.js.map +1 -1
  27. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  28. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  29. package/dist/modules/workspace/startup/kb-git.js +86 -0
  30. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  31. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  32. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  33. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  34. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  35. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  36. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  37. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  38. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  39. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  40. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  41. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  42. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  43. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  44. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  45. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  46. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  47. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  48. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  49. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  50. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  51. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  52. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  53. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  54. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  55. package/dist/modules/workspace/workspace.service.d.ts +0 -35
  56. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  57. package/dist/modules/workspace/workspace.service.js +2 -101
  58. package/dist/modules/workspace/workspace.service.js.map +1 -1
  59. package/kb-template/AGENTS.md +11 -5
  60. package/kb-template/access.md +40 -30
  61. package/kb-template/gitignore.template +16 -0
  62. package/package.json +3 -3
  63. package/src/core/core-ports.ts +7 -0
  64. package/src/core/create-core-server.ts +30 -11
  65. package/src/core/create-core-services.ts +43 -22
  66. package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
  67. package/src/modules/access/access.routes.ts +3 -0
  68. package/src/modules/access/render-roles-yaml.ts +65 -0
  69. package/src/modules/access/roles-admin.service.ts +32 -14
  70. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  71. package/src/modules/settings/setup.routes.ts +112 -5
  72. package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
  73. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  74. package/src/modules/workspace/startup/kb-git.ts +94 -0
  75. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  76. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  77. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  78. package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
  79. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  80. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  81. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  82. package/src/modules/workspace/workspace.service.ts +2 -106
  83. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
  84. package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
  85. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  86. package/src/modules/workspace/kb-seed.service.ts +0 -584
@@ -11,19 +11,24 @@ import {
11
11
  PLUGIN_SKILLS_DIR,
12
12
  renderPluginManifest,
13
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';
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
+ import type { KbBranch, OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
19
20
 
20
21
  /**
21
22
  * 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
+ * layout under `Plugins/` (https://agent-plugins.org, v1.0.0), as an
24
+ * {@link OnServerStart} step. (It began life as an in-place module run from
25
+ * the lazy top-up; that path is gone, and this is the only form.) Steps
26
+ * never write: every change is DECLARED on the branch handle
27
+ * (`move`/`write`/`remove`) and the runner applies it, while every read goes
28
+ * against the real, pre-step tree via `repoDir()`.
23
29
  *
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.
30
+ * Idempotent: a KB already on the new layout is untouched, and a half-finished
31
+ * run is completed by the next one.
27
32
  *
28
33
  * What moves — and what CONVERTS:
29
34
  *
@@ -43,21 +48,26 @@ import { IGNORE_FILENAME } from './bevel-ignore.js';
43
48
  * `extensions["software.bevel.hexis"].mcpServers[<id>]` block, the reverse-DNS
44
49
  * namespace the spec reserves for client-specific data. `http`/`inline`
45
50
  * manuals still MOVE as `.tool` files: nothing but this platform can run them.
51
+ *
52
+ * Scope: EVERY branch, drafts included and writable (`ctx.allBranches()`) —
53
+ * maintenance applied uniformly at the quiet moment is what keeps a draft's
54
+ * change-request diff down to the user's own changes; a stale draft against a
55
+ * migrated target would diff by the whole rename.
56
+ *
57
+ * Outcomes: a per-manual NOT-converted refusal makes the step `partial` with
58
+ * the reasons (the declared ops for everything else still apply); a branch
59
+ * carrying BOTH roots contributes nothing but a note; everything else is `ok`.
46
60
  */
61
+ export class GroupsToPluginsStep implements OnServerStart {
62
+ readonly name = 'groups-to-plugins';
47
63
 
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[];
64
+ async run(ctx: ServerStartContext): Promise<StepResult> {
65
+ const refusals: string[] = [];
66
+ for (const branch of await ctx.allBranches()) {
67
+ await migrateBranch(branch, refusals);
68
+ }
69
+ return refusals.length > 0 ? { outcome: 'partial', reason: refusals.join('; ') } : { outcome: 'ok' };
70
+ }
61
71
  }
62
72
 
63
73
  // lstat, both helpers: SYMLINKS ARE NOT SUPPORTED IN PLUGINS, anywhere, so a
@@ -81,18 +91,6 @@ async function isDir(p: string): Promise<boolean> {
81
91
  }
82
92
  }
83
93
 
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
94
  /**
97
95
  * A header value referencing a vault variable rather than carrying a literal.
98
96
  * The substitutor's own grammar decides (shared/variable-refs.ts): both
@@ -129,126 +127,6 @@ async function readJson(p: string): Promise<Record<string, unknown> | null> {
129
127
  }
130
128
  }
131
129
 
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
130
  /**
253
131
  * Parse a `.tool`; a convertible MCP manual (has a url) parses to a
254
132
  * descriptor. A non-candidate (other types, a file that will not parse)
@@ -302,46 +180,195 @@ async function asMcpManual(abs: string, repoRel: string): Promise<ToolManualDesc
302
180
  }
303
181
  }
304
182
 
305
- /** Reorganise ONE plugin folder in place. Returns notes + whether files changed. */
183
+ interface ConvertedManual {
184
+ manual: ToolManualDescriptor;
185
+ /** Repo-relative path of the source `.tool`, removed only once the fold has been declared. */
186
+ rel: string;
187
+ note: string;
188
+ }
189
+
190
+ /**
191
+ * Migrate one branch. Declares ops only; a branch whose migration declares
192
+ * nothing stays clean, so a notes-only pass (advisory refusals) commits
193
+ * nothing — the same contract the in-place migration's `migrated` flag
194
+ * carried.
195
+ */
196
+ async function migrateBranch(branch: KbBranch, refusals: string[]): Promise<void> {
197
+ const repoDir = await branch.repoDir();
198
+ const legacyDir = path.join(repoDir, LEGACY_GROUPS_DIR);
199
+ const pluginsDir = path.join(repoDir, PLUGINS_DIR);
200
+
201
+ const hasLegacy = await isDir(legacyDir);
202
+ const hasPlugins = await isDir(pluginsDir);
203
+
204
+ // `hasPlugins` is false for a FILE or SYMLINK squatting the `Plugins` name.
205
+ // Checked BEFORE the nothing-to-do early return below: a branch with a
206
+ // squatter and NO Groups/ would otherwise be silently skipped (and on a
207
+ // draft nothing later ever reports it), while a branch mid-migration would
208
+ // die in the applier with a bare ENOTDIR. Throw the actionable version
209
+ // either way: under the phase's fail-closed contract this stops the boot,
210
+ // which a squatted reserved root deserves (same stance as
211
+ // template-files.step.ts's reserved-dir check).
212
+ if (!hasPlugins && (await exists(pluginsDir))) {
213
+ throw new Error(
214
+ `Branch "${branch.name}": "${PLUGINS_DIR}" exists but is not a directory` +
215
+ (hasLegacy ? `, so ${LEGACY_GROUPS_DIR}/ cannot be renamed to ${PLUGINS_DIR}/` : '') +
216
+ `. Remove or rename the "${PLUGINS_DIR}" entry — the platform requires this name to be a folder.`,
217
+ );
218
+ }
219
+
220
+ if (hasLegacy && hasPlugins) {
221
+ // Both present: somebody is mid-migration by hand, or two branches merged
222
+ // badly. Merging them here would guess at which copy of a same-named
223
+ // plugin wins, so we refuse and say so — loudly, because the KB is in a
224
+ // state a human needs to look at. The branch contributes no ops, only a
225
+ // note (which surfaces in a commit only if a later step dirties it).
226
+ console.warn(
227
+ `[groups-to-plugins] ${branch.name}: both ${LEGACY_GROUPS_DIR}/ and ${PLUGINS_DIR}/ exist — leaving both alone. ` +
228
+ `Merge ${LEGACY_GROUPS_DIR}/ into ${PLUGINS_DIR}/ by hand; nothing is being migrated automatically.`,
229
+ );
230
+ branch.note(
231
+ `${LEGACY_GROUPS_DIR}/ and ${PLUGINS_DIR}/ both exist — merge by hand; nothing was migrated automatically`,
232
+ );
233
+ return;
234
+ }
235
+ if (!hasLegacy && !hasPlugins) return;
236
+
237
+ // Every read below goes against the PRE-STEP tree: when the root rename is
238
+ // declared this run it is NOT yet on disk, so the plugin folders are still
239
+ // read under their legacy root while every declared op speaks post-rename
240
+ // `Plugins/…` paths — the root move is declared FIRST, so by the time the
241
+ // per-folder ops apply, the tree is already under `Plugins/`.
242
+ const rootOnDisk = hasLegacy ? legacyDir : pluginsDir;
243
+ // Detail notes are held back until we know ops were declared: the subject
244
+ // line must describe a commit that will actually exist.
245
+ const details: string[] = [];
246
+ let changed = false;
247
+
248
+ if (hasLegacy) {
249
+ // The Groups→Plugins root rename is ONE declared op, directory and all.
250
+ branch.move(LEGACY_GROUPS_DIR, PLUGINS_DIR);
251
+ details.push(`${LEGACY_GROUPS_DIR}/ → ${PLUGINS_DIR}/`);
252
+ changed = true;
253
+ // Rides WITH the rename, not on every run: the rename is what turned the
254
+ // ignore rule stale, so the run that renames is the run that heals it.
255
+ changed = (await rewriteIgnoreRootRule(repoDir, branch, details)) || changed;
256
+ }
257
+
258
+ // Runs whether or not the rename just happened, so a KB already on
259
+ // `Plugins/` still gets missing manifests and any half-done reorganisation
260
+ // finished. That is what makes this idempotent rather than once-only.
261
+ for (const entry of await fs.readdir(rootOnDisk, { withFileTypes: true })) {
262
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
263
+ const folderChanged = await migratePluginFolder(
264
+ branch,
265
+ path.join(rootOnDisk, entry.name),
266
+ entry.name,
267
+ details,
268
+ refusals,
269
+ );
270
+ changed = changed || folderChanged;
271
+ }
272
+
273
+ if (!changed) return;
274
+ // First note becomes the commit subject — the same messages the lazy
275
+ // top-up committed under; the detail notes become its body.
276
+ branch.note(
277
+ hasLegacy
278
+ ? `Move ${LEGACY_GROUPS_DIR}/ to ${PLUGINS_DIR}/ (Agent Plugins layout)`
279
+ : `Reorganise ${PLUGINS_DIR}/ to the Agent Plugins layout`,
280
+ );
281
+ for (const line of details) branch.note(line);
282
+ }
283
+
284
+ /**
285
+ * Rewrite the KB's `.bevelignore` rule for the renamed root: the exact line
286
+ * `Groups/` becomes `Plugins/`. Without this a migrated KB is left with a
287
+ * stale rule for a folder that no longer exists and NO rule for the new one,
288
+ * so plugin internals start showing up in the file tree and agent view.
289
+ *
290
+ * The file is the operator's — this touches ONE line, the one the platform's
291
+ * own rename invalidated, and only when `Plugins/` is not already listed
292
+ * (in which case the stale line is harmlessly dead and left alone).
293
+ */
294
+ async function rewriteIgnoreRootRule(repoDir: string, branch: KbBranch, details: string[]): Promise<boolean> {
295
+ let current: string;
296
+ try {
297
+ current = await fs.readFile(path.join(repoDir, IGNORE_FILENAME), 'utf-8');
298
+ } catch {
299
+ return false; // no ignore file — nothing went stale
300
+ }
301
+ const legacyRule = `${LEGACY_GROUPS_DIR}/`;
302
+ const newRule = `${PLUGINS_DIR}/`;
303
+ const lines = current.split('\n');
304
+ if (lines.some((l) => l.trim() === newRule)) return false;
305
+ const idx = lines.findIndex((l) => l.trim() === legacyRule);
306
+ if (idx === -1) return false;
307
+ // EVERY exact-match line follows the rename: the first becomes the new
308
+ // rule, any further duplicates are dropped — rewriting only the first would
309
+ // leave stale `Groups/` lines behind, and the already-has-Plugins guard
310
+ // above means a second pass would never touch them.
311
+ lines[idx] = newRule;
312
+ const rewritten = lines.filter((l, i) => i <= idx || l.trim() !== legacyRule);
313
+ branch.write(IGNORE_FILENAME, rewritten.join('\n'));
314
+ details.push(`${IGNORE_FILENAME}: ${legacyRule} → ${newRule}`);
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Reorganise ONE plugin folder. `folderDir` is where the folder sits ON DISK
320
+ * (under the legacy root when the rename is buffered this run); declared op
321
+ * paths always speak `Plugins/<folder>/…`. Returns whether ops were declared.
322
+ */
306
323
  async function migratePluginFolder(
307
- pluginDir: string,
324
+ branch: KbBranch,
325
+ folderDir: string,
308
326
  folderName: string,
309
- ): Promise<{ notes: string[]; changed: boolean }> {
310
- const notes: string[] = [];
327
+ details: string[],
328
+ refusals: string[],
329
+ ): Promise<boolean> {
330
+ const relPlugin = `${PLUGINS_DIR}/${folderName}`;
311
331
  let changed = false;
312
332
 
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}`);
333
+ // When the manifest is written this run its content is remembered: the
334
+ // buffered write is not on disk yet, and the fold below must see it.
335
+ let renderedManifest: string | null = null;
336
+ if (!(await exists(path.join(folderDir, PLUGIN_MANIFEST_FILE)))) {
337
+ renderedManifest = renderPluginManifest(folderName);
338
+ branch.write(`${relPlugin}/${PLUGIN_MANIFEST_FILE}`, renderedManifest);
339
+ details.push(`${folderName}: wrote ${PLUGIN_MANIFEST_FILE}`);
320
340
  changed = true;
321
341
  }
322
342
 
323
- const entries = await fs.readdir(pluginDir, { withFileTypes: true });
343
+ const entries = await fs.readdir(folderDir, { withFileTypes: true });
324
344
  const converted: ConvertedManual[] = [];
325
345
 
326
- const convertOrMove = async (abs: string, name: string, note: string): Promise<void> => {
327
- const manual = await asMcpManual(abs, `${PLUGINS_DIR}/${folderName}/${name}`);
346
+ const convertOrMove = async (abs: string, rel: string, note: string): Promise<void> => {
347
+ const manual = await asMcpManual(abs, rel);
328
348
  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 });
349
+ // QUEUED for conversion — the `.tool`'s removal is declared only AFTER
350
+ // its entry's writes (see foldIntoPluginFiles). Buffering makes the
351
+ // apply all-or-nothing anyway, but the declaration order keeps the old
352
+ // recovery reasoning legible: the file IS the recovery path until the
353
+ // fold has landed.
354
+ converted.push({ manual, rel, note });
335
355
  return;
336
356
  }
337
357
  if (typeof manual === 'string') {
338
358
  // 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`);
359
+ // a refused conversion must read as the deliberate retention it is —
360
+ // surfaced through the step's `partial` reason rather than a note, so
361
+ // it names what did NOT change instead of decorating a commit.
362
+ refusals.push(`${branch.name}: ${folderName}: ${note} NOT converted — ${manual}; kept as a .tool`);
341
363
  }
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)}`);
364
+ const destDisk = path.join(folderDir, ...HEXIS_TOOLS_DIR.split('/'), path.basename(abs));
365
+ // Never overwrite: a destination that already exists means a previous run
366
+ // got there first (or a human did), and clobbering it would destroy the
367
+ // newer copy. The check is against the pre-step tree — the only tree that
368
+ // exists while this step runs.
369
+ if (abs !== destDisk && !(await exists(destDisk))) {
370
+ branch.move(rel, `${relPlugin}/${HEXIS_TOOLS_DIR}/${path.basename(abs)}`);
371
+ details.push(`${folderName}: ${note} → ${HEXIS_TOOLS_DIR}/${path.basename(abs)}`);
345
372
  changed = true;
346
373
  }
347
374
  };
@@ -352,20 +379,20 @@ async function migratePluginFolder(
352
379
  if (entry.name === PLUGIN_MCP_FILE || entry.name === 'access.md') continue;
353
380
  if (entry.name === HEXIS_TOOLS_DIR.split('/')[0]) continue;
354
381
 
355
- const abs = path.join(pluginDir, entry.name);
382
+ const abs = path.join(folderDir, entry.name);
356
383
 
357
384
  // A skill is a folder carrying SKILL.md — the same rule the catalog uses.
358
385
  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}/`);
386
+ if (!(await exists(path.join(folderDir, PLUGIN_SKILLS_DIR, entry.name)))) {
387
+ branch.move(`${relPlugin}/${entry.name}`, `${relPlugin}/${PLUGIN_SKILLS_DIR}/${entry.name}`);
388
+ details.push(`${folderName}: ${entry.name}/ → ${PLUGIN_SKILLS_DIR}/${entry.name}/`);
362
389
  changed = true;
363
390
  }
364
391
  continue;
365
392
  }
366
393
 
367
394
  if (entry.isFile() && entry.name.toLowerCase().endsWith('.tool')) {
368
- await convertOrMove(abs, entry.name, entry.name);
395
+ await convertOrMove(abs, `${relPlugin}/${entry.name}`, entry.name);
369
396
  }
370
397
  }
371
398
 
@@ -375,105 +402,160 @@ async function migratePluginFolder(
375
402
  // `isDir` is lstat-based, so a SYMLINK planted at this path is not swept:
376
403
  // this sweep DELETES what it converts, and following a link would delete
377
404
  // `.tool` files from wherever the link really points.
378
- const extToolsDir = path.join(pluginDir, ...HEXIS_TOOLS_DIR.split('/'));
405
+ //
406
+ // Under buffered ops this sweep reads the PRE-STEP tree, so a `.tool` the
407
+ // first sweep just declared moved here is not visible — and does not need
408
+ // to be: same-run movables were already converted or refused by the same
409
+ // `asMcpManual` logic above. The sweep exists for files parked by EARLIER
410
+ // runs, which are on disk. (The old in-place code happened to re-see
411
+ // same-run moves and no-op on them; the buffered read makes that a
412
+ // non-event by construction.)
413
+ const extToolsDir = path.join(folderDir, ...HEXIS_TOOLS_DIR.split('/'));
379
414
  if (await isDir(extToolsDir)) {
380
415
  for (const entry of await fs.readdir(extToolsDir, { withFileTypes: true })) {
381
416
  if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.tool')) continue;
382
417
  const abs = path.join(extToolsDir, entry.name);
383
- const manual = await asMcpManual(abs, `${PLUGINS_DIR}/${folderName}/${HEXIS_TOOLS_DIR}/${entry.name}`);
418
+ const rel = `${relPlugin}/${HEXIS_TOOLS_DIR}/${entry.name}`;
419
+ const manual = await asMcpManual(abs, rel);
384
420
  // A refusal reason here is a SETTLED resident of the tools dir — it
385
421
  // 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.
422
+ // so re-raising it would be log spam. Only a convertible manual queues.
387
423
  if (manual !== null && typeof manual !== 'string') {
388
- converted.push({ manual, abs, note: `${HEXIS_TOOLS_DIR}/${entry.name}` });
424
+ converted.push({ manual, rel, note: `${HEXIS_TOOLS_DIR}/${entry.name}` });
389
425
  }
390
426
  }
391
427
  }
392
428
 
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;
429
+ changed =
430
+ (await foldIntoPluginFiles(branch, folderDir, relPlugin, folderName, renderedManifest, converted, details, refusals)) ||
431
+ changed;
432
+ return changed;
425
433
  }
426
434
 
427
435
  /**
428
- * Migrate `repoDir` in place. Safe to call on every top-up.
436
+ * Fold converted mcp manuals into the plugin's mcp.json and plugin.json.
429
437
  *
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.
438
+ * MERGE, never clobber: an entry already present under a manual's key — hand
439
+ * written or from a previous run — wins, because overwriting it would discard
440
+ * the newer intent. The extension block merges the same way. A plugin.json
441
+ * that does not parse costs the extension write (logged), not the migration.
433
442
  */
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[] = [];
443
+ async function foldIntoPluginFiles(
444
+ branch: KbBranch,
445
+ folderDir: string,
446
+ relPlugin: string,
447
+ folderName: string,
448
+ renderedManifest: string | null,
449
+ manuals: ConvertedManual[],
450
+ details: string[],
451
+ refusals: string[],
452
+ ): Promise<boolean> {
453
+ if (manuals.length === 0) return false;
438
454
 
439
- const hasLegacy = await isDir(legacyDir);
440
- const hasPlugins = await isDir(pluginsDir);
455
+ const mcp = (await readJson(path.join(folderDir, PLUGIN_MCP_FILE))) ?? {
456
+ $schema: PLUGIN_MCP_SCHEMA,
457
+ mcpServers: {},
458
+ };
459
+ // An array (or any non-object) here would take property assignments and then
460
+ // drop them at stringify — normalize to an object before merging into it.
461
+ if (typeof mcp.mcpServers !== 'object' || mcp.mcpServers === null || Array.isArray(mcp.mcpServers)) {
462
+ mcp.mcpServers = {};
463
+ }
464
+ const servers = mcp.mcpServers as Record<string, unknown>;
441
465
 
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.
466
+ // The manifest may have been DECLARED this very run — a buffered write not
467
+ // yet on disk. Reading the disk then would see "missing" and refuse
468
+ // conversions the write-then-read in-place code performed, so the step's
469
+ // own rendered content stands in for the tree it is about to produce.
470
+ const manifest =
471
+ renderedManifest !== null
472
+ ? (JSON.parse(renderedManifest) as Record<string, unknown>)
473
+ : await readJson(path.join(folderDir, PLUGIN_MANIFEST_FILE));
474
+ if (manifest === null) {
447
475
  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.`,
476
+ `[groups-to-plugins] ${branch.name}: ${folderName}/${PLUGIN_MANIFEST_FILE} is missing or unparsable — ` +
477
+ 'mcp manuals convert only when they carry NOTHING for the extensions block; any ' +
478
+ 'non-portable half (auth headers, variables, a description, or the local-only flag) ' +
479
+ 'keeps the manual a `.tool` until the manifest is fixed.',
450
480
  );
451
- return { migrated: false, renamed: false, ignoreRewritten: false, notes };
452
481
  }
453
482
 
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
- }
483
+ let wroteMcp = false;
484
+ let wroteManifest = false;
485
+ const folded: ConvertedManual[] = [];
486
+ for (const item of manuals.sort((a, b) => a.manual.name.localeCompare(b.manual.name))) {
487
+ const m = item.manual;
488
+ const { literal, credential } = splitHeaders(m.headers);
489
+ // A manual whose non-portable half (auth headers, variables, local flag)
490
+ // has nowhere to go — the manifest is missing or unparsable — is NOT
491
+ // converted at all: writing only its portable half and deleting the
492
+ // source would silently discard the credential wiring. It stays a
493
+ // `.tool` until the manifest is fixed.
494
+ const extEntry = {
495
+ ...(Object.keys(credential).length > 0 ? { headers: credential } : {}),
496
+ ...(m.variables && m.variables.length > 0 ? { variables: m.variables } : {}),
497
+ ...(typeof m.description === 'string' ? { description: m.description } : {}),
498
+ ...(m.remote === false ? { local: true } : {}),
499
+ };
500
+ if (manifest === null && Object.keys(extEntry).length > 0) {
501
+ refusals.push(
502
+ `${branch.name}: ${folderName}: ${item.note} NOT converted — ${PLUGIN_MANIFEST_FILE} is missing/unparsable ` +
503
+ 'and the manual carries declarations (auth headers, variables, a description, or the ' +
504
+ 'local-only flag) that would be lost; fix the manifest first',
505
+ );
506
+ continue;
507
+ }
508
+ folded.push(item);
509
+ // Own-property check: `in` sees `constructor` and friends on the prototype,
510
+ // which would silently skip a legitimately named server.
511
+ if (!Object.prototype.hasOwnProperty.call(servers, m.name)) {
512
+ servers[m.name] = {
513
+ type: 'streamable-http',
514
+ url: m.url,
515
+ ...(Object.keys(literal).length > 0 ? { headers: literal } : {}),
516
+ };
517
+ wroteMcp = true;
518
+ details.push(`${folderName}: ${m.name} → ${PLUGIN_MCP_FILE}`);
519
+ }
466
520
 
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;
521
+ // The non-portable half: auth headers, variable declarations, local-only.
522
+ if (manifest !== null && Object.keys(extEntry).length > 0) {
523
+ // Normalize each level: a parseable manifest can still carry a string or
524
+ // array where an object belongs, and mutating that would throw mid-run.
525
+ if (typeof manifest.extensions !== 'object' || manifest.extensions === null || Array.isArray(manifest.extensions)) {
526
+ manifest.extensions = {};
527
+ }
528
+ const ext = manifest.extensions as Record<string, unknown>;
529
+ if (typeof ext[HEXIS_EXTENSION_NS] !== 'object' || ext[HEXIS_EXTENSION_NS] === null || Array.isArray(ext[HEXIS_EXTENSION_NS])) {
530
+ ext[HEXIS_EXTENSION_NS] = {};
531
+ }
532
+ const ns = ext[HEXIS_EXTENSION_NS] as Record<string, unknown>;
533
+ if (typeof ns.mcpServers !== 'object' || ns.mcpServers === null || Array.isArray(ns.mcpServers)) {
534
+ ns.mcpServers = {};
535
+ }
536
+ const extServers = ns.mcpServers as Record<string, unknown>;
537
+ if (!Object.prototype.hasOwnProperty.call(extServers, m.name)) {
538
+ extServers[m.name] = extEntry;
539
+ wroteManifest = true;
540
+ }
541
+ }
476
542
  }
477
543
 
478
- return { migrated: changed, renamed, ignoreRewritten, notes };
544
+ if (wroteMcp) {
545
+ branch.write(`${relPlugin}/${PLUGIN_MCP_FILE}`, `${JSON.stringify(mcp, null, 2)}\n`);
546
+ }
547
+ if (wroteManifest) {
548
+ // May supersede the bare manifest declared above — ops apply in order, so
549
+ // the extended content is what lands.
550
+ branch.write(`${relPlugin}/${PLUGIN_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`);
551
+ details.push(`${folderName}: wrote mcp-server declarations into ${PLUGIN_MANIFEST_FILE}`);
552
+ }
553
+ // Sources go LAST in declaration order, once everything they carried has
554
+ // been declared elsewhere — the same shape the in-place code used so a
555
+ // failure never stranded the non-portable half with no source to retry from.
556
+ for (const item of folded) {
557
+ branch.remove(item.rel);
558
+ details.push(`${folderName}: ${item.note} converted to an ${PLUGIN_MCP_FILE} entry`);
559
+ }
560
+ return wroteMcp || wroteManifest || folded.length > 0;
479
561
  }