bopodev-api 0.1.34 → 0.1.36

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 (95) hide show
  1. package/package.json +5 -5
  2. package/src/app.ts +4 -2
  3. package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
  4. package/src/assets/starter-packs/devrel-growth.zip +0 -0
  5. package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
  6. package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
  7. package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
  8. package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
  9. package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
  10. package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
  11. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
  12. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
  13. package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
  14. package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
  15. package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
  16. package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
  17. package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
  18. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
  19. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
  20. package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
  21. package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
  22. package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
  23. package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
  24. package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
  25. package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
  26. package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
  27. package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
  28. package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
  29. package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
  30. package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
  31. package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
  32. package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
  33. package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
  34. package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
  35. package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
  36. package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
  37. package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
  38. package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
  39. package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
  40. package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
  41. package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
  42. package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
  43. package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
  44. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
  45. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
  46. package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
  47. package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
  48. package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
  49. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
  50. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
  51. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
  52. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
  53. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
  54. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
  55. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
  56. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
  57. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
  58. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
  59. package/src/lib/agent-issue-permissions.ts +56 -0
  60. package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
  61. package/src/lib/instance-paths.ts +5 -0
  62. package/src/realtime/office-space.ts +7 -0
  63. package/src/routes/agents.ts +23 -1
  64. package/src/routes/assistant.ts +40 -1
  65. package/src/routes/companies.ts +227 -15
  66. package/src/routes/issues.ts +82 -3
  67. package/src/routes/observability.ts +222 -0
  68. package/src/routes/plugins.ts +393 -103
  69. package/src/routes/{loops.ts → routines.ts} +72 -76
  70. package/src/scripts/onboard-seed.ts +2 -0
  71. package/src/server.ts +3 -1
  72. package/src/services/company-assistant-context-snapshot.ts +4 -2
  73. package/src/services/company-assistant-service.ts +17 -15
  74. package/src/services/company-file-archive-service.ts +81 -6
  75. package/src/services/company-file-import-service.ts +221 -31
  76. package/src/services/company-knowledge-file-service.ts +361 -0
  77. package/src/services/company-skill-file-service.ts +151 -2
  78. package/src/services/governance-service.ts +58 -3
  79. package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
  80. package/src/services/plugin-artifact-installer.ts +115 -0
  81. package/src/services/plugin-artifact-store.ts +28 -0
  82. package/src/services/plugin-capability-policy.ts +31 -0
  83. package/src/services/plugin-jobs-service.ts +74 -0
  84. package/src/services/plugin-manifest-loader.ts +78 -3
  85. package/src/services/plugin-rpc.ts +102 -0
  86. package/src/services/plugin-runtime.ts +240 -209
  87. package/src/services/plugin-worker-host.ts +167 -0
  88. package/src/services/starter-pack-registry.ts +68 -0
  89. package/src/services/template-apply-service.ts +3 -1
  90. package/src/services/template-catalog.ts +29 -0
  91. package/src/services/work-loop-service/work-loop-service.ts +18 -18
  92. package/src/shutdown/graceful-shutdown.ts +3 -1
  93. package/src/validation/issue-routes.ts +19 -2
  94. package/src/worker/scheduler.ts +21 -1
  95. package/src/services/company-export-service.ts +0 -63
