bopodev-api 0.1.8 → 0.1.9

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.
@@ -0,0 +1,314 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { mkdir } from "node:fs/promises";
3
+ import {
4
+ bootstrapDatabase,
5
+ createAgent,
6
+ createCompany,
7
+ createIssue,
8
+ createProject,
9
+ listAgents,
10
+ listCompanies,
11
+ listIssues,
12
+ listProjects,
13
+ updateIssue,
14
+ updateAgent,
15
+ updateCompany
16
+ } from "bopodev-db";
17
+ import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
18
+ import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
19
+
20
+ export interface OnboardSeedSummary {
21
+ companyId: string;
22
+ companyName: string;
23
+ companyCreated: boolean;
24
+ ceoCreated: boolean;
25
+ ceoProviderType: AgentProvider;
26
+ ceoMigrated: boolean;
27
+ }
28
+
29
+ const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
30
+ const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
31
+ const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
32
+ type AgentProvider = "codex" | "claude_code" | "shell";
33
+ const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
34
+ const STARTUP_PROJECT_NAME = "Leadership Setup";
35
+ const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
36
+ const CEO_STARTUP_TASK_MARKER = "[bopohq:onboarding:ceo-startup:v1]";
37
+
38
+ export async function ensureOnboardingSeed(input: {
39
+ dbPath?: string;
40
+ companyName: string;
41
+ companyId?: string;
42
+ agentProvider?: AgentProvider;
43
+ }): Promise<OnboardSeedSummary> {
44
+ const companyName = input.companyName.trim();
45
+ if (companyName.length === 0) {
46
+ throw new Error("BOPO_DEFAULT_COMPANY_NAME is required for onboarding seed.");
47
+ }
48
+ const agentProvider = parseAgentProvider(input.agentProvider) ?? "shell";
49
+
50
+ const { db, client } = await bootstrapDatabase(input.dbPath);
51
+
52
+ try {
53
+ const companies = await listCompanies(db);
54
+ let companyRow =
55
+ (input.companyId ? companies.find((entry) => entry.id === input.companyId) : undefined) ??
56
+ companies.find((entry) => entry.name === companyName);
57
+ let companyCreated = false;
58
+
59
+ if (!companyRow) {
60
+ const createdCompany = await createCompany(db, { name: companyName });
61
+ companyRow = {
62
+ id: createdCompany.id,
63
+ name: createdCompany.name,
64
+ mission: createdCompany.mission ?? null,
65
+ createdAt: new Date()
66
+ };
67
+ companyCreated = true;
68
+ } else if (companyRow.name !== companyName) {
69
+ companyRow = (await updateCompany(db, { id: companyRow.id, name: companyName })) ?? companyRow;
70
+ }
71
+
72
+ const companyId = companyRow.id;
73
+ const resolvedCompanyName = companyRow.name;
74
+ const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
75
+ await mkdir(defaultRuntimeCwd, { recursive: true });
76
+ const defaultRuntimeConfig = normalizeRuntimeConfig({
77
+ defaultRuntimeCwd,
78
+ runtimeConfig: {
79
+ runtimeEnv: resolveSeedRuntimeEnv(agentProvider)
80
+ }
81
+ });
82
+ const agents = await listAgents(db, companyId);
83
+ const existingCeo = agents.find((agent) => agent.role === "CEO" || agent.name === "CEO");
84
+ let ceoCreated = false;
85
+ let ceoMigrated = false;
86
+ let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
87
+
88
+ let ceoId = existingCeo?.id ?? null;
89
+
90
+ if (!existingCeo) {
91
+ const ceo = await createAgent(db, {
92
+ companyId,
93
+ role: "CEO",
94
+ name: "CEO",
95
+ providerType: agentProvider,
96
+ heartbeatCron: "*/5 * * * *",
97
+ monthlyBudgetUsd: "100.0000",
98
+ canHireAgents: true,
99
+ ...runtimeConfigToDb(defaultRuntimeConfig),
100
+ initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
101
+ });
102
+ ceoId = ceo.id;
103
+ ceoCreated = true;
104
+ ceoProviderType = agentProvider;
105
+ } else if (isBootstrapCeoRuntime(existingCeo.providerType, existingCeo.stateBlob)) {
106
+ const nextState = {
107
+ ...stripRuntimeFromState(existingCeo.stateBlob),
108
+ ...runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
109
+ };
110
+ await updateAgent(db, {
111
+ companyId,
112
+ id: existingCeo.id,
113
+ providerType: agentProvider,
114
+ ...runtimeConfigToDb(defaultRuntimeConfig),
115
+ stateBlob: nextState
116
+ });
117
+ ceoMigrated = true;
118
+ ceoProviderType = agentProvider;
119
+ ceoId = existingCeo.id;
120
+ } else {
121
+ ceoId = existingCeo.id;
122
+ }
123
+
124
+ if (ceoId) {
125
+ const startupProjectId = await ensureStartupProject(db, companyId);
126
+ await ensureCeoStartupTask(db, {
127
+ companyId,
128
+ projectId: startupProjectId,
129
+ ceoId
130
+ });
131
+ }
132
+
133
+ return {
134
+ companyId,
135
+ companyName: resolvedCompanyName,
136
+ companyCreated,
137
+ ceoCreated,
138
+ ceoProviderType,
139
+ ceoMigrated
140
+ };
141
+ } finally {
142
+ const maybeClose = (client as { close?: () => Promise<void> }).close;
143
+ if (maybeClose) {
144
+ await maybeClose.call(client);
145
+ }
146
+ }
147
+ }
148
+
149
+ async function ensureStartupProject(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"], companyId: string) {
150
+ const projects = await listProjects(db, companyId);
151
+ const existing = projects.find((project) => project.name === STARTUP_PROJECT_NAME);
152
+ if (existing) {
153
+ return existing.id;
154
+ }
155
+ const created = await createProject(db, {
156
+ companyId,
157
+ name: STARTUP_PROJECT_NAME,
158
+ description: "Initial leadership onboarding and operating setup."
159
+ });
160
+ return created.id;
161
+ }
162
+
163
+ async function ensureCeoStartupTask(
164
+ db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
165
+ input: { companyId: string; projectId: string; ceoId: string }
166
+ ) {
167
+ const issues = await listIssues(db, input.companyId);
168
+ const existing = issues.find(
169
+ (issue) =>
170
+ issue.assigneeAgentId === input.ceoId &&
171
+ issue.title === CEO_STARTUP_TASK_TITLE &&
172
+ typeof issue.body === "string" &&
173
+ issue.body.includes(CEO_STARTUP_TASK_MARKER)
174
+ );
175
+ const body = [
176
+ CEO_STARTUP_TASK_MARKER,
177
+ "",
178
+ "Stand up your leadership operating baseline before taking on additional delivery work.",
179
+ "",
180
+ "1. Create the folder `agents/ceo/` in the repository workspace.",
181
+ "2. Author these files with your own voice and responsibilities:",
182
+ " - `agents/ceo/AGENTS.md`",
183
+ " - `agents/ceo/HEARTBEAT.md`",
184
+ " - `agents/ceo/SOUL.md`",
185
+ " - `agents/ceo/TOOLS.md`",
186
+ "3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
187
+ " - Supported simple body: `{ \"bootstrapPrompt\": \"Primary operating reference: agents/ceo/AGENTS.md ...\" }`",
188
+ " - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
189
+ " - Use a temp JSON file or heredoc with `curl --data @file`; do not hand-escape multiline JSON.",
190
+ "4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
191
+ " - `GET /agents` may be wrapped as `{ \"ok\": true, \"data\": [...] }` or returned as a raw array.",
192
+ " - Shape-safe filter: `jq -er --arg id \"$BOPOHQ_AGENT_ID\" '(.data? // .) | if type==\"array\" then . else [] end | map(select(.id == $id))[0] | {id,name,role,bootstrapPrompt}'`",
193
+ "5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
194
+ "6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
195
+ " - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
196
+ " - optional `managerAgentId`, `bootstrapPrompt`, `runtimeConfig`, `canHireAgents`",
197
+ " - `requestApproval: true` and `sourceIssueId`",
198
+ "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
199
+ "",
200
+ "Safety checks before requesting hire:",
201
+ "- Do not request duplicates if a Founding Engineer already exists.",
202
+ "- Do not request duplicates if a pending approval for the same role is already open.",
203
+ "- Do not assume `python` is installed in the runtime shell; prefer direct headers, `node`, or `jq` when scripting.",
204
+ "- Shell commands run under `zsh`; avoid Bash-only features such as `local -n`, `declare -n`, `mapfile`, and `readarray`."
205
+ ].join("\n");
206
+ if (existing) {
207
+ if (existing.body !== body) {
208
+ await updateIssue(db, {
209
+ companyId: input.companyId,
210
+ id: existing.id,
211
+ body
212
+ });
213
+ }
214
+ return existing.id;
215
+ }
216
+
217
+ const startupIssue = await createIssue(db, {
218
+ companyId: input.companyId,
219
+ projectId: input.projectId,
220
+ title: CEO_STARTUP_TASK_TITLE,
221
+ body,
222
+ status: "todo",
223
+ priority: "high",
224
+ assigneeAgentId: input.ceoId,
225
+ labels: ["onboarding", "leadership", "agent-setup"],
226
+ tags: ["ceo-startup"]
227
+ });
228
+ return startupIssue.id;
229
+ }
230
+
231
+ async function main() {
232
+ const companyName = process.env[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
233
+ const companyId = process.env[DEFAULT_COMPANY_ID_ENV]?.trim() || undefined;
234
+ const agentProvider = parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ?? undefined;
235
+ const result = await ensureOnboardingSeed({
236
+ dbPath: process.env.BOPO_DB_PATH,
237
+ companyName,
238
+ companyId,
239
+ agentProvider
240
+ });
241
+ // eslint-disable-next-line no-console
242
+ console.log(JSON.stringify(result));
243
+ }
244
+
245
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
246
+ void main();
247
+ }
248
+
249
+ function parseAgentProvider(value: unknown): AgentProvider | null {
250
+ if (value === "codex" || value === "claude_code" || value === "shell") {
251
+ return value;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
257
+ if (agentProvider !== "codex") {
258
+ return {};
259
+ }
260
+ const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
261
+ if (!key) {
262
+ return {};
263
+ }
264
+ return {
265
+ OPENAI_API_KEY: key
266
+ };
267
+ }
268
+
269
+ function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
270
+ if (providerType !== "shell") {
271
+ return false;
272
+ }
273
+ const runtime = parseRuntimeFromState(stateBlob);
274
+ if (!runtime || runtime.command !== "echo") {
275
+ return false;
276
+ }
277
+ const args = Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry).toLowerCase()) : [];
278
+ return args.some((entry) => entry.includes(CEO_BOOTSTRAP_SUMMARY));
279
+ }
280
+
281
+ function parseRuntimeFromState(stateBlob: string | null): { command?: string; args?: string[] } | null {
282
+ if (!stateBlob) {
283
+ return null;
284
+ }
285
+ try {
286
+ const parsed = JSON.parse(stateBlob) as { runtime?: { command?: unknown; args?: unknown } };
287
+ const runtime = parsed.runtime;
288
+ if (!runtime || typeof runtime !== "object") {
289
+ return null;
290
+ }
291
+ return {
292
+ command: typeof runtime.command === "string" ? runtime.command : undefined,
293
+ args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined
294
+ };
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ function stripRuntimeFromState(stateBlob: string | null) {
301
+ if (!stateBlob) {
302
+ return {};
303
+ }
304
+ try {
305
+ const parsed = JSON.parse(stateBlob) as Record<string, unknown>;
306
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
307
+ return {};
308
+ }
309
+ const { runtime: _runtime, ...rest } = parsed;
310
+ return rest;
311
+ } catch {
312
+ return {};
313
+ }
314
+ }
package/src/server.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createServer } from "node:http";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { sql } from "drizzle-orm";
5
+ import { config as loadDotenv } from "dotenv";
3
6
  import { bootstrapDatabase } from "bopodev-db";
