bopodev-api 0.1.34 → 0.1.35

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 (90) 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/realtime/office-space.ts +7 -0
  62. package/src/routes/agents.ts +23 -1
  63. package/src/routes/assistant.ts +40 -1
  64. package/src/routes/companies.ts +227 -15
  65. package/src/routes/issues.ts +48 -0
  66. package/src/routes/plugins.ts +393 -103
  67. package/src/routes/{loops.ts → routines.ts} +72 -76
  68. package/src/scripts/onboard-seed.ts +2 -0
  69. package/src/server.ts +3 -1
  70. package/src/services/company-assistant-context-snapshot.ts +4 -2
  71. package/src/services/company-assistant-service.ts +17 -15
  72. package/src/services/company-file-archive-service.ts +56 -3
  73. package/src/services/company-file-import-service.ts +210 -31
  74. package/src/services/governance-service.ts +58 -3
  75. package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
  76. package/src/services/plugin-artifact-installer.ts +115 -0
  77. package/src/services/plugin-artifact-store.ts +28 -0
  78. package/src/services/plugin-capability-policy.ts +31 -0
  79. package/src/services/plugin-jobs-service.ts +74 -0
  80. package/src/services/plugin-manifest-loader.ts +78 -3
  81. package/src/services/plugin-rpc.ts +102 -0
  82. package/src/services/plugin-runtime.ts +240 -209
  83. package/src/services/plugin-worker-host.ts +167 -0
  84. package/src/services/starter-pack-registry.ts +68 -0
  85. package/src/services/template-apply-service.ts +3 -1
  86. package/src/services/template-catalog.ts +29 -0
  87. package/src/services/work-loop-service/work-loop-service.ts +18 -18
  88. package/src/shutdown/graceful-shutdown.ts +3 -1
  89. package/src/worker/scheduler.ts +21 -1
  90. package/src/services/company-export-service.ts +0 -63
@@ -4,7 +4,7 @@ import { unzipSync } from "fflate";
4
4
  import { parse as yamlParse } from "yaml";
5
5
  import { z } from "zod";
6
6
  import type { BopoDb } from "bopodev-db";
7
- import { createAgent, createCompany, createProject } from "bopodev-db";
7
+ import { createAgent, createCompany, createGoal, createProject, updateAgent, updateGoal } from "bopodev-db";
8
8
  import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
