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
@@ -139,6 +139,7 @@ async function loadOfficeOccupantForAgent(db: BopoDb, companyId: string, agentId
139
139
  companyId: agents.companyId,
140
140
  name: agents.name,
141
141
  avatarSeed: agents.avatarSeed,
142
+ lucideIconName: agents.lucideIconName,
142
143
  role: agents.role,
143
144
  status: agents.status,
144
145
  providerType: agents.providerType,
@@ -222,6 +223,7 @@ function deriveAgentOccupant(
222
223
  companyId: string;
223
224
  name: string;
224
225
  avatarSeed?: string | null;
226
+ lucideIconName?: string | null;
225
227
  role: string;
226
228
  status: string;
227
229
  providerType: string;
@@ -300,6 +302,7 @@ function deriveAgentOccupant(
300
302
  taskLabel: `${formatActionLabel(pendingApproval.action)} approval`,
301
303
  providerType: normalizeProviderType(agent.providerType),
302
304
  avatarSeed: agent.avatarSeed ?? null,
305
+ lucideIconName: agent.lucideIconName?.trim() ? agent.lucideIconName.trim() : null,
303
306
  focusEntityType: "approval",
304
307
  focusEntityId: pendingApproval.id,
305
308
  updatedAt: pendingApproval.createdAt.toISOString()
@@ -320,6 +323,7 @@ function deriveAgentOccupant(
320
323
  taskLabel: claimedIssues[0]?.title ?? "Checking in on work",
321
324
  providerType: normalizeProviderType(agent.providerType),
322
325
  avatarSeed: agent.avatarSeed ?? null,
326
+ lucideIconName: agent.lucideIconName?.trim() ? agent.lucideIconName.trim() : null,
323
327
  focusEntityType: claimedIssues[0] ? "issue" : "agent",
324
328
  focusEntityId: claimedIssues[0]?.id ?? agent.id,
325
329
  updatedAt: activeRun.startedAt.toISOString()
@@ -340,6 +344,7 @@ function deriveAgentOccupant(
340
344
  taskLabel: "Paused",
341
345
  providerType: normalizeProviderType(agent.providerType),
342
346
  avatarSeed: agent.avatarSeed ?? null,
347
+ lucideIconName: agent.lucideIconName?.trim() ? agent.lucideIconName.trim() : null,
343
348
  focusEntityType: "agent",
344
349
  focusEntityId: agent.id,
345
350
  updatedAt: agent.updatedAt.toISOString()
@@ -359,6 +364,7 @@ function deriveAgentOccupant(
359
364
  taskLabel: nextAssignedIssue ? `Up next: ${nextAssignedIssue.title}` : "Waiting for work",
360
365
  providerType: normalizeProviderType(agent.providerType),
361
366
  avatarSeed: agent.avatarSeed ?? null,
367
+ lucideIconName: agent.lucideIconName?.trim() ? agent.lucideIconName.trim() : null,
362
368
  focusEntityType: nextAssignedIssue ? "issue" : "agent",
363
369
  focusEntityId: nextAssignedIssue?.id ?? agent.id,
364
370
  updatedAt: nextAssignedIssue?.updatedAt.toISOString() ?? agent.updatedAt.toISOString()
@@ -396,6 +402,7 @@ function deriveHireCandidateOccupant(approval: {
396
402
  taskLabel: "Awaiting hire approval",
397
403
  providerType,
398
404
  avatarSeed: null,
405
+ lucideIconName: null,
399
406
  focusEntityType: "approval",
400
407
  focusEntityId: approval.id,
401
408
  updatedAt: approval.createdAt.toISOString()
@@ -108,6 +108,8 @@ const UPDATE_AGENT_ALLOWED_KEYS = new Set([
108
108
  "heartbeatCron",
109
109
  "monthlyBudgetUsd",
110
110
  "canHireAgents",
111
+ "canAssignAgents",
112
+ "canCreateIssues",
111
113
  "runtimeConfig",
112
114
  "runtimeCommand",
113
115
  "runtimeArgs",
@@ -120,7 +122,9 @@ const UPDATE_AGENT_ALLOWED_KEYS = new Set([
120
122
  "interruptGraceSec",
121
123
  "runtimeEnv",
122
124
  "runPolicy",
123
- "enabledSkillIds"
125
+ "enabledSkillIds",
126
+ "lucideIconName",
127
+ "avatarSeed"
124
128
  ]);
125
129
  const UPDATE_RUNTIME_CONFIG_ALLOWED_KEYS = new Set([
126
130
  "runtimeCommand",
@@ -485,6 +489,8 @@ export function createAgentsRouter(ctx: AppContext) {
485
489
  heartbeatCron: parsed.data.heartbeatCron,
486
490
  monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
487
491
  canHireAgents: parsed.data.canHireAgents,
492
+ canAssignAgents: parsed.data.canAssignAgents,
493
+ canCreateIssues: parsed.data.canCreateIssues,
488
494
  ...runtimeConfigToDb(runtimeConfig),
489
495
  initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
490
496
  });
@@ -537,6 +543,12 @@ export function createAgentsRouter(ctx: AppContext) {
537
543
  if (parsed.data.canHireAgents !== undefined) {
538
544
  forbiddenFieldUpdates.push("canHireAgents");
539
545
  }
546
+ if (parsed.data.canAssignAgents !== undefined) {
547
+ forbiddenFieldUpdates.push("canAssignAgents");
548
+ }
549
+ if (parsed.data.canCreateIssues !== undefined) {
550
+ forbiddenFieldUpdates.push("canCreateIssues");
551
+ }
540
552
  if (parsed.data.status !== undefined) {
541
553
  forbiddenFieldUpdates.push("status");
542
554
  }
@@ -616,10 +628,18 @@ export function createAgentsRouter(ctx: AppContext) {
616
628
  if (requiresRuntimeCwd(effectiveProviderType) && hasText(effectiveRuntimeCwd)) {
617
629
  await mkdir(effectiveRuntimeCwd, { recursive: true });
618
630
  }
631
+ const nextLucideIconName =
632
+ parsed.data.lucideIconName === undefined ? undefined : parsed.data.lucideIconName.trim();
633
+
634
+ const nextAvatarSeed =
635
+ parsed.data.avatarSeed === undefined ? undefined : parsed.data.avatarSeed.trim();
636
+
619
637
  const agent = await updateAgent(ctx.db, {
620
638
  companyId: req.companyId!,
621
639
  id: req.params.agentId,
622
640
  managerAgentId: parsed.data.managerAgentId,
641
+ lucideIconName: nextLucideIconName,
642
+ avatarSeed: nextAvatarSeed,
623
643
  role:
624
644
  parsed.data.role !== undefined || parsed.data.roleKey !== undefined || parsed.data.title !== undefined
625
645
  ? resolveAgentRoleText(
@@ -639,6 +659,8 @@ export function createAgentsRouter(ctx: AppContext) {
639
659
  monthlyBudgetUsd:
640
660
  typeof parsed.data.monthlyBudgetUsd === "number" ? parsed.data.monthlyBudgetUsd.toFixed(4) : undefined,
641
661
  canHireAgents: parsed.data.canHireAgents,
662
+ canAssignAgents: parsed.data.canAssignAgents,
663
+ canCreateIssues: parsed.data.canCreateIssues,
642
664
  ...runtimeConfigToDb(nextRuntime),
643
665
  stateBlob: runtimeConfigToStateBlobPatch(nextRuntime)
644
666
  });
@@ -2,9 +2,11 @@ import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  createAssistantThread,
5
+ deleteAssistantThread,
5
6
  getAssistantThreadById,
6
7
  getOrCreateAssistantThread,
7
- listAssistantMessages
8
+ listAssistantMessages,
9
+ listAssistantThreadsForCompany
8
10
  } from "bopodev-db";
9
11
  import type { AppContext } from "../context";
10
12
  import { sendError, sendOk } from "../http";
@@ -30,6 +32,20 @@ export function createAssistantRouter(ctx: AppContext) {
30
32
  return sendOk(res, { brains: listAskAssistantBrains() });
31
33
  });
32
34
 
35
+ router.get("/threads", async (req, res) => {
36
+ const companyId = req.companyId!;
37
+ const rawLimit = Number(req.query.limit ?? 50);
38
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 100) : 50;
39
+ const rows = await listAssistantThreadsForCompany(ctx.db, companyId, limit);
40
+ return sendOk(res, {
41
+ threads: rows.map((t) => ({
42
+ id: t.id,
43
+ updatedAt: t.updatedAt.toISOString(),
44
+ preview: t.previewBody ? truncateAssistantPreview(t.previewBody) : null
45
+ }))
46
+ });
47
+ });
48
+
33
49
  router.get("/messages", async (req, res) => {
34
50
  const companyId = req.companyId!;
35
51
  const qThread =
@@ -97,6 +113,19 @@ export function createAssistantRouter(ctx: AppContext) {
97
113
  return sendOk(res, { threadId: thread.id });
98
114
  });
99
115
 
116
+ router.delete("/threads/:threadId", async (req, res) => {
117
+ const companyId = req.companyId!;
118
+ const threadId = typeof req.params.threadId === "string" ? req.params.threadId.trim() : "";
119
+ if (!threadId) {
120
+ return sendError(res, "threadId is required.", 422);
121
+ }
122
+ const ok = await deleteAssistantThread(ctx.db, companyId, threadId);
123
+ if (!ok) {
124
+ return sendError(res, "Chat thread not found.", 404);
125
+ }
126
+ return sendOk(res, { deleted: true });
127
+ });
128
+
100
129
  return router;
101
130
  }
102
131
 
@@ -107,3 +136,13 @@ function safeJsonParse(raw: string): unknown {
107
136
  return null;
108
137
  }
109
138
  }
139
+
140
+ const ASSISTANT_THREAD_PREVIEW_MAX = 120;
141
+
142
+ function truncateAssistantPreview(body: string): string {
143
+ const singleLine = body.replace(/\s+/g, " ").trim();
144
+ if (singleLine.length <= ASSISTANT_THREAD_PREVIEW_MAX) {
145
+ return singleLine;
146
+ }
147
+ return `${singleLine.slice(0, ASSISTANT_THREAD_PREVIEW_MAX - 1)}…`;
148
+ }
@@ -3,8 +3,18 @@ import type { NextFunction, Request, Response } from "express";
3
3
  import { Router } from "express";
4
4
  import multer from "multer";
5
5
  import { z } from "zod";
6
- import { CompanySchema } from "bopodev-contracts";
7
- import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
6
+ import { CompanySchema, TemplateManifestSchema } from "bopodev-contracts";
7
+ import {
8
+ createAgent,
9
+ createCompany,
10
+ deleteCompany,
11
+ getCurrentTemplateVersion,
12
+ getTemplateBySlug,
13
+ listAgents,
14
+ listCompanies,
15
+ updateAgent,
16
+ updateCompany
17
+ } from "bopodev-db";
8
18
  import type { AppContext } from "../context";
9
19
  import { sendError, sendOk, sendOkValidated } from "../http";
10
20
  import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
@@ -19,10 +29,22 @@ import {
19
29
  pipeCompanyExportZip,
20
30
  readCompanyExportFileText
21
31
  } from "../services/company-file-archive-service";
22
- import { buildCompanyPortabilityExport } from "../services/company-export-service";
23
- import { CompanyFileImportError, importCompanyFromZipBuffer } from "../services/company-file-import-service";
24
- import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
25
- import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
32
+ import {
33
+ assertManifestHasCeoAgent,
34
+ CompanyFileImportError,
35
+ importCompanyFromZipBuffer,
36
+ parseCompanyZipBuffer,
37
+ seedOperationalDataFromPackage,
38
+ summarizeCompanyPackageForPreview
39
+ } from "../services/company-file-import-service";
40
+ import { ensureBuiltinPluginsRegistered } from "../services/plugin-runtime";
41
+ import { listStarterPackMetadata, readStarterPackZipBuffer, resolveStarterPackDefinition } from "../services/starter-pack-registry";
42
+ import { TemplateApplyError, applyTemplateManifest } from "../services/template-apply-service";
43
+ import {
44
+ ensureCompanyBuiltinTemplateDefaults,
45
+ getBuiltinStarterTemplateBySlug,
46
+ type BuiltinStarterTemplateDefinition
47
+ } from "../services/template-catalog";
26
48
 
27
49
  const zipUpload = multer({
28
50
  storage: multer.memoryStorage(),
@@ -43,7 +65,8 @@ const createCompanySchema = z.object({
43
65
  providerType: z
44
66
  .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "http", "shell"])
45
67
  .optional(),
46
- runtimeModel: z.string().optional()
68
+ runtimeModel: z.string().optional(),
69
+ starterPackId: z.string().min(1).optional()
47
70
  });
48
71
 
49
72
  const updateCompanySchema = z
@@ -73,6 +96,36 @@ export function createCompaniesRouter(ctx: AppContext) {
73
96
  );
74
97
  });
75
98
 
99
+ router.get("/starter-packs", requireBoardRole, async (_req, res) => {
100
+ return sendOk(res, { starterPacks: listStarterPackMetadata() });
101
+ });
102
+
103
+ router.post("/import/files/preview", requireBoardRole, zipUpload.single("archive"), async (req, res) => {
104
+ const file = req.file;
105
+ if (!file?.buffer) {
106
+ return sendError(res, 'Upload a .zip file in field "archive".', 422);
107
+ }
108
+ try {
109
+ const parsed = parseCompanyZipBuffer(file.buffer);
110
+ const summary = summarizeCompanyPackageForPreview(parsed);
111
+ const warnings: string[] = [];
112
+ if (!summary.hasCeo) {
113
+ warnings.push("No agent with roleKey 'ceo' found; the company may not be ready to run until you add a CEO.");
114
+ }
115
+ return sendOk(res, { ok: true, ...summary, errors: [] as string[], warnings });
116
+ } catch (err) {
117
+ const message = err instanceof CompanyFileImportError ? err.message : String(err);
118
+ return sendOk(res, {
119
+ ok: false,
120
+ companyName: "",
121
+ counts: { projects: 0, agents: 0, goals: 0, routines: 0, skillFiles: 0, knowledgeFiles: 0 },
122
+ hasCeo: false,
123
+ errors: [message],
124
+ warnings: [] as string[]
125
+ });
126
+ }
127
+ });
128
+
76
129
  router.post("/import/files", requireBoardRole, zipUpload.single("archive"), async (req, res) => {
77
130
  const file = req.file;
78
131
  if (!file?.buffer) {
@@ -173,11 +226,11 @@ export function createCompaniesRouter(ctx: AppContext) {
173
226
  if (!canAccessCompany(req, companyId)) {
174
227
  return sendError(res, "Actor does not have access to this company.", 403);
175
228
  }
176
- const payload = await buildCompanyPortabilityExport(ctx.db, companyId);
177
- if (!payload) {
178
- return sendError(res, "Company not found.", 404);
179
- }
180
- return sendOk(res, payload);
229
+ return sendError(
230
+ res,
231
+ "JSON company export was removed. Use POST /companies/:companyId/export/files/zip for the portable company zip.",
232
+ 410
233
+ );
181
234
  });
182
235
 
183
236
  router.post("/", requireBoardRole, async (req, res) => {
@@ -185,14 +238,142 @@ export function createCompaniesRouter(ctx: AppContext) {
185
238
  if (!parsed.success) {
186
239
  return sendError(res, parsed.error.message, 422);
187
240
  }
188
- const company = await createCompany(ctx.db, parsed.data);
189
241
  const providerType =
190
242
  parseAgentProvider(parsed.data.providerType) ??
191
243
  parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ??
192
244
  "shell";
245
+ const requestedModel = parsed.data.runtimeModel?.trim() || process.env[DEFAULT_AGENT_MODEL_ENV]?.trim() || undefined;
246
+
247
+ const companyInput = {
248
+ name: parsed.data.name,
249
+ mission: parsed.data.mission ?? null
250
+ };
251
+
252
+ if (parsed.data.starterPackId?.trim()) {
253
+ const packId = parsed.data.starterPackId.trim();
254
+ const builtinTemplate = getBuiltinStarterTemplateBySlug(packId);
255
+ const zipPack = resolveStarterPackDefinition(packId);
256
+ if (!builtinTemplate && !zipPack) {
257
+ return sendError(res, `Unknown starter pack: ${packId}`, 422);
258
+ }
259
+
260
+ const company = await createCompany(ctx.db, companyInput);
261
+ await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
262
+ await ensureBuiltinPluginsRegistered(ctx.db, [company.id]);
263
+
264
+ try {
265
+ if (builtinTemplate) {
266
+ const templateRow = await getTemplateBySlug(ctx.db, company.id, packId);
267
+ if (!templateRow) {
268
+ return sendError(res, `Starter template '${packId}' was not registered.`, 500);
269
+ }
270
+ const templateVersion = await getCurrentTemplateVersion(ctx.db, company.id, templateRow.id);
271
+ if (!templateVersion) {
272
+ return sendError(res, `Starter template '${packId}' has no current version.`, 500);
273
+ }
274
+ let manifestRaw: unknown;
275
+ try {
276
+ manifestRaw = JSON.parse(templateVersion.manifestJson) as unknown;
277
+ } catch {
278
+ return sendError(res, `Starter template '${packId}' has invalid manifest JSON.`, 500);
279
+ }
280
+ const manifestParsed = TemplateManifestSchema.safeParse(manifestRaw);
281
+ if (!manifestParsed.success) {
282
+ return sendError(res, `Starter template '${packId}' has invalid manifest: ${manifestParsed.error.message}`, 422);
283
+ }
284
+ const variables = buildStarterTemplateVariables(builtinTemplate, companyInput);
285
+ await applyTemplateManifest(ctx.db, {
286
+ companyId: company.id,
287
+ templateId: templateRow.id,
288
+ templateVersion: templateVersion.version,
289
+ templateVersionId: templateVersion.id,
290
+ manifest: manifestParsed.data,
291
+ variables
292
+ });
293
+ } else {
294
+ let packBuffer: Buffer;
295
+ try {
296
+ packBuffer = await readStarterPackZipBuffer(packId);
297
+ } catch (err) {
298
+ return sendError(res, `Failed to read starter pack: ${String(err)}`, 500);
299
+ }
300
+ let parsedPackage;
301
+ try {
302
+ parsedPackage = parseCompanyZipBuffer(packBuffer);
303
+ assertManifestHasCeoAgent(parsedPackage.doc);
304
+ } catch (err) {
305
+ const message = err instanceof CompanyFileImportError ? err.message : String(err);
306
+ return sendError(res, message, 422);
307
+ }
308
+ await seedOperationalDataFromPackage(ctx.db, company.id, parsedPackage);
309
+ }
310
+ } catch (err) {
311
+ const message =
312
+ err instanceof TemplateApplyError
313
+ ? err.message
314
+ : err instanceof CompanyFileImportError
315
+ ? err.message
316
+ : String(err);
317
+ return sendError(res, message, 422);
318
+ }
319
+
320
+ const agents = await listAgents(ctx.db, company.id);
321
+ const rk = (a: (typeof agents)[number]) => (a.roleKey ?? "").toLowerCase();
322
+ const leaderAgent =
323
+ agents.find((a) => rk(a) === "ceo") ??
324
+ agents.find((a) => rk(a) === "cmo") ??
325
+ agents.find((a) => a.canHireAgents) ??
326
+ agents[0] ??
327
+ null;
328
+ if (!leaderAgent) {
329
+ return sendError(res, "Starter did not yield an agent to attach the selected CEO runtime to.", 422);
330
+ }
331
+
332
+ const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, company.id);
333
+ const resolvedRuntimeModel = resolveRuntimeModelForProvider(
334
+ providerType,
335
+ await resolveOpencodeRuntimeModel(
336
+ providerType,
337
+ normalizeRuntimeConfig({
338
+ defaultRuntimeCwd,
339
+ runtimeConfig: {
340
+ runtimeModel: requestedModel,
341
+ runtimeEnv: resolveSeedRuntimeEnv(providerType)
342
+ }
343
+ })
344
+ )
345
+ );
346
+ const bootstrapPrompt = leaderAgent.bootstrapPrompt?.trim()
347
+ ? leaderAgent.bootstrapPrompt
348
+ : buildDefaultCeoBootstrapPrompt();
349
+ const defaultRuntimeConfig = normalizeRuntimeConfig({
350
+ defaultRuntimeCwd,
351
+ runtimeConfig: {
352
+ runtimeModel: resolvedRuntimeModel,
353
+ bootstrapPrompt,
354
+ runtimeEnv: resolveSeedRuntimeEnv(providerType),
355
+ ...(providerType === "shell"
356
+ ? {
357
+ runtimeCommand: "echo",
358
+ runtimeArgs: ["ceo bootstrap heartbeat"]
359
+ }
360
+ : {})
361
+ }
362
+ });
363
+ await updateAgent(ctx.db, {
364
+ companyId: company.id,
365
+ id: leaderAgent.id,
366
+ providerType,
367
+ ...runtimeConfigToDb(defaultRuntimeConfig),
368
+ stateBlob: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
369
+ });
370
+
371
+ return sendOk(res, company);
372
+ }
373
+
374
+ const company = await createCompany(ctx.db, companyInput);
193
375
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, company.id);
194
376
  await mkdir(defaultRuntimeCwd, { recursive: true });
195
- const requestedModel = parsed.data.runtimeModel?.trim() || process.env[DEFAULT_AGENT_MODEL_ENV]?.trim() || undefined;
196
377
  const resolvedRuntimeModel = resolveRuntimeModelForProvider(
197
378
  providerType,
198
379
  await resolveOpencodeRuntimeModel(
@@ -232,10 +413,12 @@ export function createCompaniesRouter(ctx: AppContext) {
232
413
  heartbeatCron: "*/5 * * * *",
233
414
  monthlyBudgetUsd: "100.0000",
234
415
  canHireAgents: true,
416
+ canAssignAgents: true,
417
+ canCreateIssues: true,
235
418
  ...runtimeConfigToDb(defaultRuntimeConfig),
236
419
  initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
237
420
  });
238
- await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
421
+ await ensureBuiltinPluginsRegistered(ctx.db, [company.id]);
239
422
  await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
240
423
  return sendOk(res, company);
241
424
  });
@@ -272,6 +455,35 @@ export function createCompaniesRouter(ctx: AppContext) {
272
455
  return router;
273
456
  }
274
457
 
458
+ function buildStarterTemplateVariables(
459
+ definition: BuiltinStarterTemplateDefinition,
460
+ input: { name: string; mission: string | null }
461
+ ): Record<string, unknown> {
462
+ const name = input.name.trim();
463
+ const missionTail = (input.mission ?? "").trim() || name;
464
+ const defaults: Record<string, string> = {
465
+ brandName: name,
466
+ productName: name,
467
+ targetAudience: missionTail,
468
+ primaryChannel: "LinkedIn"
469
+ };
470
+ const out: Record<string, unknown> = {};
471
+ for (const v of definition.variables) {
472
+ const key = v.key;
473
+ if (defaults[key] !== undefined) {
474
+ out[key] = defaults[key]!;
475
+ continue;
476
+ }
477
+ const dv = v.defaultValue;
478
+ if (dv !== undefined && dv !== null && String(dv).length > 0) {
479
+ out[key] = dv;
480
+ continue;
481
+ }
482
+ out[key] = missionTail;
483
+ }
484
+ return out;
485
+ }
486
+
275
487
  function requireCompanyWriteAccess(req: Request, res: Response, next: NextFunction) {
276
488
  const targetCompanyId = readCompanyIdParam(req);
277
489
  if (!targetCompanyId) {
@@ -16,6 +16,7 @@ import {
16
16
  deleteIssue,
17
17
  desc,
18
18
  eq,
19
+ getCompanyAgent,
19
20
  getIssue,
20
21
  heartbeatRuns,
21
22
  getIssueAttachment,
@@ -40,10 +41,16 @@ import {
40
41
  type CommentRecipientInput,
41
42
  type PersistedCommentRecipient
42
43
  } from "../lib/comment-recipients";
44
+ import {
45
+ agentIssueCreateAssigneeForbiddenMessage,
46
+ agentIssueCreateForbiddenMessage,
47
+ agentIssuePutAssigneeChangeForbiddenMessage
48
+ } from "../lib/agent-issue-permissions";
43
49
  import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
44
50
  import { requireCompanyScope } from "../middleware/company-scope";
45
51
  import { enforcePermission } from "../middleware/request-actor";
46
52
  import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
53
+ import { knowledgeFileExists } from "../services/company-knowledge-file-service";
47
54
  import { publishAttentionSnapshot } from "../realtime/attention";
48
55
  import {
49
56
  createIssueCommentLegacySchema,
@@ -102,10 +109,26 @@ function normalizeOptionalExternalLink(value: string | null | undefined) {
102
109
  return trimmed.length > 0 ? trimmed : null;
103
110
  }
104
111
 
112
+ async function validateIssueKnowledgePaths(companyId: string, paths: string[]): Promise<string | null> {
113
+ for (const p of paths) {
114
+ if (!(await knowledgeFileExists({ companyId, relativePath: p }))) {
115
+ return `Knowledge file not found: ${p}`;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
105
121
  function toIssueResponse(issue: Record<string, unknown>, goalIds: string[] = []) {
106
122
  const labels = parseStringArray(issue.labelsJson);
107
123
  const tags = parseStringArray(issue.tagsJson);
108
- const { labelsJson: _labelsJson, tagsJson: _tagsJson, goalId: _legacyGoalId, ...rest } = issue as Record<string, unknown> & {
124
+ const knowledgePaths = parseStringArray(issue.knowledgePathsJson);
125
+ const {
126
+ labelsJson: _labelsJson,
127
+ tagsJson: _tagsJson,
128
+ knowledgePathsJson: _knowledgePathsJson,
129
+ goalId: _legacyGoalId,
130
+ ...rest
131
+ } = issue as Record<string, unknown> & {
109
132
  goalId?: unknown;
110
133
  };
111
134
  const externalRaw = rest.externalLink;
@@ -116,7 +139,8 @@ function toIssueResponse(issue: Record<string, unknown>, goalIds: string[] = [])
116
139
  externalLink,
117
140
  labels,
118
141
  tags,
119
- goalIds
142
+ goalIds,
143
+ knowledgePaths
120
144
  };
121
145
  }
122
146
 
@@ -170,6 +194,28 @@ export function createIssuesRouter(ctx: AppContext) {
170
194
  if (!parsed.success) {
171
195
  return sendError(res, parsed.error.message, 422);
172
196
  }
197
+ if (req.actor?.type === "agent") {
198
+ const agentRow = await getCompanyAgent(ctx.db, req.companyId!, req.actor.id);
199
+ if (!agentRow) {
200
+ return sendError(res, "Agent not found.", 403);
201
+ }
202
+ const orchestration = {
203
+ canAssignAgents: agentRow.canAssignAgents ?? true,
204
+ canCreateIssues: agentRow.canCreateIssues ?? true
205
+ };
206
+ const createDeny = agentIssueCreateForbiddenMessage(orchestration);
207
+ if (createDeny) {
208
+ return sendError(res, createDeny, 403);
209
+ }
210
+ const assignDeny = agentIssueCreateAssigneeForbiddenMessage(
211
+ orchestration,
212
+ req.actor.id,
213
+ parsed.data.assigneeAgentId ?? null
214
+ );
215
+ if (assignDeny) {
216
+ return sendError(res, assignDeny, 403);
217
+ }
218
+ }
173
219
  if (parsed.data.assigneeAgentId) {
174
220
  const assignmentValidation = await validateIssueAssignmentScope(
175
221
  ctx,
@@ -181,6 +227,12 @@ export function createIssuesRouter(ctx: AppContext) {
181
227
  return sendError(res, assignmentValidation, 422);
182
228
  }
183
229
  }
230
+ if (parsed.data.knowledgePaths.length > 0) {
231
+ const kpErr = await validateIssueKnowledgePaths(req.companyId!, parsed.data.knowledgePaths);
232
+ if (kpErr) {
233
+ return sendError(res, kpErr, 422);
234
+ }
235
+ }
184
236
  const issue = await createIssue(ctx.db, {
185
237
  companyId: req.companyId!,
186
238
  projectId: parsed.data.projectId,
@@ -193,7 +245,8 @@ export function createIssuesRouter(ctx: AppContext) {
193
245
  priority: parsed.data.priority,
194
246
  assigneeAgentId: parsed.data.assigneeAgentId,
195
247
  labels: parsed.data.labels,
196
- tags: parsed.data.tags
248
+ tags: parsed.data.tags,
249
+ knowledgePaths: parsed.data.knowledgePaths
197
250
  });
198
251
  await appendActivity(ctx.db, {
199
252
  companyId: req.companyId!,
@@ -538,6 +591,26 @@ export function createIssuesRouter(ctx: AppContext) {
538
591
  if (!existingIssue) {
539
592
  return sendError(res, "Issue not found.", 404);
540
593
  }
594
+ if (req.actor?.type === "agent" && parsed.data.assigneeAgentId !== undefined) {
595
+ const agentRow = await getCompanyAgent(ctx.db, req.companyId!, req.actor.id);
596
+ if (!agentRow) {
597
+ return sendError(res, "Agent not found.", 403);
598
+ }
599
+ const orchestration = {
600
+ canAssignAgents: agentRow.canAssignAgents ?? true,
601
+ canCreateIssues: agentRow.canCreateIssues ?? true
602
+ };
603
+ const prevAssignee = existingIssue.assigneeAgentId;
604
+ const putDeny = agentIssuePutAssigneeChangeForbiddenMessage(
605
+ orchestration,
606
+ req.actor.id,
607
+ prevAssignee,
608
+ parsed.data.assigneeAgentId!
609
+ );
610
+ if (putDeny) {
611
+ return sendError(res, putDeny, 403);
612
+ }
613
+ }
541
614
  const effectiveProjectId = parsed.data.projectId ?? existingIssue.projectId;
542
615
  const effectiveAssigneeAgentId =
543
616
  parsed.data.assigneeAgentId === undefined ? existingIssue.assigneeAgentId : parsed.data.assigneeAgentId;
@@ -558,6 +631,12 @@ export function createIssuesRouter(ctx: AppContext) {
558
631
  if (updateBody.externalLink !== undefined) {
559
632
  updateBody.externalLink = normalizeOptionalExternalLink(updateBody.externalLink) ?? null;
560
633
  }
634
+ if (updateBody.knowledgePaths !== undefined && updateBody.knowledgePaths.length > 0) {
635
+ const kpErr = await validateIssueKnowledgePaths(req.companyId!, updateBody.knowledgePaths);
636
+ if (kpErr) {
637
+ return sendError(res, kpErr, 422);
638
+ }
639
+ }
561
640
  const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...updateBody });
562
641
  if (!issue) {
563
642
  return sendError(res, "Issue not found.", 404);