@bevel-software/platform-shared 0.20.0 → 0.22.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.
@@ -36,12 +36,19 @@ import { validateFilename } from './filename.js';
36
36
  * Don't hard-code these strings elsewhere — import them from here.
37
37
  *
38
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.
39
+ * (`KnowledgeBase/`, `Skills/`, `Plugins/`) and the guide's file name travel
40
+ * as a {@link KbLayout} VALUE: every helper below that depends on a name takes
41
+ * the layout it judges against, and the backend hands each knowledge base's
42
+ * layout to its services at construction — a server hosting several knowledge
43
+ * bases in one process holds one per knowledge base, never a process-wide one.
44
+ *
45
+ * The `let` bindings (`KNOWLEDGE_BASE_DIR`, …) are the BROWSER'S copy, applied
46
+ * once by {@link configureKbLayout} from `GET /api/config`, the same shape as
47
+ * the branch model in `git/protected.ts`: a page shows one deployment, so one
48
+ * process-wide value is right there. The backend package forbids importing
49
+ * them (an ESLint rule names each one). They carry defaults, so nothing waits
50
+ * for configuration; but read them inside a function body, never capture one
51
+ * at module scope.
45
52
  */
46
53
 
47
54
  /** Folder under the repo root that contains all team ontologies. */
@@ -218,7 +225,7 @@ export function gitignoreLiteral(name: string): string {
218
225
  * whether this sentence is present by searching for the RAW name — see
219
226
  * {@link mentionsAgentsFile}, which is how the startup step asks.
220
227
  */
221
- export function agentsFilePointerSentence(agentsFile: string = AGENTS_FILE): string {
228
+ export function agentsFilePointerSentence(agentsFile: string): string {
222
229
  return `Read [${markdownLinkLabel(agentsFile)}](${agentsFileLinkPath(agentsFile)})${POINTER_SENTENCE_TAIL}`;
223
230
  }
224
231
 
@@ -266,7 +273,7 @@ function escapeRegExp(text: string): string {
266
273
  * answers, and only the caller knows that the second one means "consider
267
274
  * appending".
268
275
  */
269
- export function retargetAgentsFilePointer(text: string, agentsFile: string = AGENTS_FILE): string | null {
276
+ export function retargetAgentsFilePointer(text: string, agentsFile: string): string | null {
270
277
  let found = false;
271
278
  const wanted = agentsFilePointerSentence(agentsFile);
272
279
  const updated = text.replace(POINTER_SENTENCE_PATTERN, () => {
@@ -315,7 +322,7 @@ function agentsFileLinkPath(agentsFile: string): string {
315
322
  * that one would append a second copy on the next boot, and a third on the
316
323
  * one after — the exact failure this feature exists to prevent.
317
324
  */
318
- export function mentionsAgentsFile(text: string, agentsFile: string = AGENTS_FILE): boolean {
325
+ export function mentionsAgentsFile(text: string, agentsFile: string): boolean {
319
326
  return (
320
327
  text.includes(agentsFile) ||
321
328
  text.includes(markdownLinkLabel(agentsFile)) ||
@@ -369,7 +376,7 @@ export function validateKbRootName(name: string): string | null {
369
376
  */
370
377
  export function validateAgentsFileName(
371
378
  name: string,
372
- roots: Pick<KbLayout, 'knowledgeBaseDir' | 'skillsDir' | 'pluginsDir'> = currentKbLayout(),
379
+ roots: Pick<KbLayout, 'knowledgeBaseDir' | 'skillsDir' | 'pluginsDir'>,
373
380
  ): string | null {
374
381
  const v = name.trim();
375
382
  if (!v) return 'A file name is required.';
@@ -466,21 +473,37 @@ export function onKbLayoutApplied(listener: () => void): void {
466
473
  }
467
474
 
468
475
  /**
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.
476
+ * A layout as the helpers hold it: validated, every name trimmed, the guide's
477
+ * name filled in from the default when absent. Throws on an invalid one so a
478
+ * bad deployment setting fails beside the rest of the wiring rather than
479
+ * scattering a half-renamed tree.
472
480
  */
473
- export function configureKbLayout(layout: KbLayout): void {
481
+ export function resolveKbLayout(layout: KbLayout): Required<KbLayout> {
474
482
  const problem = validateKbLayout(layout);
475
483
  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);
484
+ return Object.freeze({
485
+ knowledgeBaseDir: layout.knowledgeBaseDir.trim(),
486
+ skillsDir: layout.skillsDir.trim(),
487
+ pluginsDir: layout.pluginsDir.trim(),
488
+ agentsFile: agentsFileOf(layout),
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Apply the layout to the BROWSER'S live bindings. Called once during boot
494
+ * from `GET /api/config`; validated by {@link resolveKbLayout}. Applying the
495
+ * defaults is a no-op.
496
+ */
497
+ export function configureKbLayout(layout: KbLayout): void {
498
+ const resolved = resolveKbLayout(layout);
499
+ KNOWLEDGE_BASE_DIR = resolved.knowledgeBaseDir;
500
+ SKILLS_DIR = resolved.skillsDir;
501
+ PLUGINS_DIR = resolved.pluginsDir;
502
+ AGENTS_FILE = resolved.agentsFile;
480
503
  for (const listener of layoutListeners) listener();
481
504
  }
482
505
 
483
- /** The layout currently in effect. */
506
+ /** The browser's layout as a value — what the live bindings currently hold. */
484
507
  export function currentKbLayout(): Required<KbLayout> {
485
508
  return {
486
509
  knowledgeBaseDir: KNOWLEDGE_BASE_DIR,
@@ -491,14 +514,14 @@ export function currentKbLayout(): Required<KbLayout> {
491
514
  }
492
515
 
493
516
  /**
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.
517
+ * Whether a layout is the default one. The setup-completing save applies the
518
+ * stored names while this holds of the one in effect, the same "only from
519
+ * none to some" rule the branch model follows — and also on a retry after its
520
+ * own failed initialization run, when the process holds names setup applied
521
+ * but the app never opened (see `setup.routes.ts`). A layout the process
522
+ * booted with is never replaced there.
500
523
  */
501
- export function isDefaultKbLayout(layout: KbLayout = currentKbLayout()): boolean {
524
+ export function isDefaultKbLayout(layout: KbLayout): boolean {
502
525
  return (
503
526
  layout.knowledgeBaseDir === DEFAULT_KB_LAYOUT.knowledgeBaseDir &&
504
527
  layout.skillsDir === DEFAULT_KB_LAYOUT.skillsDir &&
@@ -516,7 +539,7 @@ export function isDefaultKbLayout(layout: KbLayout = currentKbLayout()): boolean
516
539
  * renamed the guide gets a guide naming the file it lives in. Text without
517
540
  * placeholders passes through unchanged.
518
541
  */
519
- export function renderKbLayoutPlaceholders(text: string, layout: KbLayout = currentKbLayout()): string {
542
+ export function renderKbLayoutPlaceholders(text: string, layout: KbLayout): string {
520
543
  // Replacer FUNCTIONS: a string replacement would interpret `$&`, `$$` and
521
544
  // friends inside a folder name, and `$` is a legal character in one.
522
545
  return text
@@ -790,9 +813,9 @@ export function isPersonalPluginFolder(folderName: string): boolean {
790
813
  * to start with the prefix is a plugin — discovery, the principal picker and
791
814
  * the item pages all ask this one question of the FOLDER.
792
815
  */
793
- export function isPersonalPluginDir(repoRelDir: string): boolean {
816
+ export function isPersonalPluginDir(repoRelDir: string, layout: KbLayout): boolean {
794
817
  const segments = repoRelDir.split('/').filter(Boolean);
795
- return segments.length === 2 && segments[0] === PLUGINS_DIR && isPersonalPluginFolder(segments[1]!);
818
+ return segments.length === 2 && segments[0] === layout.pluginsDir && isPersonalPluginFolder(segments[1]!);
796
819
  }
797
820
 
798
821
  /**
@@ -808,9 +831,9 @@ export function isPersonalPluginDir(repoRelDir: string): boolean {
808
831
  * have. Callers bucket by it; nothing requires it. A plugin-less skill is a
809
832
  * real, supported state — the prototype calls those "yours alone".
810
833
  */
811
- export function pluginOfPath(repoRelativePath: string): string | null {
834
+ export function pluginOfPath(repoRelativePath: string, layout: KbLayout): string | null {
812
835
  const segments = repoRelativePath.split('/').filter(Boolean);
813
- if (segments[0] !== PLUGINS_DIR) return null;
836
+ if (segments[0] !== layout.pluginsDir) return null;
814
837
  // Needs a segment for the plugin AND at least one below it, otherwise
815
838
  // `Plugins/GTM` (the folder itself) would report itself as being in a plugin,
816
839
  // and a loose `Plugins/slack.tool` would report a plugin named "slack.tool".
@@ -833,19 +856,19 @@ export const PIPELINES_DIR = 'Pipelines';
833
856
  /**
834
857
  * The roots whose subfolders are discovered as ontologies by the graph parser
835
858
  * (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.
859
+ * A function of the layout, not a constant: the knowledge-base root is
860
+ * configurable per deployment.
838
861
  */
839
- export function ontologyRoots(): readonly string[] {
840
- return [KNOWLEDGE_BASE_DIR, DATA_DIR];
862
+ export function ontologyRoots(layout: KbLayout): readonly string[] {
863
+ return [layout.knowledgeBaseDir, DATA_DIR];
841
864
  }
842
865
 
843
866
  /**
844
- * Every reserved root name, as currently configured — the set the file tree
845
- * renders as its own sections rather than folding into Knowledge.
867
+ * Every reserved root name under `layout` — the set the file tree renders as
868
+ * its own sections rather than folding into Knowledge.
846
869
  */
847
- export function reservedRootDirNames(): ReadonlySet<string> {
848
- return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]);
870
+ export function reservedRootDirNames(layout: KbLayout): ReadonlySet<string> {
871
+ return new Set([layout.knowledgeBaseDir, layout.skillsDir, layout.pluginsDir, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]);
849
872
  }
850
873
 
851
874
  /**
@@ -854,11 +877,11 @@ export function reservedRootDirNames(): ReadonlySet<string> {
854
877
  * needs read access to where it lands (the "read before write" rule); a new
855
878
  * folder directly under one of these three is the one place that rule does
856
879
  * not apply, because the new folder carries its creator's own grant. A
857
- * function, like {@link reservedRootDirNames}, because the names are
858
- * configurable.
880
+ * function of the layout, like {@link reservedRootDirNames}, because the
881
+ * names are configurable.
859
882
  */
860
- export function creatableRootDirNames(): ReadonlySet<string> {
861
- return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR]);
883
+ export function creatableRootDirNames(layout: KbLayout): ReadonlySet<string> {
884
+ return new Set([layout.knowledgeBaseDir, layout.skillsDir, layout.pluginsDir]);
862
885
  }
863
886
 
864
887
  /** The `Knowledge/` marker subfolder of an ontology (holds the graph nodes). */
@@ -2,7 +2,6 @@ import {
2
2
  DEFAULT_KB_LAYOUT,
3
3
  FIXED_PLATFORM_FILE_NAMES,
4
4
  agentsFileOf,
5
- currentKbLayout,
6
5
  reservedRootDirNames,
7
6
  type KbLayout,
8
7
  } from './kb-layout.js';
@@ -14,14 +13,15 @@ import {
14
13
  * the repository root only, so a nested file of either name is ordinary
15
14
  * content. Moving one changes what the platform enforces, so moves refuse them.
16
15
  *
17
- * A FUNCTION, not a constant, and that is the whole of the configurable-guide
18
- * change on this side: the guide's name is a deployment setting, so the fourth
19
- * platform file is `HEXIS.md` on one deployment and `AGENTS.md` on the next —
20
- * and on the first, a root `AGENTS.md` is the CUSTOMER'S own conventions file,
21
- * which has to move and delete like any page. Every gate asks this rather than
22
- * reading a list captured at module load.
16
+ * A FUNCTION OF THE LAYOUT, not a constant, and that is the whole of the
17
+ * configurable-guide change on this side: the guide's name is a deployment
18
+ * setting, so the fourth platform file is `HEXIS.md` on one deployment and
19
+ * `AGENTS.md` on the next — and on the first, a root `AGENTS.md` is the
20
+ * CUSTOMER'S own conventions file, which has to move and delete like any page.
21
+ * Every gate asks this with the layout it serves rather than reading a list
22
+ * captured at module load.
23
23
  */
24
- export function platformFileNames(layout: KbLayout = currentKbLayout()): readonly string[] {
24
+ export function platformFileNames(layout: KbLayout): readonly string[] {
25
25
  return [...FIXED_PLATFORM_FILE_NAMES, agentsFileOf(layout)];
26
26
  }
27
27
 
@@ -38,8 +38,8 @@ export const PLATFORM_FILE_NAMES: readonly string[] = Object.freeze(
38
38
  /** The platform files that are read wherever they sit, not only at the root. */
39
39
  const PLATFORM_FILES_AT_ANY_DEPTH = new Set(['access.md', '.bevelignore']);
40
40
 
41
- /** The names in effect, as a set — rebuilt per call, because the guide's is configurable. */
42
- const platformFiles = (): ReadonlySet<string> => new Set(platformFileNames());
41
+ /** The names under `layout`, as a set — rebuilt per call, because the guide's is configurable. */
42
+ const platformFiles = (layout: KbLayout): ReadonlySet<string> => new Set(platformFileNames(layout));
43
43
 
44
44
  const normalize = (path: string): string => path.replace(/^\.?\/+/, '').replace(/\/+$/, '');
45
45
 
@@ -65,10 +65,10 @@ const hasTraversal = (path: string): boolean =>
65
65
  * Exact spelling, as the platform reads it: `Access.md` is content. A caller
66
66
  * on a case-insensitive disk passes the path's on-disk spelling.
67
67
  */
68
- export function isPlatformFile(repoRelativePath: string): boolean {
68
+ export function isPlatformFile(repoRelativePath: string, layout: KbLayout): boolean {
69
69
  const norm = normalize(repoRelativePath);
70
70
  const name = baseName(norm);
71
- if (!platformFiles().has(name)) return false;
71
+ if (!platformFiles(layout).has(name)) return false;
72
72
  return PLATFORM_FILES_AT_ANY_DEPTH.has(name) || norm === name;
73
73
  }
74
74
 
@@ -96,10 +96,10 @@ export function platformFileCreationRefusal(pathOrName: string): string {
96
96
  * one takes a whole section of the knowledge base with it. Exact spelling; a
97
97
  * caller on a case-insensitive disk passes the path's on-disk spelling.
98
98
  */
99
- export function isPlatformFolder(repoRelativeDir: string): boolean {
99
+ export function isPlatformFolder(repoRelativeDir: string, layout: KbLayout): boolean {
100
100
  const norm = normalize(repoRelativeDir);
101
101
  if (norm === '') return true;
102
- return !norm.includes('/') && reservedRootDirNames().has(norm);
102
+ return !norm.includes('/') && reservedRootDirNames(layout).has(norm);
103
103
  }
104
104
 
105
105
  /** The sentence a delete or move of a platform folder is refused with. */
@@ -118,8 +118,8 @@ export function platformFolderRefusal(repoRelativeDir: string): string {
118
118
  * of the root's rather than standing in for it, which is why moving the
119
119
  * ROOT's copy into a folder is a move out and not a restore.
120
120
  */
121
- export function isRootPlatformFile(repoRelativePath: string): boolean {
122
- return platformFiles().has(normalize(repoRelativePath));
121
+ export function isRootPlatformFile(repoRelativePath: string, layout: KbLayout): boolean {
122
+ return platformFiles(layout).has(normalize(repoRelativePath));
123
123
  }
124
124
 
125
125
  /**
@@ -143,11 +143,12 @@ export type PlatformRestoreDestination =
143
143
 
144
144
  export function platformRestoreDestination(
145
145
  repoRelativeDestination: string,
146
+ layout: KbLayout,
146
147
  ): PlatformRestoreDestination | null {
147
148
  const norm = normalize(repoRelativeDestination);
148
149
  if (hasTraversal(norm)) return null;
149
150
  const name = baseName(norm);
150
- if (!platformFiles().has(name)) return null;
151
+ if (!platformFiles(layout).has(name)) return null;
151
152
  if (name === 'access.md') {
152
153
  const slash = norm.lastIndexOf('/');
153
154
  return { name, kind: 'folder-without-access-md', dir: slash === -1 ? '' : norm.slice(0, slash) };
@@ -178,11 +179,12 @@ export function platformRestoreDestination(
178
179
  export function isPlatformRestoreShape(
179
180
  repoRelativeSource: string,
180
181
  repoRelativeDestination: string,
182
+ layout: KbLayout,
181
183
  ): boolean {
182
184
  if (hasTraversal(repoRelativeSource)) return false;
183
185
  const name = baseName(repoRelativeSource);
184
- if (!platformFiles().has(name)) return false;
185
- if (isRootPlatformFile(repoRelativeSource)) return false;
186
- const target = platformRestoreDestination(repoRelativeDestination);
186
+ if (!platformFiles(layout).has(name)) return false;
187
+ if (isRootPlatformFile(repoRelativeSource, layout)) return false;
188
+ const target = platformRestoreDestination(repoRelativeDestination, layout);
187
189
  return target !== null && target.name === name;
188
190
  }