@bevel-software/platform-shared 0.14.0 → 0.19.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 (73) hide show
  1. package/dist/auth/types.d.ts +12 -0
  2. package/dist/auth/types.d.ts.map +1 -1
  3. package/dist/git/pr.types.d.ts +75 -11
  4. package/dist/git/pr.types.d.ts.map +1 -1
  5. package/dist/git/types.d.ts +75 -2
  6. package/dist/git/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +6 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/workflow/events.d.ts +39 -4
  12. package/dist/workflow/events.d.ts.map +1 -1
  13. package/dist/workflow/events.js.map +1 -1
  14. package/dist/workflow/interface.d.ts +137 -5
  15. package/dist/workflow/interface.d.ts.map +1 -1
  16. package/dist/workflow/types.d.ts +93 -0
  17. package/dist/workflow/types.d.ts.map +1 -1
  18. package/dist/workspace/access-verbs.d.ts +83 -0
  19. package/dist/workspace/access-verbs.d.ts.map +1 -0
  20. package/dist/workspace/access-verbs.js +110 -0
  21. package/dist/workspace/access-verbs.js.map +1 -0
  22. package/dist/workspace/agent-preamble.d.ts +33 -0
  23. package/dist/workspace/agent-preamble.d.ts.map +1 -0
  24. package/dist/workspace/agent-preamble.js +45 -0
  25. package/dist/workspace/agent-preamble.js.map +1 -0
  26. package/dist/workspace/entry-exists.d.ts +27 -0
  27. package/dist/workspace/entry-exists.d.ts.map +1 -0
  28. package/dist/workspace/entry-exists.js +33 -0
  29. package/dist/workspace/entry-exists.js.map +1 -0
  30. package/dist/workspace/filename.d.ts +15 -0
  31. package/dist/workspace/filename.d.ts.map +1 -1
  32. package/dist/workspace/filename.js +37 -3
  33. package/dist/workspace/filename.js.map +1 -1
  34. package/dist/workspace/frontmatter-carriers.d.ts +44 -0
  35. package/dist/workspace/frontmatter-carriers.d.ts.map +1 -0
  36. package/dist/workspace/frontmatter-carriers.js +52 -0
  37. package/dist/workspace/frontmatter-carriers.js.map +1 -0
  38. package/dist/workspace/frontmatter.d.ts +23 -4
  39. package/dist/workspace/frontmatter.d.ts.map +1 -1
  40. package/dist/workspace/frontmatter.js +59 -9
  41. package/dist/workspace/frontmatter.js.map +1 -1
  42. package/dist/workspace/kb-layout.d.ts +365 -19
  43. package/dist/workspace/kb-layout.d.ts.map +1 -1
  44. package/dist/workspace/kb-layout.js +619 -20
  45. package/dist/workspace/kb-layout.js.map +1 -1
  46. package/dist/workspace/placeholder.d.ts +21 -0
  47. package/dist/workspace/placeholder.d.ts.map +1 -0
  48. package/dist/workspace/placeholder.js +28 -0
  49. package/dist/workspace/placeholder.js.map +1 -0
  50. package/dist/workspace/platform-files.d.ts +105 -0
  51. package/dist/workspace/platform-files.d.ts.map +1 -0
  52. package/dist/workspace/platform-files.js +147 -0
  53. package/dist/workspace/platform-files.js.map +1 -0
  54. package/dist/workspace/types.d.ts +8 -0
  55. package/dist/workspace/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/auth/types.ts +12 -0
  58. package/src/git/pr.types.ts +77 -11
  59. package/src/git/types.ts +81 -3
  60. package/src/index.ts +6 -0
  61. package/src/workflow/events.ts +40 -3
  62. package/src/workflow/interface.ts +162 -6
  63. package/src/workflow/types.ts +70 -0
  64. package/src/workspace/access-verbs.ts +124 -0
  65. package/src/workspace/agent-preamble.ts +47 -0
  66. package/src/workspace/entry-exists.ts +36 -0
  67. package/src/workspace/filename.ts +38 -3
  68. package/src/workspace/frontmatter-carriers.ts +57 -0
  69. package/src/workspace/frontmatter.ts +57 -8
  70. package/src/workspace/kb-layout.ts +660 -24
  71. package/src/workspace/placeholder.ts +29 -0
  72. package/src/workspace/platform-files.ts +188 -0
  73. package/src/workspace/types.ts +8 -0
@@ -1,4 +1,6 @@
1
1
  import { branchSegment } from '../git/branchAuthor.js';
2
+ import { PREAMBLE_FILE } from './agent-preamble.js';
3
+ import { validateFilename } from './filename.js';
2
4
 
3
5
  /**
4
6
  * Top-level layout of the KB repo (inside the `KB_DIR_NAME` clone).
@@ -8,7 +10,8 @@ import { branchSegment } from '../git/branchAuthor.js';
8
10
  *
9
11
  * <kbDirName>/
10
12
  * ├── KnowledgeBase/ ← all team ontologies live here (the knowledge graph)
11
- * ├── Plugins/ ← one folder per plugin; each holds BOTH skills and tools
13
+ * ├── Skills/ ← shared skills, organised by ownership; plugins LINK to them
14
+ * ├── Plugins/ ← one folder per plugin: manifest, MCP servers, tools, links
12
15
  * ├── Data/ ← agent-produced records; parsed like KnowledgeBase/
13
16
  * ├── Agents/ ← .agent files — agent role configurations (not the graph)
14
17
  * ├── Pipelines/ ← .pipeline files — execution-layer processes (not the graph)
@@ -24,23 +27,47 @@ import { branchSegment } from '../git/branchAuthor.js';
24
27
  *
25
28
  * These names are the single source of truth for both sides of the app:
26
29
  * - Backend: the graph parser discovers ontologies under the
27
- * {@link ONTOLOGY_ROOTS} (`KnowledgeBase/` and `Data/`); `Plugins/`,
30
+ * {@link ontologyRoots} (`KnowledgeBase/` and `Data/`); `Plugins/`,
28
31
  * `Agents/`, `Pipelines/` (and anything else at the root) are ignored by
29
32
  * parsing, validation, and the diagram.
30
33
  * - Frontend: the file tree renders these root folders as distinct
31
34
  * top-level sections.
32
35
  *
33
36
  * Don't hard-code these strings elsewhere — import them from here.
37
+ *
38
+ * CONFIGURABLE, WITH DEFAULTS. The three roots a deployment may rename
39
+ * (`KnowledgeBase/`, `Skills/`, `Plugins/`) are `let` bindings applied by
40
+ * {@link configureKbLayout} — the backend from its deployment settings, the
41
+ * browser from `GET /api/config` — the same live-binding pattern as the branch
42
+ * model in `git/protected.ts`. Unlike the branch model they carry defaults, so
43
+ * nothing has to wait for configuration; but the same rule applies: read them
44
+ * inside a function body, never capture one at module scope.
34
45
  */