@@ -0,0 +1,361 @@
1
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { isInsidePath, resolveCompanyKnowledgePath, resolveCompanyProjectsWorkspacePath } from "../lib/instance-paths";
4
+
5
+ const MAX_OBSERVABILITY_FILES = 200;
6
+ const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
7
+ const MAX_PATH_SEGMENTS = 32;
8
+ const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
9
+
10
+ /** Default file body when POST create omits `content`. Markdown/text start empty (no frontmatter boilerplate). */
11
+ function defaultContentForNewKnowledgeFile(relativePath: string): string {
12
+ const lower = relativePath.toLowerCase();
13
+ if (lower.endsWith(".json")) {
14
+ return "{}\n";
15
+ }
16
+ return "";
17
+ }
18
+
19
+ export function assertKnowledgeRelativePath(relativePath: string): string {
20
+ const normalized = relativePath.trim().replace(/\\/g, "/");
21
+ if (!normalized || normalized.startsWith("/") || normalized.includes("..")) {
22
+ throw new Error("Invalid relative path.");
23
+ }
24
+ const parts = normalized.split("/").filter(Boolean);
25
+ if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS) {
26
+ throw new Error("Invalid relative path.");
27
+ }
28
+ for (const p of parts) {
29
+ if (p === "." || p === ".." || p.startsWith(".")) {
30
+ throw new Error("Invalid relative path.");
31
+ }
32
+ }
33
+ const base = parts[parts.length - 1]!;
34
+ const lower = base.toLowerCase();
35
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
36
+ if (!TEXT_EXT.has(ext)) {
37
+ throw new Error("Only text knowledge files (.md, .yaml, .yml, .txt, .json) are allowed.");
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ async function knowledgeRoot(companyId: string) {
43
+ const root = resolveCompanyKnowledgePath(companyId);
44
+ const companyWorkspace = resolveCompanyProjectsWorkspacePath(companyId);
45
+ if (!isInsidePath(companyWorkspace, root)) {
46
+ throw new Error("Invalid knowledge root.");
47
+ }
48
+ return { root };
49
+ }
50
+
51
+ export async function listKnowledgeFiles(input: { companyId: string; maxFiles?: number }) {
52
+ const { root } = await knowledgeRoot(input.companyId);
53
+ await mkdir(root, { recursive: true });
54
+ const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? MAX_OBSERVABILITY_FILES));
55
+ const relativePaths = await walkKnowledgeTextFiles(root, maxFiles);
56
+ return { root, files: relativePaths.map((relativePath) => ({ relativePath })) };
57
+ }
58
+
59
+ export type KnowledgeTreeNode =
60
+ | { type: "file"; name: string; relativePath: string }
61
+ | { type: "dir"; name: string; children: KnowledgeTreeNode[] };
62
+
63
+ /** Nested tree from flat relative paths (trie). */
64
+ export function buildKnowledgeTreeFromPaths(files: { relativePath: string }[]): KnowledgeTreeNode[] {
65
+ type Trie = { dirs: Map<string, Trie>; files: KnowledgeTreeNode[] };
66
+ const root: Trie = { dirs: new Map(), files: [] };
67
+
68
+ for (const { relativePath } of [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
69
+ const parts = relativePath.split("/").filter(Boolean);
70
+ if (parts.length === 0) {
71
+ continue;
72
+ }
73
+ const fileName = parts.pop()!;
74
+ let node = root;
75
+ for (const segment of parts) {
76
+ if (!node.dirs.has(segment)) {
77
+ node.dirs.set(segment, { dirs: new Map(), files: [] });
78
+ }
79
+ node = node.dirs.get(segment)!;
80
+ }
81
+ node.files.push({ type: "file", name: fileName, relativePath });
82
+ }
83
+
84
+ function trieToNodes(trie: Trie): KnowledgeTreeNode[] {
85
+ const dirNodes: KnowledgeTreeNode[] = [];
86
+ for (const [name, child] of [...trie.dirs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
87
+ const children = trieToNodes(child);
88
+ dirNodes.push({ type: "dir", name, children });
89
+ }
90
+ const sortedFiles = [...trie.files].sort((a, b) => a.name.localeCompare(b.name));
91
+ return [...dirNodes, ...sortedFiles];
92
+ }
93
+
94
+ return trieToNodes(root);
95
+ }
96
+
97
+ async function walkKnowledgeTextFiles(knowledgeDir: string, maxFiles: number): Promise<string[]> {
98
+ const collected: string[] = [];
99
+ const queue = [knowledgeDir];
100
+ while (queue.length > 0 && collected.length < maxFiles) {
101
+ const current = queue.shift();
102
+ if (!current) {
103
+ continue;
104
+ }
105
+ let entries: import("node:fs").Dirent[];
106
+ try {
107
+ entries = await readdir(current, { withFileTypes: true });
108
+ } catch {
109
+ continue;
110
+ }
111
+ for (const entry of entries) {
112
+ if (collected.length >= maxFiles) {
113
+ break;
114
+ }
115
+ const absolutePath = join(current, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (!entry.name.startsWith(".")) {
118
+ queue.push(absolutePath);
119
+ }
120
+ continue;
121
+ }
122
+ if (entry.name.startsWith(".")) {
123
+ continue;
124
+ }
125
+ const lower = entry.name.toLowerCase();
126
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
127
+ if (TEXT_EXT.has(ext)) {
128
+ collected.push(relative(knowledgeDir, absolutePath).replace(/\\/g, "/"));
129
+ }
130
+ }
131
+ }
132
+ return collected.sort((a, b) => a.localeCompare(b));
133
+ }
134
+
135
+ export async function readKnowledgeFile(input: { companyId: string; relativePath: string }) {
136
+ const { root } = await knowledgeRoot(input.companyId);
137
+ const rel = assertKnowledgeRelativePath(input.relativePath);
138
+ const candidate = resolve(root, rel);
139
+ if (!isInsidePath(root, candidate)) {
140
+ throw new Error("Requested path is outside of knowledge directory.");
141
+ }
142
+ const info = await stat(candidate);
143
+ if (!info.isFile()) {
144
+ throw new Error("Requested path is not a file.");
145
+ }
146
+ if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
147
+ throw new Error("File exceeds size limit.");
148
+ }
149
+ const content = await readFile(candidate, "utf8");
150
+ return {
151
+ relativePath: rel,
152
+ content,
153
+ sizeBytes: info.size
154
+ };
155
+ }
156
+
157
+ export async function writeKnowledgeFile(input: {
158
+ companyId: string;
159
+ relativePath: string;
160
+ content: string;
161
+ }) {
162
+ const { root } = await knowledgeRoot(input.companyId);
163
+ const rel = assertKnowledgeRelativePath(input.relativePath);
164
+ const candidate = resolve(root, rel);
165
+ if (!isInsidePath(root, candidate)) {
166
+ throw new Error("Requested path is outside of knowledge directory.");
167
+ }
168
+ const bytes = Buffer.byteLength(input.content, "utf8");
169
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
170
+ throw new Error("Content exceeds size limit.");
171
+ }
172
+ const parent = dirname(candidate);
173
+ if (!isInsidePath(root, parent)) {
174
+ throw new Error("Invalid parent directory.");
175
+ }
176
+ await mkdir(parent, { recursive: true });
177
+ await writeFile(candidate, input.content, { encoding: "utf8" });
178
+ const info = await stat(candidate);
179
+ return {
180
+ relativePath: rel,
181
+ sizeBytes: info.size
182
+ };
183
+ }
184
+
185
+ /** Create a new file; fails if it already exists. */
186
+ export async function createKnowledgeFile(input: {
187
+ companyId: string;
188
+ relativePath: string;
189
+ content?: string;
190
+ }) {
191
+ const { root } = await knowledgeRoot(input.companyId);
192
+ const rel = assertKnowledgeRelativePath(input.relativePath);
193
+ const candidate = resolve(root, rel);
194
+ if (!isInsidePath(root, candidate)) {
195
+ throw new Error("Requested path is outside of knowledge directory.");
196
+ }
197
+ try {
198
+ await stat(candidate);
199
+ throw new Error("A file already exists at this path.");
200
+ } catch (error) {
201
+ if (error instanceof Error && error.message === "A file already exists at this path.") {
202
+ throw error;
203
+ }
204
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
205
+ if (code !== "ENOENT") {
206
+ throw error;
207
+ }
208
+ }
209
+ const body = input.content ?? defaultContentForNewKnowledgeFile(rel);
210
+ const bytes = Buffer.byteLength(body, "utf8");
211
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
212
+ throw new Error("Content exceeds size limit.");
213
+ }
214
+ const parent = dirname(candidate);
215
+ if (!isInsidePath(root, parent)) {
216
+ throw new Error("Invalid parent directory.");
217
+ }
218
+ await mkdir(parent, { recursive: true });
219
+ await writeFile(candidate, body, { encoding: "utf8" });
220
+ const info = await stat(candidate);
221
+ return {
222
+ relativePath: rel,
223
+ sizeBytes: info.size
224
+ };
225
+ }
226
+
227
+ /** Rename/move a knowledge file within the knowledge root. */
228
+ export async function renameKnowledgeFile(input: {
229
+ companyId: string;
230
+ fromRelativePath: string;
231
+ toRelativePath: string;
232
+ }) {
233
+ const fromRel = assertKnowledgeRelativePath(input.fromRelativePath.trim());
234
+ const toRel = assertKnowledgeRelativePath(input.toRelativePath.trim());
235
+ if (fromRel === toRel) {
236
+ return { relativePath: toRel };
237
+ }
238
+ const { root } = await knowledgeRoot(input.companyId);
239
+ const fromAbs = resolve(root, fromRel);
240
+ const toAbs = resolve(root, toRel);
241
+ if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
242
+ throw new Error("Requested path is outside of knowledge directory.");
243
+ }
244
+ const fromInfo = await stat(fromAbs);
245
+ if (!fromInfo.isFile()) {
246
+ throw new Error("Source path is not a file.");
247
+ }
248
+ try {
249
+ await stat(toAbs);
250
+ throw new Error("A file already exists at the destination path.");
251
+ } catch (error) {
252
+ if (error instanceof Error && error.message === "A file already exists at the destination path.") {
253
+ throw error;
254
+ }
255
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
256
+ if (code !== "ENOENT") {
257
+ throw error;
258
+ }
259
+ }
260
+ const parent = dirname(toAbs);
261
+ if (!isInsidePath(root, parent)) {
262
+ throw new Error("Invalid parent directory.");
263
+ }
264
+ await mkdir(parent, { recursive: true });
265
+ await rename(fromAbs, toAbs);
266
+ return { relativePath: toRel };
267
+ }
268
+
269
+ /** Folder path prefix (no trailing slash), e.g. `guides/onboarding`. */
270
+ export function assertKnowledgeFolderPrefix(prefix: string): string {
271
+ const normalized = prefix.trim().replace(/\\/g, "/").replace(/\/+$/g, "");
272
+ if (!normalized) {
273
+ throw new Error("Invalid folder path.");
274
+ }
275
+ if (normalized.startsWith("/") || normalized.includes("..")) {
276
+ throw new Error("Invalid folder path.");
277
+ }
278
+ const parts = normalized.split("/").filter(Boolean);
279
+ if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS - 1) {
280
+ throw new Error("Invalid folder path.");
281
+ }
282
+ for (const p of parts) {
283
+ if (p === "." || p === ".." || p.startsWith(".")) {
284
+ throw new Error("Invalid folder path.");
285
+ }
286
+ }
287
+ return parts.join("/");
288
+ }
289
+
290
+ /**
291
+ * Rename a knowledge folder by moving every file under `fromPrefix/` to the same relative paths under `toPrefix`.
292
+ */
293
+ export async function renameKnowledgeFolderPrefix(input: {
294
+ companyId: string;
295
+ fromPrefix: string;
296
+ toPrefix: string;
297
+ }) {
298
+ const fromP = assertKnowledgeFolderPrefix(input.fromPrefix);
299
+ const toP = assertKnowledgeFolderPrefix(input.toPrefix);
300
+ if (fromP === toP) {
301
+ return { moved: 0, fromPrefix: fromP, toPrefix: toP };
302
+ }
303
+ if (toP.startsWith(`${fromP}/`) || fromP.startsWith(`${toP}/`)) {
304
+ throw new Error("Invalid folder rename: one folder sits inside the other.");
305
+ }
306
+ const { files } = await listKnowledgeFiles({ companyId: input.companyId, maxFiles: MAX_OBSERVABILITY_FILES });
307
+ const paths = files.map((f) => f.relativePath);
308
+ const filePathsToMove = paths.filter((p) => p.startsWith(`${fromP}/`));
309
+ if (filePathsToMove.length === 0) {
310
+ throw new Error("No files found under that folder.");
311
+ }
312
+ const existing = new Set(paths);
313
+ for (const p of filePathsToMove) {
314
+ const np = `${toP}${p.slice(fromP.length)}`;
315
+ if (existing.has(np) && !filePathsToMove.includes(np)) {
316
+ throw new Error(`A file already exists at ${np}.`);
317
+ }
318
+ }
319
+ const sorted = [...filePathsToMove].sort((a, b) => b.length - a.length);
320
+ for (const p of sorted) {
321
+ const np = `${toP}${p.slice(fromP.length)}`;
322
+ await renameKnowledgeFile({
323
+ companyId: input.companyId,
324
+ fromRelativePath: p,
325
+ toRelativePath: np
326
+ });
327
+ existing.delete(p);
328
+ existing.add(np);
329
+ }
330
+ return { moved: sorted.length, fromPrefix: fromP, toPrefix: toP };
331
+ }
332
+
333
+ export async function deleteKnowledgeFile(input: { companyId: string; relativePath: string }) {
334
+ const { root } = await knowledgeRoot(input.companyId);
335
+ const rel = assertKnowledgeRelativePath(input.relativePath);
336
+ const candidate = resolve(root, rel);
337
+ if (!isInsidePath(root, candidate)) {
338
+ throw new Error("Requested path is outside of knowledge directory.");
339
+ }
340
+ const info = await stat(candidate);
341
+ if (!info.isFile()) {
342
+ throw new Error("Requested path is not a file.");
343
+ }
344
+ await rm(candidate, { force: true });
345
+ return { relativePath: rel };
346
+ }
347
+
348
+ export async function knowledgeFileExists(input: { companyId: string; relativePath: string }): Promise<boolean> {
349
+ try {
350
+ const rel = assertKnowledgeRelativePath(input.relativePath);
351
+ const { root } = await knowledgeRoot(input.companyId);
352
+ const candidate = resolve(root, rel);
353
+ if (!isInsidePath(root, candidate)) {
354
+ return false;
355
+ }
356
+ const info = await stat(candidate);
357
+ return info.isFile();
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
4
  import TurndownService from "turndown";
@@ -8,6 +8,9 @@ const MAX_OBSERVABILITY_FILES = 200;
8
8
  const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
9
9
  const SKILL_MD = "SKILL.md";
10
10
  export const SKILL_LINK_BASENAME = ".bopo-skill-link.json";
11
+ /** Optional UI-only label for the skill in Settings (does not rename files or the skill id). */
12
+ export const SKILL_SIDEBAR_TITLE_BASENAME = ".bopo-skill-sidebar-title.json";
13
+ const MAX_SKILL_SIDEBAR_TITLE_CHARS = 200;
11
14
  const SKILL_ID_RE = /^[a-zA-Z0-9_-]+$/;
12
15
  const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
13
16
 
@@ -27,6 +30,8 @@ export type CompanySkillPackageListItem = {
27
30
  skillId: string;
28
31
  linkedUrl: string | null;
29
32
  linkLastFetchedAt: string | null;
33
+ /** When set, shown in Settings sidebar instead of `skillId`. */
34
+ sidebarTitle: string | null;
30
35
  };
31
36
 
32
37
  export function assertCompanySkillId(skillId: string): string {
@@ -101,6 +106,24 @@ export async function readOptionalSkillLinkUrl(root: string): Promise<string | n
101
106
  return rec?.url ?? null;
102
107
  }
103
108
 
109
+ export async function readOptionalSkillSidebarTitle(root: string): Promise<string | null> {
110
+ try {
111
+ const raw = await readFile(join(root, SKILL_SIDEBAR_TITLE_BASENAME), "utf8");
112
+ const parsed: unknown = JSON.parse(raw);
113
+ if (!parsed || typeof parsed !== "object") {
114
+ return null;
115
+ }
116
+ const t = (parsed as { sidebarTitle?: unknown }).sidebarTitle;
117
+ if (typeof t !== "string") {
118
+ return null;
119
+ }
120
+ const s = t.trim();
121
+ return s.length > 0 ? s : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
104
127
  async function writeSkillLinkMetadata(root: string, url: string): Promise<{ lastFetchedAt: string }> {
105
128
  const lastFetchedAt = new Date().toISOString();
106
129
  await writeFile(
@@ -131,10 +154,12 @@ export async function listCompanySkillPackages(input: { companyId: string; maxSk
131
154
  if (!hasMd && !linkedUrl) {
132
155
  continue;
133
156
  }
157
+ const sidebarTitle = await readOptionalSkillSidebarTitle(skillDir);
134
158
  items.push({
135
159
  skillId: ent.name,
136
160
  linkedUrl,
137
- linkLastFetchedAt: linkRec?.lastFetchedAt ?? null
161
+ linkLastFetchedAt: linkRec?.lastFetchedAt ?? null,
162
+ sidebarTitle
138
163
  });
139
164
  if (items.length >= maxSkills) {
140
165
  break;
@@ -227,6 +252,47 @@ export async function readCompanySkillFile(input: {
227
252
  };
228
253
  }
229
254
 
255
+ function assertSkillSidebarTitle(trimmed: string): string {
256
+ if (!trimmed) {
257
+ throw new Error("Title is empty.");
258
+ }
259
+ if (trimmed.length > MAX_SKILL_SIDEBAR_TITLE_CHARS) {
260
+ throw new Error(`Title must be at most ${MAX_SKILL_SIDEBAR_TITLE_CHARS} characters.`);
261
+ }
262
+ if (/[\r\n]/.test(trimmed)) {
263
+ throw new Error("Title cannot contain line breaks.");
264
+ }
265
+ return trimmed;
266
+ }
267
+
268
+ /** Set or clear the Settings-only sidebar label for a company skill package. */
269
+ export async function setCompanySkillSidebarTitle(input: {
270
+ companyId: string;
271
+ skillId: string;
272
+ /** Empty / whitespace removes the custom title (sidebar falls back to skill id). */
273
+ title: string;
274
+ }) {
275
+ const { root, id } = await skillRoot(input.companyId, input.skillId);
276
+ const hasMd = await skillDirHasSkillMd(root);
277
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
278
+ if (!hasMd && !linkedUrl) {
279
+ throw new Error("Skill not found.");
280
+ }
281
+ const metaPath = join(root, SKILL_SIDEBAR_TITLE_BASENAME);
282
+ const trimmedAll = input.title.trim();
283
+ if (!trimmedAll || trimmedAll === id) {
284
+ try {
285
+ await rm(metaPath, { force: true });
286
+ } catch {
287
+ /* noop */
288
+ }
289
+ return { skillId: id, sidebarTitle: null as string | null };
290
+ }
291
+ const t = assertSkillSidebarTitle(trimmedAll);
292
+ await writeFile(metaPath, JSON.stringify({ sidebarTitle: t }, null, 2), "utf8");
293
+ return { skillId: id, sidebarTitle: t };
294
+ }
295
+
230
296
  export async function writeCompanySkillFile(input: {
231
297
  companyId: string;
232
298
  skillId: string;
@@ -263,6 +329,89 @@ export async function writeCompanySkillFile(input: {
263
329
  };
264
330
  }
265
331
 
332
+ /** Rename/move a text file within one skill package directory. */
333
+ export async function renameCompanySkillFile(input: {
334
+ companyId: string;
335
+ skillId: string;
336
+ fromRelativePath: string;
337
+ toRelativePath: string;
338
+ }) {
339
+ const { root } = await skillRoot(input.companyId, input.skillId);
340
+ const fromRel = assertSkillRelativePath(input.fromRelativePath.trim());
341
+ const toRel = assertSkillRelativePath(input.toRelativePath.trim());
342
+ if (fromRel === toRel) {
343
+ return { skillId: input.skillId, relativePath: toRel };
344
+ }
345
+ const fromAbs = resolve(root, fromRel);
346
+ const toAbs = resolve(root, toRel);
347
+ if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
348
+ throw new Error("Requested path is outside of skill directory.");
349
+ }
350
+ const hasMd = await skillDirHasSkillMd(root);
351
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
352
+ if (linkedUrl && !hasMd && fromRel === SKILL_MD) {
353
+ throw new Error("This skill is loaded from a URL only; save a local copy before renaming.");
354
+ }
355
+ const fromInfo = await stat(fromAbs);
356
+ if (!fromInfo.isFile()) {
357
+ throw new Error("Source path is not a file.");
358
+ }
359
+ try {
360
+ await stat(toAbs);
361
+ throw new Error("A file already exists at the destination path.");
362
+ } catch (error) {
363
+ if (error instanceof Error && error.message === "A file already exists at the destination path.") {
364
+ throw error;
365
+ }
366
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
367
+ if (code !== "ENOENT") {
368
+ throw error;
369
+ }
370
+ }
371
+ const parent = dirname(toAbs);
372
+ if (!isInsidePath(root, parent)) {
373
+ throw new Error("Invalid parent directory.");
374
+ }
375
+ await mkdir(parent, { recursive: true });
376
+ await rename(fromAbs, toAbs);
377
+ return { skillId: input.skillId, relativePath: toRel };
378
+ }
379
+
380
+ /** Delete one text file from a skill package. Removing sole local SKILL.md with no URL link deletes the package folder. */
381
+ export async function deleteCompanySkillFile(input: {
382
+ companyId: string;
383
+ skillId: string;
384
+ relativePath: string;
385
+ }) {
386
+ const { root } = await skillRoot(input.companyId, input.skillId);
387
+ const rel = assertSkillRelativePath(input.relativePath.trim());
388
+ const candidate = resolve(root, rel);
389
+ if (!isInsidePath(root, candidate)) {
390
+ throw new Error("Requested path is outside of skill directory.");
391
+ }
392
+ const hasMd = await skillDirHasSkillMd(root);
393
+ const linkedUrl = await readOptionalSkillLinkUrl(root);
394
+ if (rel === SKILL_MD && linkedUrl && !hasMd) {
395
+ throw new Error("This skill is loaded from a URL only; delete the whole skill or refresh from URL.");
396
+ }
397
+ const allFiles = await walkSkillTextFiles(root, MAX_OBSERVABILITY_FILES);
398
+ if (rel === SKILL_MD && allFiles.length > 1) {
399
+ throw new Error("Remove other files in this skill before deleting SKILL.md.");
400
+ }
401
+ const info = await stat(candidate);
402
+ if (!info.isFile()) {
403
+ throw new Error("Requested path is not a file.");
404
+ }
405
+ await rm(candidate, { force: true });
406
+ if (rel === SKILL_MD && allFiles.length === 1) {
407
+ const stillLinked = await readOptionalSkillLinkUrl(root);
408
+ if (!stillLinked) {
409
+ await rm(root, { recursive: true, force: true });
410
+ }
411
+ }
412
+ return { skillId: input.skillId, relativePath: rel };
413
+ }
414
+
266
415
  /** Remove `skills/<id>/` entirely (local files and linked-skill pointer). */
267
416
  export async function deleteCompanySkillPackage(input: { companyId: string; skillId: string }) {
268
417
  const { root, id } = await skillRoot(input.companyId, input.skillId);
@@ -4,6 +4,8 @@ import {
4
4
  AGENT_ROLE_LABELS,
5
5
  AgentCreateRequestSchema,
6
6
  AgentRoleKeySchema,
7
+ PluginInstallSourceTypeSchema,
8
+ PluginManifestV2Schema,
7
9
  TemplateManifestDefault,
8
10
  TemplateManifestSchema
9
11
  } from "bopodev-contracts";
@@ -13,6 +15,7 @@ import {
13
15
  approvalRequests,
14
16
  agents,
15
17
  appendAuditEvent,
18
+ appendPluginInstall,
16
19
  createAgent,
17
20
  createGoal,
18
21
  createIssue,
@@ -28,6 +31,7 @@ import {
28
31
  projects,
29
32
  eq,
30
33
  updateProjectWorkspace,
34
+ markPluginInstallsSuperseded,
31
35
  updatePluginConfig
32
36
  } from "bopodev-db";
33
37
  import {
@@ -44,6 +48,8 @@ import {
44
48
  } from "../lib/instance-paths";
45
49
  import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
46
50
  import { appendDurableFact } from "./memory-file-service";
51
+ import { writePackagedPluginManifestToFilesystem } from "./plugin-manifest-loader";
52
+ import { registerPluginManifest } from "./plugin-runtime";
47
53
  import { applyTemplateManifest } from "./template-apply-service";
48
54
 
49
55
  const approvalGatedActions = new Set([
@@ -97,7 +103,14 @@ const grantPluginCapabilitiesPayloadSchema = z.object({
97
103
  enabled: z.boolean().optional(),
98
104
  priority: z.number().int().min(0).max(1000).optional(),
99
105
  grantedCapabilities: z.array(z.string().min(1)).default([]),
100
- config: z.record(z.string(), z.unknown()).default({})
106
+ capabilityNamespaces: z.array(z.string().min(1)).default([]),
107
+ config: z.record(z.string(), z.unknown()).default({}),
108
+ sourceType: PluginInstallSourceTypeSchema.optional(),
109
+ sourceRef: z.string().optional(),
110
+ integrity: z.string().optional(),
111
+ buildHash: z.string().optional(),
112
+ manifestJson: z.string().optional(),
113
+ install: z.boolean().default(true)
101
114
  });
102
115
  const applyTemplatePayloadSchema = z.object({
103
116
  templateId: z.string().min(1),
@@ -311,6 +324,8 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
311
324
  heartbeatCron: parsed.data.heartbeatCron,
312
325
  monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
313
326
  canHireAgents: parsed.data.canHireAgents,
327
+ canAssignAgents: parsed.data.canAssignAgents,
328
+ canCreateIssues: parsed.data.canCreateIssues,
314
329
  ...runtimeConfigToDb(runtimeConfig),
315
330
  initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
316
331
  });
@@ -556,13 +571,52 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
556
571
  if (!parsed.success) {
557
572
  throw new GovernanceError("Approval payload for plugin capability grant is invalid.");
558
573
  }
574
+ if (parsed.data.manifestJson) {
575
+ let rawManifest: unknown;
576
+ try {
577
+ rawManifest = JSON.parse(parsed.data.manifestJson);
578
+ } catch {
579
+ throw new GovernanceError("Plugin install manifest JSON is invalid.");
580
+ }
581
+ const manifestParsed = PluginManifestV2Schema.safeParse(rawManifest);
582
+ if (!manifestParsed.success) {
583
+ throw new GovernanceError("Plugin install manifest payload failed validation.");
584
+ }
585
+ await writePackagedPluginManifestToFilesystem(manifestParsed.data, {
586
+ sourceType: parsed.data.sourceType ?? "registry",
587
+ sourceRef: parsed.data.sourceRef,
588
+ integrity: parsed.data.integrity,
589
+ buildHash: parsed.data.buildHash
590
+ });
591
+ await registerPluginManifest(db, manifestParsed.data);
592
+ await markPluginInstallsSuperseded(db, {
593
+ companyId,
594
+ pluginId: parsed.data.pluginId
595
+ });
596
+ await appendPluginInstall(db, {
597
+ companyId,
598
+ pluginId: parsed.data.pluginId,
599
+ pluginVersion: manifestParsed.data.version,
600
+ sourceType: parsed.data.sourceType ?? "registry",
601
+ sourceRef: parsed.data.sourceRef ?? null,
602
+ integrity: parsed.data.integrity ?? null,
603
+ buildHash: parsed.data.buildHash ?? null,
604
+ artifactPath: manifestParsed.data.install?.artifactPath ?? null,
605
+ manifestJson: JSON.stringify(manifestParsed.data),
606
+ status: "active"
607
+ });
608
+ }
609
+ const configWithNamespaces = {
610
+ ...parsed.data.config,
611
+ _grantedCapabilityNamespaces: parsed.data.capabilityNamespaces
612
+ };
559
613
  await updatePluginConfig(db, {
560
614
  companyId,
561
615
  pluginId: parsed.data.pluginId,
562
616
  enabled: parsed.data.enabled,
563
617
  priority: parsed.data.priority,
564
618
  grantedCapabilitiesJson: JSON.stringify(parsed.data.grantedCapabilities),
565
- configJson: JSON.stringify(parsed.data.config)
619
+ configJson: JSON.stringify(configWithNamespaces)
566
620
  });
567
621
  return {
568
622
  applied: true,
@@ -572,7 +626,8 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
572
626
  pluginId: parsed.data.pluginId,
573
627
  enabled: parsed.data.enabled ?? null,
574
628
  priority: parsed.data.priority ?? null,
575
- grantedCapabilities: parsed.data.grantedCapabilities
629
+ grantedCapabilities: parsed.data.grantedCapabilities,
630
+ capabilityNamespaces: parsed.data.capabilityNamespaces
576
631
  }
577
632
  };
578
633
  }