4
7
  import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
5
8
  import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
@@ -9,6 +12,8 @@ import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
9
12
  import { attachRealtimeHub } from "./realtime/hub";
10
13
  import { createHeartbeatScheduler } from "./worker/scheduler";
11
14
 
15
+ loadApiEnv();
16
+
12
17
  async function main() {
13
18
  const dbPath = process.env.BOPO_DB_PATH;
14
19
  const port = Number(process.env.PORT ?? 4020);
@@ -59,12 +64,11 @@ async function main() {
59
64
  console.log(`BopoHQ API running on http://localhost:${port}`);
60
65
  });
61
66
 
62
- const configuredSchedulerCompanyIds = parseSchedulerCompanyIds(process.env.BOPO_SCHEDULER_COMPANY_IDS);
63
- if (process.env.BOPO_DEFAULT_COMPANY_ID && !process.env.BOPO_SCHEDULER_COMPANY_IDS) {
64
- // eslint-disable-next-line no-console
65
- console.warn("[startup] BOPO_DEFAULT_COMPANY_ID no longer scopes heartbeat sweeps; scheduler now sweeps all companies.");
67
+ const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
68
+ const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
69
+ if (schedulerCompanyId) {
70
+ createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
66
71
  }
67
- createHeartbeatScheduler(db, realtimeHub, { companyIds: configuredSchedulerCompanyIds });
68
72
  }