35
46
 
36
47
  /** Folder under the repo root that contains all team ontologies. */
37
- export const KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
48
+ export let KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
49
+
50
+ /**
51
+ * Folder under the repo root that holds SHARED skills, organised by ownership:
52
+ *
53
+ * Skills/<scope>/…/<skill>/SKILL.md a skill, at any depth
54
+ * Skills/<scope>/access.md who owns / may read the scope
55
+ *
56
+ * A skill's readability comes from ITS OWN path walk — the scope folders'
57
+ * `access.md` files — never from the plugins that link it. Plugins point at
58
+ * skills here by path (see `HEXIS_LINKED_SKILLS_KEY`), so one definition can
59
+ * ship in several plugins, and a skill in no plugin at all is a normal state.
60
+ * Inline skills under `Plugins/<Plugin>/skills/` remain supported (personal
61
+ * folders, legacy layouts); the catalog is the union of both trees.
62
+ */
63
+ export let SKILLS_DIR = 'Skills';
38
64
 
39
65
  /**
40
66
  * Folder under the repo root that holds the plugins.
41
67
  *
42
- * Plugins/<Plugin>/plugin.json the Agent Plugins manifest
43
- * Plugins/<Plugin>/skills/<skill>/SKILL.md a skill
68
+ * Plugins/<Plugin>/plugin.json the Agent Plugins manifest; its
69
+ * hexis extension lists LINKED skills
70
+ * Plugins/<Plugin>/skills/<skill>/SKILL.md an inline skill
44
71
  * Plugins/<Plugin>/mcp.json MCP servers
45
72
  * Plugins/<Plugin>/software.bevel.hexis/tools/ http + inline `.tool` manuals
46
73
  * Plugins/<Plugin>/access.md who can read/write the plugin
@@ -59,10 +86,11 @@ export const KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
59
86
  * `inline` types the spec has no slot for. `mcp`-type manuals are emitted as
60
87
  * real `mcp.json` entries instead, so the portable half stays portable.
61
88
  *
62
- * Skills and tools live TOGETHER in one plugin because they share a single
63
- * access boundary: a tool a plugin cannot read is a skill that plugin cannot
64
- * run, so splitting them across two roots meant maintaining the same permission
65
- * twice and letting them drift.
89
+ * A plugin's own `access.md` governs what the plugin FOLDER holds: the
90
+ * manifest, the MCP servers, the tools, and any inline skills. Shared skills
91
+ * under `Skills/` are governed by their own scope and are made visible to a
92
+ * plugin's members by granting the plugin's principal (`plugin/<Name>/read`)
93
+ * on the skill — ownership decides, the plugin is a view.
66
94
  *
67
95
  * A plugin is not a registry of unique names — it is a folder. The same
68
96
  * integration may exist in several plugins as separate files (`Everyone/…/
@@ -74,7 +102,429 @@ export const KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
74
102
  * display casing. The lowercase slug the spec does constrain lives in the
75
103
  * manifest's `name` field.
76
104
  */