9
9
  import {
10
10
  resolveAgentMemoryRootPath,
@@ -16,16 +16,22 @@ import { ensureBuiltinPluginsRegistered } from "./plugin-runtime";
16
16
  import { ensureCompanyBuiltinTemplateDefaults } from "./template-catalog";
17
17
  import { addWorkLoopTrigger, createWorkLoop } from "./work-loop-service/work-loop-service";
18
18
 
19
- const EXPORT_SCHEMA = "bopo/company-export/v1";
19
+ export const EXPORT_SCHEMA = "bopo/company-export/v1";
20
20
 
21
- const BopoExportYamlSchema = z.object({
21
+ const goalLevelSchema = z.enum(["company", "project", "agent"]);
22
+ const goalStatusSchema = z.enum(["draft", "active", "completed", "archived"]);
23
+
24
+ export const BopoExportYamlSchema = z.object({
22
25
  schema: z.string(),
23
26
  company: z.object({
24
27
  name: z.string().min(1),
25
28
  mission: z.string().nullable().optional(),
26
29
  slug: z.string().optional()
27
30
  }),
28
- projects: z.record(z.string(), z.object({ name: z.string().min(1), description: z.string().nullable().optional(), status: z.string().optional() })),
31
+ projects: z.record(
32
+ z.string(),
33
+ z.object({ name: z.string().min(1), description: z.string().nullable().optional(), status: z.string().optional() })
34
+ ),
29
35
  agents: z.record(
30
36
  z.string(),
31
37
  z.object({
@@ -37,9 +43,27 @@ const BopoExportYamlSchema = z.object({
37
43
  managerSlug: z.string().nullable().optional(),
38
44
  providerType: z.string().min(1),
39
45
  heartbeatCron: z.string().min(1),
40
- canHireAgents: z.boolean().optional()
46
+ canHireAgents: z.boolean().optional(),
47
+ canAssignAgents: z.boolean().optional(),
48
+ canCreateIssues: z.boolean().optional(),
49
+ bootstrapPrompt: z.string().nullable().optional(),
50
+ monthlyBudgetUsd: z.union([z.string(), z.number()]).optional()
41
51
  })
42
52
  ),
53
+ goals: z
54
+ .record(
55
+ z.string(),
56
+ z.object({
57
+ level: goalLevelSchema,
58
+ title: z.string().min(1),
59
+ description: z.string().nullable().optional(),
60
+ status: goalStatusSchema.optional(),
61
+ projectSlug: z.string().nullable().optional(),
62
+ parentGoalSlug: z.string().nullable().optional(),
63
+ ownerAgentSlug: z.string().nullable().optional()
64
+ })
65
+ )
66
+ .optional(),
43
67
  routines: z
44
68
  .record(
45
69
  z.string(),
@@ -62,6 +86,13 @@ const BopoExportYamlSchema = z.object({
62
86
  .optional()
63
87
  });
64
88
 
89
+ export type BopoExportDoc = z.infer<typeof BopoExportYamlSchema>;
90
+
91
+ export type ParsedCompanyPackage = {
92
+ doc: BopoExportDoc;
93
+ entries: Record<string, string>;
94
+ };
95
+
65
96
  export class CompanyFileImportError extends Error {
66
97
  constructor(message: string) {
67
98
  super(message);
@@ -77,7 +108,7 @@ function normalizeZipPath(key: string): string | null {
77
108
  return t;
78
109
  }
79
110
 
80
- function decodeZipEntries(buffer: Buffer): Record<string, string> {
111
+ export function decodeZipEntries(buffer: Buffer): Record<string, string> {
81
112
  let raw: Record<string, Uint8Array>;
82
113
  try {
83
114
  raw = unzipSync(new Uint8Array(buffer));
@@ -99,6 +130,30 @@ function decodeZipEntries(buffer: Buffer): Record<string, string> {
99
130
  return out;
100
131
  }
101
132
 
133
+ /** Parse and validate a company zip; throws CompanyFileImportError on failure. */
134
+ export function parseCompanyZipBuffer(buffer: Buffer): ParsedCompanyPackage {
135
+ const entries = decodeZipEntries(buffer);
136
+ const yamlText = entries[".bopo.yaml"] ?? entries["bopo.yaml"];
137
+ if (!yamlText?.trim()) {
138
+ throw new CompanyFileImportError('Zip must contain a ".bopo.yaml" manifest at the archive root.');
139
+ }
140
+ let parsedYaml: unknown;
141
+ try {
142
+ parsedYaml = yamlParse(yamlText);
143
+ } catch {
144
+ throw new CompanyFileImportError(".bopo.yaml is not valid YAML.");
145
+ }
146
+ const parsed = BopoExportYamlSchema.safeParse(parsedYaml);
147
+ if (!parsed.success) {
148
+ throw new CompanyFileImportError(`Invalid export manifest: ${parsed.error.message}`);
149
+ }
150
+ const doc = parsed.data;
151
+ if (doc.schema !== EXPORT_SCHEMA) {
152
+ throw new CompanyFileImportError(`Unsupported export schema '${doc.schema}' (expected ${EXPORT_SCHEMA}).`);
153
+ }
154
+ return { doc, entries };
155
+ }
156
+
102
157
  const PROVIDER_TYPES = new Set([
103
158
  "claude_code",
104
159
  "codex",
@@ -118,36 +173,60 @@ function coerceProviderType(raw: string): AgentProvider {
118
173
  return PROVIDER_TYPES.has(raw) ? (raw as AgentProvider) : "shell";
119
174
  }
120
175
 
121
- export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Promise<{ companyId: string; name: string }> {
122
- const entries = decodeZipEntries(buffer);
123
- const yamlText = entries[".bopo.yaml"] ?? entries["bopo.yaml"];
124
- if (!yamlText?.trim()) {
125
- throw new CompanyFileImportError('Zip must contain a ".bopo.yaml" manifest at the archive root.');
176
+ function formatMonthlyBudgetUsdFromManifest(raw: string | number | undefined): string {
177
+ if (raw === undefined) {
178
+ return "100.0000";
126
179
  }
127
- let parsedYaml: unknown;
128
- try {
129
- parsedYaml = yamlParse(yamlText);
130
- } catch {
131
- throw new CompanyFileImportError(".bopo.yaml is not valid YAML.");
180
+ if (typeof raw === "number" && Number.isFinite(raw)) {
181
+ return raw.toFixed(4);
132
182
  }
133
- const parsed = BopoExportYamlSchema.safeParse(parsedYaml);
134
- if (!parsed.success) {
135
- throw new CompanyFileImportError(`Invalid export manifest: ${parsed.error.message}`);
183
+ const s = String(raw).trim();
184
+ if (!s) {
185
+ return "100.0000";
136
186
  }
137
- const doc = parsed.data;
138
- if (doc.schema !== EXPORT_SCHEMA) {
139
- throw new CompanyFileImportError(`Unsupported export schema '${doc.schema}' (expected ${EXPORT_SCHEMA}).`);
187
+ const n = Number(s);
188
+ return Number.isFinite(n) ? n.toFixed(4) : "100.0000";
189
+ }
190
+
191
+ function sortGoalSlugsForImport(
192
+ goals: Record<string, { parentGoalSlug?: string | null | undefined }>
193
+ ): string[] {
194
+ const slugs = Object.keys(goals);
195
+ const slugSet = new Set(slugs);
196
+ const remaining = new Set(slugs);
197
+ const order: string[] = [];
198
+ while (remaining.size > 0) {
199
+ const ready = [...remaining].filter((slug) => {
200
+ const p = goals[slug]?.parentGoalSlug?.trim();
201
+ if (!p) {
202
+ return true;
203
+ }
204
+ if (!slugSet.has(p)) {
205
+ throw new CompanyFileImportError(`Goal '${slug}' references unknown parent goal slug '${p}'.`);
206
+ }
207
+ return !remaining.has(p);
208
+ });
209
+ if (ready.length === 0) {
210
+ throw new CompanyFileImportError("Circular goal parent chain in manifest.");
211
+ }
212
+ ready.sort((a, b) => a.localeCompare(b));
213
+ for (const s of ready) {
214
+ order.push(s);
215
+ remaining.delete(s);
216
+ }
140
217
  }
218
+ return order;
219
+ }
141
220
 
142
- const created = await createCompany(db, {
143
- name: doc.company.name,
144
- mission: doc.company.mission ?? null
145
- });
146
- const companyId = created.id;
221
+ /**
222
+ * Seeds projects, agents, workspace files, goals, and routines for an existing company.
223
+ * Does not create the company row or call ensureCompanyBuiltinTemplateDefaults.
224
+ */
225
+ export async function seedOperationalDataFromPackage(db: BopoDb, companyId: string, parsed: ParsedCompanyPackage): Promise<void> {
226
+ const { doc, entries } = parsed;
147
227
  const cwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
148
228
  await mkdir(cwd, { recursive: true });
149
229
  await ensureBuiltinPluginsRegistered(db, [companyId]);
150
- await ensureCompanyBuiltinTemplateDefaults(db, companyId);
151
230
 
152
231
  const projectSlugToId = new Map<string, string>();
153
232
  const projectStatuses = new Set(["planned", "active", "paused", "blocked", "completed", "archived"]);
@@ -183,7 +262,11 @@ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Pr
183
262
  }
184
263
  const defaultRt = normalizeRuntimeConfig({
185
264
  defaultRuntimeCwd: cwd,
186
- runtimeConfig: { runtimeModel: undefined, runtimeEnv: {} }
265
+ runtimeConfig: {
266
+ runtimeModel: undefined,
267
+ runtimeEnv: {},
268
+ bootstrapPrompt: a.bootstrapPrompt?.trim() || undefined
269
+ }
187
270
  });
188
271
  const createdAgent = await createAgent(db, {
189
272
  companyId,
@@ -195,8 +278,10 @@ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Pr
195
278
  name: a.name,
196
279
  providerType: coerceProviderType(a.providerType),
197
280
  heartbeatCron: a.heartbeatCron,
198
- monthlyBudgetUsd: "100.0000",
281
+ monthlyBudgetUsd: formatMonthlyBudgetUsdFromManifest(a.monthlyBudgetUsd),
199
282
  canHireAgents: a.canHireAgents ?? false,
283
+ canAssignAgents: a.canAssignAgents ?? true,
284
+ canCreateIssues: a.canCreateIssues ?? true,
200
285
  ...runtimeConfigToDb(defaultRt),
201
286
  initialState: runtimeConfigToStateBlobPatch(defaultRt)
202
287
  });
@@ -246,6 +331,54 @@ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Pr
246
331
  }
247
332
  }
248
333
 
334
+ const goalsManifest = doc.goals ?? {};
335
+ const goalSlugToId = new Map<string, string>();
336
+ for (const slug of sortGoalSlugsForImport(goalsManifest)) {
337
+ const g = goalsManifest[slug]!;
338
+ const level = g.level;
339
+ const projectSlug = g.projectSlug?.trim() || null;
340
+ const projectId = projectSlug ? projectSlugToId.get(projectSlug) ?? null : null;
341
+ if (level === "project" && !projectId) {
342
+ throw new CompanyFileImportError(`Goal '${slug}' (project level) references unknown project slug '${projectSlug ?? ""}'.`);
343
+ }
344
+ if (level === "company" && projectId) {
345
+ throw new CompanyFileImportError(`Goal '${slug}' is company-level but specifies a project.`);
346
+ }
347
+ const parentSlug = g.parentGoalSlug?.trim() || null;
348
+ const parentGoalId = parentSlug ? goalSlugToId.get(parentSlug) ?? null : null;
349
+ if (parentSlug && !parentGoalId) {
350
+ throw new CompanyFileImportError(`Goal '${slug}' references unknown parent goal slug '${parentSlug}'.`);
351
+ }
352
+ const ownerSlug = g.ownerAgentSlug?.trim() || null;
353
+ const ownerAgentId = ownerSlug ? agentSlugToId.get(ownerSlug) ?? null : null;
354
+ if (ownerSlug && !ownerAgentId) {
355
+ throw new CompanyFileImportError(`Goal '${slug}' references unknown owner agent slug '${ownerSlug}'.`);
356
+ }
357
+ const agentLevelProjectId = level === "agent" ? projectId : null;
358
+ if (level === "agent" && g.projectSlug?.trim() && !projectId) {
359
+ throw new CompanyFileImportError(`Goal '${slug}' (agent level) references unknown project slug '${g.projectSlug.trim()}'.`);
360
+ }
361
+
362
+ const created = await createGoal(db, {
363
+ companyId,
364
+ projectId: level === "project" ? projectId : agentLevelProjectId,
365
+ parentGoalId,
366
+ ownerAgentId,
367
+ level,
368
+ title: g.title,
369
+ description: g.description?.trim() || undefined
370
+ });
371
+ goalSlugToId.set(slug, created.id);
372
+ const st = g.status?.trim();
373
+ if (st && st !== "draft") {
374
+ await updateGoal(db, {
375
+ companyId,
376
+ id: created.id,
377
+ status: st
378
+ });
379
+ }
380
+ }
381
+
249
382
  const routines = doc.routines ?? {};
250
383
  for (const [, r] of Object.entries(routines)) {
251
384
  const projectId = projectSlugToId.get(r.projectSlug);
@@ -266,7 +399,7 @@ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Pr
266
399
  for (const t of r.triggers) {
267
400
  await addWorkLoopTrigger(db, {
268
401
  companyId,
269
- workLoopId: loop.id,
402
+ routineId: loop.id,
270
403
  cronExpression: t.cronExpression,
271
404
  timezone: t.timezone?.trim() || "UTC",
272
405
  label: t.label ?? null,
@@ -274,6 +407,52 @@ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Pr
274
407
  });
275
408
  }
276
409
  }
410
+ }
411
+
412
+ export function assertManifestHasCeoAgent(doc: BopoExportDoc): void {
413
+ const hasCeo = Object.values(doc.agents).some((a) => (a.roleKey ?? "").trim().toLowerCase() === "ceo");
414
+ if (!hasCeo) {
415
+ throw new CompanyFileImportError("Company package must include an agent with roleKey 'ceo'.");
416
+ }
417
+ }
418
+
419
+ export function summarizeCompanyPackageForPreview(parsed: ParsedCompanyPackage): {
420
+ companyName: string;
421
+ counts: {
422
+ projects: number;
423
+ agents: number;
424
+ goals: number;
425
+ routines: number;
426
+ skillFiles: number;
427
+ };
428
+ hasCeo: boolean;
429
+ } {
430
+ const doc = parsed.doc;
431
+ const skillFiles = Object.keys(parsed.entries).filter((k) => k.startsWith("skills/") && !k.endsWith("/")).length;
432
+ const hasCeo = Object.values(doc.agents).some((a) => (a.roleKey ?? "").trim().toLowerCase() === "ceo");
433
+ return {
434
+ companyName: doc.company.name,
435
+ counts: {
436
+ projects: Object.keys(doc.projects).length,
437
+ agents: Object.keys(doc.agents).length,
438
+ goals: Object.keys(doc.goals ?? {}).length,
439
+ routines: Object.keys(doc.routines ?? {}).length,
440
+ skillFiles
441
+ },
442
+ hasCeo
443
+ };
444
+ }
277
445
 
446
+ export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Promise<{ companyId: string; name: string }> {
447
+ const parsed = parseCompanyZipBuffer(buffer);
448
+ const doc = parsed.doc;
449
+
450
+ const created = await createCompany(db, {
451
+ name: doc.company.name,
452
+ mission: doc.company.mission ?? null
453
+ });
454
+ const companyId = created.id;
455
+ await ensureCompanyBuiltinTemplateDefaults(db, companyId);
456
+ await seedOperationalDataFromPackage(db, companyId, parsed);
278
457
  return { companyId, name: doc.company.name };
279
458
  }
@@ -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
  }
@@ -723,6 +723,8 @@ export async function runHeartbeatForAgent(
723
723
  agentId: agent.id,
724
724
  heartbeatRunId: runId,
725
725
  canHireAgents: agent.canHireAgents,
726
+ canAssignAgents: agent.canAssignAgents ?? true,
727
+ canCreateIssues: agent.canCreateIssues ?? true,
726
728
  wakeContext: options?.wakeContext
727
729
  }),
728
730
  ...(linkedMaterialized.root ? { BOPODEV_MATERIALIZED_LINKED_SKILLS_ROOT: linkedMaterialized.root } : {})
@@ -4077,6 +4079,8 @@ function buildHeartbeatRuntimeEnv(input: {
4077
4079
  agentId: string;
4078
4080
  heartbeatRunId: string;
4079
4081
  canHireAgents: boolean;
4082
+ canAssignAgents: boolean;
4083
+ canCreateIssues: boolean;
4080
4084
  wakeContext?: HeartbeatWakeContext;
4081
4085
  }) {
4082
4086
  const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(input.companyId);
@@ -4085,6 +4089,7 @@ function buildHeartbeatRuntimeEnv(input: {
4085
4089
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
4086
4090
  // agents:write is required for PUT /agents/:self (bootstrapPrompt, runtimeConfig). Route handlers
4087
4091
  // still forbid agents from updating other agents' rows and from POST /agents unless canHireAgents.
4092
+ // Issue create/assignee changes for agent actors are gated by canCreateIssues / canAssignAgents (see issues routes).
4088
4093
  const actorPermissions = ["issues:write", "agents:write"].join(",");
4089
4094
  const actorHeaders = JSON.stringify({
4090
4095
  "x-company-id": input.companyId,
@@ -4115,6 +4120,8 @@ function buildHeartbeatRuntimeEnv(input: {
4115
4120
  BOPODEV_REQUEST_HEADERS_JSON: actorHeaders,
4116
4121
  BOPODEV_REQUEST_APPROVAL_DEFAULT: "true",
4117
4122
  BOPODEV_CAN_HIRE_AGENTS: input.canHireAgents ? "true" : "false",
4123
+ BOPODEV_CAN_ASSIGN_AGENTS: input.canAssignAgents ? "true" : "false",
4124
+ BOPODEV_CAN_CREATE_ISSUES: input.canCreateIssues ? "true" : "false",
4118
4125
  ...(input.wakeContext?.reason ? { BOPODEV_WAKE_REASON: input.wakeContext.reason } : {}),
4119
4126
  ...(input.wakeContext?.commentId ? { BOPODEV_WAKE_COMMENT_ID: input.wakeContext.commentId } : {}),
4120
4127
  ...(input.wakeContext?.issueIds?.length ? { BOPODEV_LINKED_ISSUE_IDS: input.wakeContext.issueIds.join(",") } : {}),
@@ -0,0 +1,115 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { PluginManifestV2Schema, type PluginManifestV2 } from "bopodev-contracts";
8
+ import { buildPluginArtifactInstallDir, ensurePluginArtifactsDir, sanitizeArtifactSegment } from "./plugin-artifact-store";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ type NpmPackResultRow = {
13
+ filename: string;
14
+ integrity?: string;
15
+ };
16
+
17
+ export async function installPluginArtifactFromNpm(input: { packageName: string; version?: string }) {
18
+ const packageRef = input.version?.trim() ? `${input.packageName}@${input.version.trim()}` : input.packageName.trim();
19
+ if (!packageRef) {
20
+ throw new Error("packageName is required.");
21
+ }
22
+ const artifactsRoot = await ensurePluginArtifactsDir();
23
+ const tempRoot = await mkdtemp(resolve(tmpdir(), "bopo-plugin-pack-"));
24
+ try {
25
+ const { stdout } = await execFileAsync("npm", ["pack", packageRef, "--json"], {
26
+ cwd: tempRoot,
27
+ maxBuffer: 5 * 1024 * 1024
28
+ });
29
+ const parsed = JSON.parse(stdout.trim()) as NpmPackResultRow[] | NpmPackResultRow;
30
+ const row = Array.isArray(parsed) ? parsed[0] : parsed;
31
+ if (!row?.filename) {
32
+ throw new Error("npm pack did not return a filename.");
33
+ }
34
+ const tarballPath = resolve(tempRoot, row.filename);
35
+ const tarball = await readFile(tarballPath);
36
+ const buildHash = createHash("sha256").update(tarball).digest("hex");
37
+ const installDir = buildPluginArtifactInstallDir({
38
+ pluginId: sanitizeArtifactSegment(input.packageName),
39
+ version: input.version?.trim() || "latest",
40
+ buildHash
41
+ });
42
+ await mkdir(installDir, { recursive: true });
43
+ await execFileAsync("tar", ["-xzf", tarballPath, "-C", installDir], {
44
+ maxBuffer: 5 * 1024 * 1024
45
+ });
46
+ const packageRoot = resolve(installDir, "package");
47
+ const packageJsonRaw = await readFile(resolve(packageRoot, "package.json"), "utf8");
48
+ const packageJsonParsed = JSON.parse(packageJsonRaw) as Record<string, unknown>;
49
+ const manifestPath = resolveManifestPath(packageRoot, packageJsonParsed);
50
+ const manifestRaw = await readFile(manifestPath, "utf8");
51
+ const manifestParsed = PluginManifestV2Schema.parse(JSON.parse(manifestRaw) as unknown);
52
+ const normalizedManifest = normalizeManifestEntrypoints(packageRoot, manifestParsed, {
53
+ packageName: input.packageName.trim(),
54
+ packageRef,
55
+ integrity: row.integrity,
56
+ buildHash,
57
+ artifactPath: packageRoot
58
+ });
59
+ return {
60
+ manifest: normalizedManifest,
61
+ packageRoot,
62
+ packageRef,
63
+ buildHash,
64
+ integrity: row.integrity
65
+ };
66
+ } finally {
67
+ await rm(tempRoot, { recursive: true, force: true });
68
+ await mkdir(artifactsRoot, { recursive: true });
69
+ }
70
+ }
71
+
72
+ function resolveManifestPath(packageRoot: string, packageJson: Record<string, unknown>) {
73
+ const bopo = packageJson.bopo;
74
+ if (typeof bopo === "object" && bopo !== null) {
75
+ const maybe = (bopo as Record<string, unknown>).pluginManifest;
76
+ if (typeof maybe === "string" && maybe.trim()) {
77
+ return resolve(packageRoot, maybe);
78
+ }
79
+ }
80
+ const legacy = packageJson.bopoPluginManifest;
81
+ if (typeof legacy === "string" && legacy.trim()) {
82
+ return resolve(packageRoot, legacy);
83
+ }
84
+ return resolve(packageRoot, "plugin.json");
85
+ }
86
+
87
+ function normalizeManifestEntrypoints(
88
+ packageRoot: string,
89
+ manifest: PluginManifestV2,
90
+ input: {
91
+ packageName: string;
92
+ packageRef: string;
93
+ integrity?: string;
94
+ buildHash: string;
95
+ artifactPath: string;
96
+ }
97
+ ): PluginManifestV2 {
98
+ return {
99
+ ...manifest,
100
+ apiVersion: "2",
101
+ entrypoints: {
102
+ worker: resolve(packageRoot, manifest.entrypoints.worker),
103
+ ui: manifest.entrypoints.ui ? resolve(packageRoot, manifest.entrypoints.ui) : undefined
104
+ },
105
+ install: {
106
+ sourceType: "registry",
107
+ sourceRef: input.packageRef,
108
+ integrity: input.integrity,
109
+ buildHash: input.buildHash || randomUUID(),
110
+ installedAt: new Date().toISOString(),
111
+ artifactPath: input.artifactPath,
112
+ packageName: input.packageName
113
+ }
114
+ };
115
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ export function resolvePluginArtifactsDir() {
5
+ return process.env.BOPO_PLUGIN_ARTIFACTS_DIR || resolve(process.cwd(), ".bopo", "plugin-artifacts");
6
+ }
7
+
8
+ export async function ensurePluginArtifactsDir() {
9
+ const dir = resolvePluginArtifactsDir();
10
+ await mkdir(dir, { recursive: true });
11
+ return dir;
12
+ }
13
+
14
+ export function sanitizeArtifactSegment(value: string) {
15
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-");
16
+ }
17
+
18
+ export function buildPluginArtifactInstallDir(input: { pluginId: string; version: string; buildHash: string }) {
19
+ const root = resolvePluginArtifactsDir();
20
+ const plugin = sanitizeArtifactSegment(input.pluginId);
21
+ const version = sanitizeArtifactSegment(input.version);
22
+ const hash = sanitizeArtifactSegment(input.buildHash.slice(0, 16));
23
+ return resolve(root, plugin, `${version}-${hash}`);
24
+ }
25
+
26
+ export async function removePluginArtifactInstallDir(path: string) {
27
+ await rm(path, { recursive: true, force: true });
28
+ }
@@ -0,0 +1,31 @@
1
+ import { PLUGIN_CAPABILITY_RISK, type PluginCapabilityNamespace } from "bopodev-contracts";
2
+
3
+ const LEGACY_HIGH_RISK = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
4
+
5
+ export type PluginTrustLevel = "dev_local" | "verified" | "restricted";
6
+
7
+ function resolveTrustLevel(): PluginTrustLevel {
8
+ const raw = process.env.BOPO_PLUGIN_TRUST_LEVEL;
9
+ if (raw === "dev_local" || raw === "verified" || raw === "restricted") {
10
+ return raw;
11
+ }
12
+ return "verified";
13
+ }
14
+
15
+ export function legacyCapabilitiesRequireApproval(capabilities: string[]) {
16
+ return capabilities.some((cap) => LEGACY_HIGH_RISK.has(cap));
17
+ }
18
+
19
+ export function namespacedCapabilitiesRequireApproval(capabilities: PluginCapabilityNamespace[]) {
20
+ const trustLevel = resolveTrustLevel();
21
+ return capabilities.some((cap) => {
22
+ const risk = PLUGIN_CAPABILITY_RISK[cap];
23
+ if (risk === "restricted") {
24
+ return true;
25
+ }
26
+ if (risk === "elevated") {
27
+ return trustLevel !== "dev_local";
28
+ }
29
+ return false;
30
+ });
31
+ }