69
73
 
70
74
  void main();
@@ -79,6 +83,34 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
79
83
  return (result.rows ?? []).length > 0;
80
84
  }
81
85
 
86
+ async function resolveSchedulerCompanyId(
87
+ db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
88
+ configuredCompanyId: string | null
89
+ ) {
90
+ if (configuredCompanyId) {
91
+ const configured = await db.execute(sql`
92
+ SELECT id
93
+ FROM companies
94
+ WHERE id = ${configuredCompanyId}
95
+ LIMIT 1
96
+ `);
97
+ if ((configured.rows ?? []).length > 0) {
98
+ return configuredCompanyId;
99
+ }
100
+ // eslint-disable-next-line no-console
101
+ console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
102
+ }
103
+
104
+ const fallback = await db.execute(sql`
105
+ SELECT id
106
+ FROM companies
107
+ ORDER BY created_at ASC
108
+ LIMIT 1
109
+ `);
110
+ const id = fallback.rows?.[0]?.id;
111
+ return typeof id === "string" && id.length > 0 ? id : null;
112
+ }
113
+
82
114
  function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
83
115
  const red = process.stderr.isTTY ? "\x1b[31m" : "";
84
116
  const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
@@ -93,13 +125,11 @@ function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
93
125
  }
94
126
  }