77
- export const PLUGINS_DIR = 'Plugins';
105
+ export let PLUGINS_DIR = 'Plugins';
106
+
107
+ /**
108
+ * The file name of the platform's MANAGED agent guide at the repository root —
109
+ * the document every connected agent is told to read first, written and
110
+ * refreshed from the packaged template on every start.
111
+ *
112
+ * Configurable for one reason: `AGENTS.md` is the name coding agents look for
113
+ * by convention, so a customer arriving with a repository of their own very
114
+ * often already HAS one, and under the default name the platform would
115
+ * overwrite it on the first boot and on every boot after. Renaming the managed
116
+ * guide (`HEXIS.md`, say) hands that name back: `AGENTS.md` becomes ordinary
117
+ * content the platform never writes, never refreshes and never hides, and the
118
+ * customer's file is the one that points at ours (see
119
+ * {@link agentsFilePointerSentence}).
120
+ *
121
+ * A live binding like the three roots above — read it inside a function body,
122
+ * never capture it at module scope.
123
+ */
124
+ export let AGENTS_FILE = 'AGENTS.md';
125
+
126
+ /**
127
+ * The name the managed guide had when it was the only name it could have.
128
+ *
129
+ * Referenced ONLY by the code that has to tell OUR file from THEIRS — the
130
+ * boot-time removal of a platform-written `AGENTS.md`, the ignore rule that
131
+ * stops hiding it, the instruction telling an agent to read the customer's
132
+ * file too. In the spirit of {@link LEGACY_GROUPS_DIR}: a second live spelling
133
+ * of the CURRENT name is how two layouts start being supported by accident, so
134
+ * this one is a constant and means exactly one thing.
135
+ */
136
+ export const LEGACY_AGENTS_FILE = 'AGENTS.md';
137
+
138
+ /**
139
+ * The platform files whose names are FIXED — the ones no deployment renames.
140
+ * The guide is the fourth platform file and is deliberately absent here: its
141
+ * name is {@link AGENTS_FILE}, and `platform-files.ts` composes the two into
142
+ * the set every gate reads.
143
+ *
144
+ * It lives in this module rather than beside that composition because the
145
+ * LAYOUT has to validate against it (a guide may not be called `access.md`),
146
+ * and `platform-files.ts` already reads this module — the other direction
147
+ * would be a cycle.
148
+ */
149
+ export const FIXED_PLATFORM_FILE_NAMES: readonly string[] = Object.freeze([
150
+ 'access.md',
151
+ 'roles.yaml',
152
+ '.bevelignore',
153
+ ]);
154
+
155
+ /**
156
+ * The three renameable roots and the guide's file name, as a deployment
157
+ * declares them and `/api/config` serves them.
158
+ */
159
+ export interface KbLayout {
160
+ knowledgeBaseDir: string;
161
+ skillsDir: string;
162
+ pluginsDir: string;
163
+ /**
164
+ * The managed agent guide's file name. OPTIONAL, and deliberately: a layout
165
+ * that predates the setting — an older server's `/api/config` body, a saved
166
+ * deployment that never named one — carries three folders and no guide, and
167
+ * the right answer to that is the right answer to an unset field, which is
168
+ * `AGENTS.md`. Read it through {@link agentsFileOf} rather than directly, so
169
+ * "absent" and "the default" can never come to mean two different things.
170
+ */
171
+ agentsFile?: string;
172
+ }
173
+
174
+ /** The layout a deployment gets when it names nothing. */
175
+ export const DEFAULT_KB_LAYOUT: Readonly<Required<KbLayout>> = Object.freeze({
176
+ knowledgeBaseDir: 'KnowledgeBase',
177
+ skillsDir: 'Skills',
178
+ pluginsDir: 'Plugins',
179
+ agentsFile: 'AGENTS.md',
180
+ });
181
+
182
+ /** The guide's name in a layout, with the default standing in for an absent one. */
183
+ export function agentsFileOf(layout: KbLayout): string {
184
+ return (layout.agentsFile ?? '').trim() || DEFAULT_KB_LAYOUT.agentsFile;
185
+ }
186
+
187
+ /**
188
+ * `name` as one literal gitignore pattern. The guide's name is the
189
+ * operator's, and the name rules admit characters gitignore reads as syntax:
190
+ * a leading `#` is a comment and a leading `!` a negation (the rule would
191
+ * silently hide nothing), `[`, `]`, `*` and `?` are globs, and a backslash is
192
+ * the escape itself. Each is escaped so the pattern names exactly the file.
193
+ */
194
+ export function gitignoreLiteral(name: string): string {
195
+ return name.replace(/[[\]*?\\]/g, '\\$&').replace(/^([#!])/, '\\$1');
196
+ }
197
+
198
+ /**
199
+ * The ONE sentence the platform offers to keep in a customer's own
200
+ * `AGENTS.md`, pointing at the managed guide beside it.
201
+ *
202
+ * Defined here, once, because two surfaces must produce the identical text:
203
+ * the deployment-settings field previews it before the admin consents, and the
204
+ * startup step appends it. A sentence written twice is a sentence that drifts,
205
+ * and a drifted one appends a SECOND copy to every customer file on the boot
206
+ * after the drift — which is the one thing this whole feature exists to stop.
207
+ *
208
+ * A guide name is a FILE NAME, not an identifier: everything `validateFilename`
209
+ * admits can appear in it — spaces, brackets, parentheses, `#`, `%` — and each
210
+ * of those means something in an inline link. So the link is BUILT rather than
211
+ * interpolated: the label backslash-escaped ({@link markdownLinkLabel}), the
212
+ * destination percent-encoded ({@link agentsFileLinkPath}). A name that only
213
+ * parenthesised would break the destination; `#` would turn the rest of the
214
+ * name into a URL fragment, and the link would point at the customer's own
215
+ * file.
216
+ *
217
+ * Neither spelling need match the name as it is on disk, so nothing may ask
218
+ * whether this sentence is present by searching for the RAW name — see
219
+ * {@link mentionsAgentsFile}, which is how the startup step asks.
220
+ */
221
+ export function agentsFilePointerSentence(agentsFile: string = AGENTS_FILE): string {
222
+ return `Read [${markdownLinkLabel(agentsFile)}](${agentsFileLinkPath(agentsFile)})${POINTER_SENTENCE_TAIL}`;
223
+ }
224
+
225
+ /**
226
+ * Everything of the sentence that does NOT depend on the guide's name — split
227
+ * out so the one definition above can also be RECOGNISED, by the pattern below,
228
+ * when the name it was written with is no longer the name in effect.
229
+ */
230
+ const POINTER_SENTENCE_TAIL =
231
+ " before working in this knowledge base — it is the platform's guide to its layout, files and rules.";
232
+
233
+ /**
234
+ * A pointer sentence the platform wrote, naming ANY guide.
235
+ *
236
+ * The label admits a backslash escape (`\[`, `\]`) because that is what
237
+ * {@link markdownLinkLabel} puts there; the destination cannot contain a `)`
238
+ * or a newline because {@link agentsFileLinkPath} encodes both. Anchored at
239
+ * both ends by text the platform fixed, so the shape is the provenance —
240
+ * a customer would have to reproduce our sentence word for word to be taken
241
+ * for us, which is the same bar the managed-guide header sets.
242
+ */
243
+ const POINTER_SENTENCE_PATTERN = new RegExp(
244
+ `Read \\[(?:[^\\]\\n\\\\]|\\\\[\\s\\S])*\\]\\(\\.\\/[^)\\n]*\\)${escapeRegExp(POINTER_SENTENCE_TAIL)}`,
245
+ 'g',
246
+ );
247
+
248
+ /** `text` as a literal inside a regular expression. */
249
+ function escapeRegExp(text: string): string {
250
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
251
+ }
252
+
253
+ /**
254
+ * `text` with every pointer sentence THE PLATFORM WROTE aimed at `agentsFile`
255
+ * — or null when it holds none of ours.
256
+ *
257
+ * This is what a SECOND rename needs. The guide's name is a setting an admin
258
+ * may change again: a knowledge base whose `AGENTS.md` was given a sentence
259
+ * pointing at `HEXIS.md` and is then renamed to `GUIDE.md` must have that
260
+ * sentence aimed at the new file, not a second one appended beneath a first
261
+ * that now points at nothing. Asking only whether the NEW name is mentioned
262
+ * cannot see that — the old sentence does not mention it.
263
+ *
264
+ * Returning the text unchanged (rather than null) when the sentence is already
265
+ * right is deliberate: "ours and correct" and "not ours at all" are different
266
+ * answers, and only the caller knows that the second one means "consider
267
+ * appending".
268
+ */
269
+ export function retargetAgentsFilePointer(text: string, agentsFile: string = AGENTS_FILE): string | null {
270
+ let found = false;
271
+ const wanted = agentsFilePointerSentence(agentsFile);
272
+ const updated = text.replace(POINTER_SENTENCE_PATTERN, () => {
273
+ found = true;
274
+ return wanted;
275
+ });
276
+ return found ? updated : null;
277
+ }
278
+
279
+ /**
280
+ * `text` as an inline link's LABEL: the characters that would end the label or
281
+ * start emphasis or code inside it, backslash-escaped. Nothing else is touched
282
+ * — a filename is read by people, and `AGENTS\.md` helps no one.
283
+ */
284
+ function markdownLinkLabel(text: string): string {
285
+ return text.replace(/[\\[\]`*_]/g, (c) => `\\${c}`);
286
+ }
287
+
288
+ /**
289
+ * The guide as an inline link's DESTINATION: `./` and the name, percent-encoded.
290
+ *
291
+ * `encodeURI` does most of it (a space, a bracket, and `%` itself, so an
292
+ * already-encoded-looking name is not decoded by a reader). Three more are
293
+ * encoded by hand because `encodeURI` leaves them and each one ENDS the path
294
+ * early: `#` opens a fragment, and `(`/`)` close the destination in
295
+ * CommonMark's bare form. `?` and the rest of the URL-significant set are
296
+ * already refused by {@link validateFilename}.
297
+ *
298
+ * An ordinary name has none of these and comes out exactly as it went in.
299
+ */
300
+ function agentsFileLinkPath(agentsFile: string): string {
301
+ return `./${encodeURI(agentsFile).replace(/[#()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
302
+ }
303
+
304
+ /**
305
+ * Whether `text` already points at the guide — the ONE question the startup
306
+ * step asks before appending {@link agentsFilePointerSentence} to a customer's
307
+ * own `AGENTS.md`.
308
+ *
309
+ * The plain name is the answer that matters: a mention in the customer's own
310
+ * words, a heading, a link they wrote, all count, and the platform stays out
311
+ * of a file it does not own. The other two spellings are the platform's OWN,
312
+ * and they are here for idempotence: the sentence writes the name escaped in
313
+ * the label and encoded in the destination, so on a punctuated name the file
314
+ * the last boot wrote need not contain the raw name at all. Asking only for
315
+ * that one would append a second copy on the next boot, and a third on the
316
+ * one after — the exact failure this feature exists to prevent.
317
+ */
318
+ export function mentionsAgentsFile(text: string, agentsFile: string = AGENTS_FILE): boolean {
319
+ return (
320
+ text.includes(agentsFile) ||
321
+ text.includes(markdownLinkLabel(agentsFile)) ||
322
+ text.includes(agentsFileLinkPath(agentsFile))
323
+ );
324
+ }
325
+
326
+ /**
327
+ * What is wrong with one root name, or null. A root is joined onto the repo
328
+ * root and onto `<dir>/.gitkeep`, so a separator or `..` would write outside
329
+ * the repository; a dot-prefixed name would be skipped by every scanner that
330
+ * treats dot-entries as bookkeeping; `.git` in any case would corrupt the clone.
331
+ */
332
+ export function validateKbRootName(name: string): string | null {
333
+ const v = name.trim();
334
+ if (!v) return 'A folder name is required.';
335
+ if (v.includes('/') || v.includes('\\')) return 'Use a single folder name — no slashes.';
336
+ // The ONE rule for what a path segment may be called — the same one every
337
+ // file and folder made through the platform passes (reserved Windows
338
+ // names, trailing dots, forbidden characters, length) — plus what a ROOT
339
+ // must not be: dot-prefixed, which every scanner skips as bookkeeping.
340
+ const asName = validateFilename(v);
341
+ if (asName) return asName;
342
+ if (v.startsWith('.')) return 'The name can\'t start with a dot.';
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * What is wrong with the agent guide's file name, or null.
348
+ *
349
+ * The rules, and what each one is for:
350
+ *
351
+ * - ONE FILE NAME. The name is joined onto the repository root and read from
352
+ * there and nowhere else, so a separator would name a file the platform
353
+ * would write but never read back.
354
+ * - A MARKDOWN NAME. The guide is a markdown document that people open in the
355
+ * app and agents read as text; `.md` is also what the per-file access rules
356
+ * apply to, so a guide under any other extension would take its folder's
357
+ * rules and stop being readable by everyone.
358
+ * - NOT `CLAUDE.md`. That is the guide's own pre-rename name; knowledge bases
359
+ * seeded before the rename still carry one, and it stays legacy content
360
+ * rather than becoming a second managed file.
361
+ * - NOT ANOTHER PLATFORM FILE. Two platform roles on one path means whichever
362
+ * writer runs last wins, silently.
363
+ * - NOT A ROOT FOLDER'S NAME, compared case-insensitively like the roots are
364
+ * to each other: the workspaces live on case-insensitive filesystems, where
365
+ * a file `Docs.md` and a folder `docs.md` are one entry.
366
+ *
367
+ * `roots` is the layout the name is judged against — the names this save would
368
+ * put in effect, not necessarily the ones running now.
369
+ */
370
+ export function validateAgentsFileName(
371
+ name: string,
372
+ roots: Pick<KbLayout, 'knowledgeBaseDir' | 'skillsDir' | 'pluginsDir'> = currentKbLayout(),
373
+ ): string | null {
374
+ const v = name.trim();
375
+ if (!v) return 'A file name is required.';
376
+ if (v.includes('/') || v.includes('\\')) return 'Use a single file name — no folders.';
377
+ // The ONE rule for what a path component may be called, as everywhere else.
378
+ const asName = validateFilename(v);
379
+ if (asName) return asName;
380
+ const lower = v.toLowerCase();
381
+ // The taken names come FIRST, so a name that is wrong for a specific reason
382
+ // is refused with that reason rather than with whichever general rule it
383
+ // happens to break as well (`roles.yaml` is not merely 'not markdown').
384
+ if (lower === 'claude.md') {
385
+ return 'CLAUDE.md is the guide\'s pre-rename name and stays reserved for it.';
386
+ }
387
+ for (const reserved of [...FIXED_PLATFORM_FILE_NAMES, PREAMBLE_FILE]) {
388
+ if (lower === reserved.toLowerCase()) return `${reserved} is a platform file name.`;
389
+ }
390
+ // A dot-prefixed name is skipped by every scanner that treats dot-entries as
391
+ // bookkeeping — including the one that would show the guide in the tree.
392
+ if (v.startsWith('.')) return 'The name can\'t start with a dot.';
393
+ if (!v.endsWith('.md')) return 'The name must end in .md.';
394
+ for (const [label, dir] of [
395
+ ['knowledge', roots.knowledgeBaseDir],
396
+ ['skills', roots.skillsDir],
397
+ ['plugins', roots.pluginsDir],
398
+ ] as const) {
399
+ if (lower === (dir ?? '').trim().toLowerCase()) {
400
+ return `That is already the ${label} folder's name.`;
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+
406
+ /**
407
+ * What is wrong with a layout, or null — the same rule {@link configureKbLayout}
408
+ * enforces, without applying anything. Separate so the setup screen can judge a
409
+ * proposed layout before it is saved. The three folder names must differ,
410
+ * compared case-insensitively: the workspaces live on case-insensitive
411
+ * filesystems too, where `Skills` and `skills` are one folder. The guide's
412
+ * file name is judged against all three by the same rule (see
413
+ * {@link validateAgentsFileName}), which makes the four names distinct.
414
+ */
415
+ export function validateKbLayout(layout: KbLayout): string | null {
416
+ for (const [label, value] of [
417
+ ['knowledge base', layout.knowledgeBaseDir],
418
+ ['skills', layout.skillsDir],
419
+ ['plugins', layout.pluginsDir],
420
+ ] as const) {
421
+ const problem = validateKbRootName(value ?? '');
422
+ if (problem) return `The ${label} folder: ${problem}`;
423
+ }
424
+ const names = [layout.knowledgeBaseDir, layout.skillsDir, layout.pluginsDir].map((n) =>
425
+ n.trim().toLowerCase(),
426
+ );
427
+ if (new Set(names).size !== names.length) {
428
+ return 'The knowledge base, skills and plugins folders must have three different names.';
429
+ }
430
+ // The fixed reserved roots are taken too: naming the skills folder `Data`
431
+ // would give one directory two reserved roles.
432
+ const fixed = [DATA_DIR, AGENTS_DIR, PIPELINES_DIR].map((n) => n.toLowerCase());
433
+ const clash = names.find((n) => fixed.includes(n));
434
+ if (clash) return `"${clash}" is a reserved folder name (${[DATA_DIR, AGENTS_DIR, PIPELINES_DIR].join(', ')}).`;
435
+ // Judged against the roots THIS layout declares, not the ones in effect: a
436
+ // save that renames the plugins folder and the guide together must be read
437
+ // as the pair it is.
438
+ const guide = validateAgentsFileName(agentsFileOf(layout), layout);
439
+ if (guide) return `The agent guide's file name: ${guide}`;
440
+ return null;
441
+ }
442
+
443
+ /** Everything that has asked to hear when the layout is applied. */
444
+ const layoutListeners = new Set<() => void>();
445
+
446
+ /**
447
+ * Be told when {@link configureKbLayout} runs — for the few things that cannot
448
+ * read a live binding at the moment they are used.
449
+ *
450
+ * Almost nothing needs this: the roots and the guide's name are `let` bindings
451
+ * read inside function bodies, so code that follows the rule follows the
452
+ * layout for free. The exception is a value BUILT ONCE and handed to something
453
+ * that keeps it — the tool catalog's descriptions, which are validated into
454
+ * frozen-ish defs at registration and then served to agents from a map. Boot
455
+ * applies the layout before they are built, but the save that completes
456
+ * FIRST-RUN SETUP applies it afterwards (see `setup.routes.ts`, which must, so
457
+ * the KB phase that runs in the same request scaffolds the names the admin
458
+ * just chose) — and without this the catalog would go on naming `AGENTS.md`
459
+ * until someone restarted the server.
460
+ *
461
+ * Listeners run in registration order, after the bindings are updated and only
462
+ * when the layout was accepted.
463
+ */
464
+ export function onKbLayoutApplied(listener: () => void): void {
465
+ layoutListeners.add(listener);
466
+ }
467
+
468
+ /**
469
+ * Apply the layout. Called once during boot on each side; throws on an invalid
470
+ * one so a bad deployment setting fails beside the rest of the wiring rather
471
+ * than scattering a half-renamed tree. Applying the defaults is a no-op.
472
+ */
473
+ export function configureKbLayout(layout: KbLayout): void {
474
+ const problem = validateKbLayout(layout);
475
+ if (problem) throw new Error(problem);
476
+ KNOWLEDGE_BASE_DIR = layout.knowledgeBaseDir.trim();
477
+ SKILLS_DIR = layout.skillsDir.trim();
478
+ PLUGINS_DIR = layout.pluginsDir.trim();
479
+ AGENTS_FILE = agentsFileOf(layout);
480
+ for (const listener of layoutListeners) listener();
481
+ }
482
+
483
+ /** The layout currently in effect. */
484
+ export function currentKbLayout(): Required<KbLayout> {
485
+ return {
486
+ knowledgeBaseDir: KNOWLEDGE_BASE_DIR,
487
+ skillsDir: SKILLS_DIR,
488
+ pluginsDir: PLUGINS_DIR,
489
+ agentsFile: AGENTS_FILE,
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Whether a layout — the one in effect, unless one is given — is the default
495
+ * one. The setup-completing save applies the stored names while this holds,
496
+ * the same "only from none to some" rule the branch model follows — and also
497
+ * on a retry after its own failed initialization run, when the process holds
498
+ * names setup applied but the app never opened (see `setup.routes.ts`). A
499
+ * layout the process booted with is never replaced here.
500
+ */
501
+ export function isDefaultKbLayout(layout: KbLayout = currentKbLayout()): boolean {
502
+ return (
503
+ layout.knowledgeBaseDir === DEFAULT_KB_LAYOUT.knowledgeBaseDir &&
504
+ layout.skillsDir === DEFAULT_KB_LAYOUT.skillsDir &&
505
+ layout.pluginsDir === DEFAULT_KB_LAYOUT.pluginsDir &&
506
+ agentsFileOf(layout) === DEFAULT_KB_LAYOUT.agentsFile
507
+ );
508
+ }
509
+
510
+ /**
511
+ * Render the layout placeholders a managed template carries —
512
+ * `{{knowledgeBaseDir}}`, `{{skillsDir}}`, `{{pluginsDir}}`, `{{agentsFile}}`
513
+ * — with the names in effect. The packaged guide and `.bevelignore` are
514
+ * written this way so a deployment that renamed its roots hands the agent a
515
+ * guide that names the folders it will actually find, and a deployment that
516
+ * renamed the guide gets a guide naming the file it lives in. Text without
517
+ * placeholders passes through unchanged.
518
+ */
519
+ export function renderKbLayoutPlaceholders(text: string, layout: KbLayout = currentKbLayout()): string {
520
+ // Replacer FUNCTIONS: a string replacement would interpret `$&`, `$$` and
521
+ // friends inside a folder name, and `$` is a legal character in one.
522
+ return text
523
+ .replaceAll('{{knowledgeBaseDir}}', () => layout.knowledgeBaseDir)
524
+ .replaceAll('{{skillsDir}}', () => layout.skillsDir)
525
+ .replaceAll('{{pluginsDir}}', () => layout.pluginsDir)
526
+ .replaceAll('{{agentsFile}}', () => agentsFileOf(layout));
527
+ }
78
528
 
79
529
  /**
80
530
  * The pre-rename name of {@link PLUGINS_DIR}. Referenced ONLY by the migration
@@ -104,6 +554,99 @@ export const HEXIS_EXTENSION_NS = 'software.bevel.hexis';
104
554
  /** UTCP manuals whose `http`/`inline` types the spec cannot express. */
105
555
  export const HEXIS_TOOLS_DIR = `${HEXIS_EXTENSION_NS}/tools`;
106
556
 
557
+ /**
558
+ * The manifest key under which a plugin LINKS shared skills:
559
+ *
560
+ * plugin.json → extensions["software.bevel.hexis"].skills: [
561
+ * "Skills/Engineering/deploy", ← one skill folder
562
+ * "Skills/Sales" ← a folder of skills: every skill beneath
563
+ * ]
564
+ *
565
+ * Entries are repo-root-relative folder paths. A plugin's effective skill set
566
+ * is its inline `skills/` folder PLUS everything these roots resolve to. The
567
+ * spec reserves `extensions` for exactly this kind of client-specific data, so
568
+ * a conformant client that ignores it still gets a valid manifest; the
569
+ * compiled distribution copies the linked skills in for it.
570
+ *
571
+ * Linking is a reference, not a grant: a member of the plugin can read a
572
+ * linked skill only because the skill's own access rules name the plugin's
573
+ * principal (`plugin/<Name>/read`). The link service writes both together.
574
+ */
575
+ export const HEXIS_LINKED_SKILLS_KEY = 'skills';
576
+
577
+ /**
578
+ * Normalise a linked-skill root, or null when it cannot be one: a
579
+ * repo-root-relative POSIX folder path with no `..`, no leading slash, no
580
+ * backslashes and no empty segments. Trailing slashes are dropped.
581
+ */
582
+ export function normalizeSkillRoot(raw: string): string | null {
583
+ if (typeof raw !== 'string') return null;
584
+ const trimmed = raw.trim();
585
+ if (!trimmed || trimmed.includes('\\') || trimmed.startsWith('/')) return null;
586
+ const segments = trimmed.split('/');
587
+ // Only TRAILING slashes are forgiven; an empty segment anywhere else
588
+ // (`Skills//deploy`) is a malformed path, not a spelling of a valid one.
589
+ while (segments.length > 0 && segments[segments.length - 1] === '') segments.pop();
590
+ if (segments.length === 0) return null;
591
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) return null;
592
+ return segments.join('/');
593
+ }
594
+
595
+ /**
596
+ * The linked-skill roots a parsed manifest declares — invalid entries are
597
+ * dropped, duplicates collapsed, order kept. A manifest with no extension
598
+ * block links nothing.
599
+ */
600
+ export function linkedSkillRoots(manifest: unknown): string[] {
601
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) return [];
602
+ const ext = (manifest as Record<string, unknown>).extensions;
603
+ if (typeof ext !== 'object' || ext === null) return [];
604
+ const ns = (ext as Record<string, unknown>)[HEXIS_EXTENSION_NS];
605
+ if (typeof ns !== 'object' || ns === null) return [];
606
+ const raw = (ns as Record<string, unknown>)[HEXIS_LINKED_SKILLS_KEY];
607
+ if (!Array.isArray(raw)) return [];
608
+ const out: string[] = [];
609
+ for (const entry of raw) {
610
+ const root = normalizeSkillRoot(typeof entry === 'string' ? entry : '');
611
+ if (root !== null && !out.includes(root)) out.push(root);
612
+ }
613
+ return out;
614
+ }
615
+
616
+ /**
617
+ * The manifest with its linked-skill roots REPLACED by `roots`, every other
618
+ * byte of the object preserved (the MCP extension block beside it, the
619
+ * portable fields above it). An empty list removes the key rather than
620
+ * leaving `skills: []` behind.
621
+ */
622
+ export function withLinkedSkillRoots(
623
+ manifest: Record<string, unknown>,
624
+ roots: readonly string[],
625
+ ): Record<string, unknown> {
626
+ const extensions =
627
+ typeof manifest.extensions === 'object' && manifest.extensions !== null && !Array.isArray(manifest.extensions)
628
+ ? { ...(manifest.extensions as Record<string, unknown>) }
629
+ : {};
630
+ const current = extensions[HEXIS_EXTENSION_NS];
631
+ const ns: Record<string, unknown> =
632
+ typeof current === 'object' && current !== null && !Array.isArray(current)
633
+ ? { ...(current as Record<string, unknown>) }
634
+ : {};
635
+ if (roots.length > 0) ns[HEXIS_LINKED_SKILLS_KEY] = [...roots];
636
+ else delete ns[HEXIS_LINKED_SKILLS_KEY];
637
+ if (Object.keys(ns).length > 0) extensions[HEXIS_EXTENSION_NS] = ns;
638
+ else delete extensions[HEXIS_EXTENSION_NS];
639
+ const out: Record<string, unknown> = { ...manifest };
640
+ if (Object.keys(extensions).length > 0) out.extensions = extensions;
641
+ else delete out.extensions;
642
+ return out;
643
+ }
644
+
645
+ /** Whether `skillPath` (a skill folder) falls under `root` (a skill folder or a folder of skills). */
646
+ export function skillUnderRoot(skillPath: string, root: string): boolean {
647
+ return skillPath === root || skillPath.startsWith(`${root}/`);
648
+ }
649
+
107
650
  /**
108
651
  * The manifest `name` for a plugin folder: lowercased, anything outside
109
652
  * `[a-z0-9.-]` folded to `-`, runs collapsed, ends trimmed to alphanumerics.
@@ -135,23 +678,79 @@ export const PLUGIN_MANIFEST_SCHEMA = `https://agent-plugins.org/schemas/${AGENT
135
678
  export const PLUGIN_MCP_SCHEMA = `https://agent-plugins.org/schemas/${AGENT_PLUGINS_SCHEMA_VERSION}/mcp.schema.json`;
136
679
 
137
680
  /**
138
- * A minimal, valid `plugin.json` for a plugin folder.
681
+ * The Agent Plugins `name`: a kebab-case identifier — lowercase letters and
682
+ * digits in hyphen-separated runs, nothing else. It is the plugin's IDENTITY:
683
+ * what the marketplace publishes it as, what the access principals are
684
+ * spelled from (`plugin/<name>/<verb>`), what the catalog and the URLs key
685
+ * on. `pluginManifestName` folds any spelling into one of these.
686
+ */
687
+ export const PLUGIN_IDENTIFIER_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
688
+
689
+ export function isPluginIdentifier(name: unknown): name is string {
690
+ return typeof name === 'string' && PLUGIN_IDENTIFIER_RE.test(name);
691
+ }
692
+
693
+ /**
694
+ * The identity of a plugin folder: the manifest's `name` when it IS an
695
+ * identifier, else the folder name folded into one. A manifest naming
696
+ * something that cannot be an identifier is not silently reinterpreted; the
697
+ * folder stands in, and discovery says so.
698
+ */
699
+ export function pluginIdentityOf(manifest: Record<string, unknown> | null, folderName: string): string {
700
+ const declared = manifest?.name;
701
+ return isPluginIdentifier(declared) ? declared : pluginManifestName(folderName);
702
+ }
703
+
704
+ /**
705
+ * What a person sees the plugin called: the manifest's `displayName` (the
706
+ * vendor field Claude Code shows in its picker; any casing, spaces allowed),
707
+ * else the manifest's `name`.
139
708
  *
140
- * Deliberately only the two required fields. `version`, `license` and the rest
141
- * are optional metadata about a DISTRIBUTED package, and inventing values for a
142
- * folder someone just made in the app would be asserting things nobody said —
143
- * a plugin here is a place a team keeps skills, not something published.
709
+ * THE MANIFEST IS THE ONLY SOURCE — there is no folder argument, on purpose.
710
+ * The folder's spelling used to stand in here, which made where a plugin
711
+ * happens to live a hidden input to what everyone sees it called: the API
712
+ * always answered with a display name while the file sometimes omitted the
713
+ * field, and moving or re-casing a folder silently renamed the plugin. Every
714
+ * write path now persists the field (see {@link renderPluginManifest} and
715
+ * the rename service), and one startup step backfilled the folder's spelling
716
+ * into the manifests written before that, so nothing renames itself.
144
717
  *
145
- * The display name is the folder, which is why nothing here carries one: the
146
- * manifest's `name` is constrained to a lowercase slug, and the field set is
147
- * closed, so there is no conformant home for "Sales" other than an extension.
718
+ * Empty only for a manifest that names nothing at all — the shape discovery
719
+ * already warns about and stands the folder in as the IDENTITY for; its
720
+ * display name then follows that identity, never the folder directly.
721
+ */
722
+ export function pluginDisplayNameOf(manifest: Record<string, unknown> | null): string {
723
+ const declared = manifest?.displayName;
724
+ if (typeof declared === 'string' && declared.trim()) return declared.trim();
725
+ const name = manifest?.name;
726
+ return typeof name === 'string' ? name.trim() : '';
727
+ }
728
+
729
+ /**
730
+ * A minimal, valid `plugin.json` for a plugin folder: the identifier the
731
+ * folder name folds into, and the name a person sees it by — `displayName`,
732
+ * ALWAYS written, so a client's picker shows "Sales Team" for `sales-team`
733
+ * and every reader has the one field to read.
734
+ *
735
+ * ONE argument, deliberately: `folderName` is the folder's own leaf, and on
736
+ * the creation path that leaf IS the name its creator typed, trimmed — the
737
+ * dialog's route and the `create_plugin` tool make the folder out of the
738
+ * typed name and hand the same string to both. A second `displayName`
739
+ * parameter would be a way for the two to disagree that no caller needs.
740
+ * The field is written even when it equals the identifier: a manifest that
741
+ * omits it when the two agree is a manifest whose readers need a second rule.
742
+ *
743
+ * Nothing else: `version`, `license` and the rest are metadata about a
744
+ * DISTRIBUTED package, and inventing values for a folder someone just made
745
+ * in the app would be asserting things nobody said.
148
746
  */
149
747
  export function renderPluginManifest(folderName: string): string {
150
- return `${JSON.stringify(
151
- { $schema: PLUGIN_MANIFEST_SCHEMA, name: pluginManifestName(folderName) },
152
- null,
153
- 2,
154
- )}\n`;
748
+ const name = pluginManifestName(folderName);
749
+ // Always a non-blank, trimmed answer: the spelling asked for, else the
750
+ // identifier — a `displayName` of spaces would be a field present and
751
+ // saying nothing, which is the shape every reader here exists to avoid.
752
+ const shown = folderName.trim() || name;
753
+ return `${JSON.stringify({ $schema: PLUGIN_MANIFEST_SCHEMA, name, displayName: shown }, null, 2)}\n`;
155
754
  }
156
755
 
157
756
  /**
@@ -184,6 +783,18 @@ export function isPersonalPluginFolder(folderName: string): boolean {
184
783
  return folderName.startsWith(PERSONAL_PLUGIN_PREFIX);
185
784
  }
186
785
 
786
+ /**
787
+ * THE structural rule for a personal shelf: a repo-relative folder that is a
788
+ * DIRECT child of the plugins root and carries the personal prefix. A deeper
789
+ * folder so named is just a name, and a plugin whose manifest name happens
790
+ * to start with the prefix is a plugin — discovery, the principal picker and
791
+ * the item pages all ask this one question of the FOLDER.
792
+ */
793
+ export function isPersonalPluginDir(repoRelDir: string): boolean {
794
+ const segments = repoRelDir.split('/').filter(Boolean);
795
+ return segments.length === 2 && segments[0] === PLUGINS_DIR && isPersonalPluginFolder(segments[1]!);
796
+ }
797
+
187
798
  /**
188
799
  * The plugin a repo-root-relative path belongs to, or `null` for content that
189
800
  * sits outside any plugin.
@@ -222,8 +833,33 @@ export const PIPELINES_DIR = 'Pipelines';
222
833
  /**
223
834
  * The roots whose subfolders are discovered as ontologies by the graph parser
224
835
  * (each subfolder with both `NodeTypes/` and `Knowledge/` is an ontology).
836
+ * A function, not a constant: `KNOWLEDGE_BASE_DIR` is configurable, and a
837
+ * module-scope array would snapshot the default before configuration.
225
838
  */
226
- export const ONTOLOGY_ROOTS: readonly string[] = [KNOWLEDGE_BASE_DIR, DATA_DIR];
839
+ export function ontologyRoots(): readonly string[] {
840
+ return [KNOWLEDGE_BASE_DIR, DATA_DIR];
841
+ }
842
+
843
+ /**
844
+ * Every reserved root name, as currently configured — the set the file tree
845
+ * renders as its own sections rather than folding into Knowledge.
846
+ */
847
+ export function reservedRootDirNames(): ReadonlySet<string> {
848
+ return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]);
849
+ }
850
+
851
+ /**
852
+ * The roots anyone may start a new folder in — knowledge, skills and plugins
853
+ * — whatever the root's own `access.md` grants them. Everywhere else a change
854
+ * needs read access to where it lands (the "read before write" rule); a new
855
+ * folder directly under one of these three is the one place that rule does
856
+ * not apply, because the new folder carries its creator's own grant. A
857
+ * function, like {@link reservedRootDirNames}, because the names are
858
+ * configurable.
859
+ */
860
+ export function creatableRootDirNames(): ReadonlySet<string> {
861
+ return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR]);
862
+ }
227
863
 
228
864
  /** The `Knowledge/` marker subfolder of an ontology (holds the graph nodes). */
229
865
  export const KNOWLEDGE_DIR = 'Knowledge';