95
127
 
96
- function parseSchedulerCompanyIds(rawValue?: string) {
97
- if (!rawValue || rawValue.trim().length === 0) {
98
- return undefined;
128
+ function loadApiEnv() {
129
+ const sourceDir = dirname(fileURLToPath(import.meta.url));
130
+ const repoRoot = resolve(sourceDir, "../../../");
131
+ const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
132
+ for (const path of candidates) {
133
+ loadDotenv({ path, override: false, quiet: true });
99
134
  }
100
- const ids = rawValue
101
- .split(",")
102
- .map((value) => value.trim())
103
- .filter((value) => value.length > 0);
104
- return ids.length > 0 ? ids : undefined;
105
135
  }
@@ -1,7 +1,26 @@
1
1
  import { and, eq } from "drizzle-orm";
2
+ import { mkdir } from "node:fs/promises";
2
3
  import { z } from "zod";
4
+ import { AgentCreateRequestSchema } from "bopodev-contracts";
3
5
  import type { BopoDb } from "bopodev-db";
4
- import { approvalRequests, createAgent, createGoal, goals, projects } from "bopodev-db";
6
+ import {
7
+ approvalRequests,
8
+ createAgent,
9
+ createGoal,
10
+ createIssue,
11
+ createProject,
12
+ goals,
13
+ listIssues,
14
+ listProjects,
15
+ projects
16
+ } from "bopodev-db";
17
+ import {
18
+ normalizeRuntimeConfig,
19
+ requiresRuntimeCwd,
20
+ runtimeConfigToDb,
21
+ runtimeConfigToStateBlobPatch
22
+ } from "../lib/agent-config";
23
+ import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
5
24
 
6
25
  const approvalGatedActions = new Set([
7
26
  "hire_agent",
@@ -11,18 +30,23 @@ const approvalGatedActions = new Set([
11
30
  "terminate_agent"
12
31
  ]);
13
32
 
14
- const hireAgentPayloadSchema = z.object({
15
- managerAgentId: z.string().optional(),
16
- role: z.string().min(1),
17
- name: z.string().min(1),
18
- providerType: z.enum(["claude_code", "codex", "http", "shell"]),
19
- heartbeatCron: z.string().min(1),
20
- monthlyBudgetUsd: z.number().nonnegative(),
21
- canHireAgents: z.boolean().default(false),
33
+ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
22
34
  runtimeCommand: z.string().optional(),
23
35
  runtimeArgs: z.array(z.string()).optional(),
24
36
  runtimeCwd: z.string().optional(),
25
- runtimeTimeoutMs: z.number().int().positive().max(600000).optional()
37
+ runtimeTimeoutMs: z.number().int().positive().max(600000).optional(),
38
+ runtimeModel: z.string().optional(),
39
+ runtimeThinkingEffort: z.enum(["auto", "low", "medium", "high"]).optional(),
40
+ bootstrapPrompt: z.string().optional(),
41
+ runtimeTimeoutSec: z.number().int().nonnegative().optional(),
42
+ interruptGraceSec: z.number().int().nonnegative().optional(),
43
+ runtimeEnv: z.record(z.string(), z.string()).optional(),
44
+ runPolicy: z
45
+ .object({
46
+ sandboxMode: z.enum(["workspace_write", "full_access"]).optional(),
47
+ allowWebSearch: z.boolean().optional()
48
+ })
49
+ .optional()
26
50
  });
27
51
 
28
52
  const activateGoalPayloadSchema = z.object({
@@ -32,6 +56,8 @@ const activateGoalPayloadSchema = z.object({
32
56
  title: z.string().min(1),
33
57
  description: z.string().optional()
34
58
  });
59
+ const AGENT_STARTUP_PROJECT_NAME = "Agent Onboarding";
60
+ const AGENT_STARTUP_TASK_MARKER = "[bopohq:onboarding:agent-startup:v1]";
35
61
 
36
62
  export class GovernanceError extends Error {
37
63
  constructor(message: string) {
@@ -132,6 +158,30 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
132
158
  if (!parsed.success) {
133
159
  throw new GovernanceError("Approval payload for agent hiring is invalid.");
134
160
  }
161
+ const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
162
+ const runtimeConfig = normalizeRuntimeConfig({
163
+ runtimeConfig: parsed.data.runtimeConfig,
164
+ legacy: {
165
+ runtimeCommand: parsed.data.runtimeCommand,
166
+ runtimeArgs: parsed.data.runtimeArgs,
167
+ runtimeCwd: parsed.data.runtimeCwd,
168
+ runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
169
+ runtimeModel: parsed.data.runtimeModel,
170
+ runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
171
+ bootstrapPrompt: parsed.data.bootstrapPrompt,
172
+ runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
173
+ interruptGraceSec: parsed.data.interruptGraceSec,
174
+ runtimeEnv: parsed.data.runtimeEnv,
175
+ runPolicy: parsed.data.runPolicy
176
+ },
177
+ defaultRuntimeCwd
178
+ });
179
+ if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
180
+ throw new GovernanceError("Approval payload for agent hiring is missing runtime working directory.");
181
+ }
182
+ if (requiresRuntimeCwd(parsed.data.providerType) && hasText(runtimeConfig.runtimeCwd)) {
183
+ await mkdir(runtimeConfig.runtimeCwd!, { recursive: true });
184
+ }
135
185
 
136
186
  const agent = await createAgent(db, {
137
187
  companyId,
@@ -142,15 +192,11 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
142
192
  heartbeatCron: parsed.data.heartbeatCron,
143
193
  monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
144
194
  canHireAgents: parsed.data.canHireAgents,
145
- initialState: {
146
- runtime: {
147
- command: parsed.data.runtimeCommand,
148
- args: parsed.data.runtimeArgs,
149
- cwd: parsed.data.runtimeCwd,
150
- timeoutMs: parsed.data.runtimeTimeoutMs
151
- }
152
- }
195
+ ...runtimeConfigToDb(runtimeConfig),
196
+ initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
153
197
  });
198
+ const startupProjectId = await ensureAgentStartupProject(db, companyId);
199
+ await ensureAgentStartupIssue(db, companyId, startupProjectId, agent.id, agent.role, agent.name);
154
200
 
155
201
  return {
156
202
  applied: true,
@@ -227,3 +273,83 @@ function parsePayload(payloadJson: string) {
227
273
  return {};
228
274
  }
229
275
  }
276
+
277
+ async function ensureAgentStartupProject(db: BopoDb, companyId: string) {
278
+ const projects = await listProjects(db, companyId);
279
+ const existing = projects.find((project) => project.name === AGENT_STARTUP_PROJECT_NAME);
280
+ if (existing) {
281
+ return existing.id;
282
+ }
283
+ const created = await createProject(db, {
284
+ companyId,
285
+ name: AGENT_STARTUP_PROJECT_NAME,
286
+ description: "Operating baseline tasks for newly approved hires."
287
+ });
288
+ return created.id;
289
+ }
290
+
291
+ async function ensureAgentStartupIssue(
292
+ db: BopoDb,
293
+ companyId: string,
294
+ projectId: string,
295
+ agentId: string,
296
+ role: string,
297
+ name: string
298
+ ) {
299
+ const title = `Set up ${role} operating files`;
300
+ const body = buildAgentStartupTaskBody(name, agentId);
301
+ const existingIssues = await listIssues(db, companyId);
302
+ const existing = existingIssues.find(
303
+ (issue) =>
304
+ issue.assigneeAgentId === agentId &&
305
+ issue.title === title &&
306
+ typeof issue.body === "string" &&
307
+ issue.body.includes(AGENT_STARTUP_TASK_MARKER)
308
+ );
309
+ if (existing) {
310
+ return existing.id;
311
+ }
312
+ const created = await createIssue(db, {
313
+ companyId,
314
+ projectId,
315
+ title,
316
+ body,
317
+ status: "todo",
318
+ priority: "high",
319
+ assigneeAgentId: agentId,
320
+ labels: ["onboarding", "agent-setup"],
321
+ tags: ["agent-startup"]
322
+ });
323
+ return created.id;
324
+ }
325
+
326
+ function buildAgentStartupTaskBody(agentName: string, agentId: string) {
327
+ const slug = slugifyAgentPath(agentName || agentId);
328
+ return [
329
+ AGENT_STARTUP_TASK_MARKER,
330
+ "",
331
+ `Create your operating baseline before starting feature delivery work.`,
332
+ "",
333
+ `1. Create the folder \`agents/${slug}/\` in the repository workspace.`,
334
+ "2. Author these files with your own responsibilities and working style:",
335
+ ` - \`agents/${slug}/AGENTS.md\``,
336
+ ` - \`agents/${slug}/HEARTBEAT.md\``,
337
+ ` - \`agents/${slug}/SOUL.md\``,
338
+ ` - \`agents/${slug}/TOOLS.md\``,
339
+ `3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`agents/${slug}/AGENTS.md\` as your primary guide.`,
340
+ "4. Post an issue comment summarizing completed setup artifacts.",
341
+ "",
342
+ "Safety checks:",
343
+ "- Do not overwrite another agent's folder.",
344
+ "- Keep content original to your role and scope."
345
+ ].join("\n");
346
+ }
347
+
348
+ function slugifyAgentPath(value: string) {
349
+ const base = value
350
+ .toLowerCase()
351
+ .replace(/[^a-z0-9]+/g, "-")
352
+ .replace(/^-+|-+$/g, "");
353
+ return base || "agent";
354
+ }
355
+