maestro-agent 0.0.1 → 0.0.2

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 (94) hide show
  1. package/README.md +316 -2
  2. package/bin/maestro.ts +5 -0
  3. package/dist/maestro +0 -0
  4. package/dist/web/assets/Connections-DV2Kql1Z.js +1 -0
  5. package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
  6. package/dist/web/assets/Home-BFbUIh2z.js +1 -0
  7. package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
  8. package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
  9. package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
  10. package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
  11. package/dist/web/assets/Settings-CTflMta-.js +1 -0
  12. package/dist/web/assets/Skills-D09W1mwX.js +2 -0
  13. package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
  14. package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
  15. package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
  16. package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
  18. package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
  19. package/dist/web/assets/index-B1k33vcR.js +11 -0
  20. package/dist/web/assets/index-Bk2hHz7P.css +1 -0
  21. package/dist/web/assets/index-Ddy5AJwx.js +61 -0
  22. package/dist/web/assets/useEventStream-DTID465I.js +1 -0
  23. package/dist/web/index.html +13 -0
  24. package/package.json +49 -6
  25. package/src/api/agents.ts +76 -0
  26. package/src/api/audit.ts +19 -0
  27. package/src/api/autopilot.ts +73 -0
  28. package/src/api/chat.ts +801 -0
  29. package/src/api/chief.ts +84 -0
  30. package/src/api/config.ts +39 -0
  31. package/src/api/gantt.ts +72 -0
  32. package/src/api/hooks.ts +54 -0
  33. package/src/api/inbox.ts +125 -0
  34. package/src/api/lark.ts +32 -0
  35. package/src/api/memory.ts +37 -0
  36. package/src/api/ops.ts +89 -0
  37. package/src/api/projects.ts +105 -0
  38. package/src/api/roles.ts +123 -0
  39. package/src/api/runtimes.ts +62 -0
  40. package/src/api/scheduled-tasks.ts +203 -0
  41. package/src/api/sessions.ts +479 -0
  42. package/src/api/skills.ts +386 -0
  43. package/src/api/tasks.ts +457 -0
  44. package/src/api/telegram.ts +94 -0
  45. package/src/api/templates.ts +36 -0
  46. package/src/api/webhooks.ts +20 -0
  47. package/src/api/workspaces.ts +150 -0
  48. package/src/bridges/lark/index.ts +213 -0
  49. package/src/bridges/telegram/index.ts +273 -0
  50. package/src/bridges/telegram/polling.ts +185 -0
  51. package/src/chat/index.ts +86 -0
  52. package/src/chief/index.ts +461 -0
  53. package/src/core/cli.ts +333 -0
  54. package/src/core/db.ts +53 -0
  55. package/src/core/event-bus.ts +33 -0
  56. package/src/core/index.ts +6 -0
  57. package/src/core/migrations.ts +303 -0
  58. package/src/core/router.ts +69 -0
  59. package/src/core/schema.sql +232 -0
  60. package/src/core/server.ts +308 -0
  61. package/src/core/validate.ts +22 -0
  62. package/src/discovery/index.ts +194 -0
  63. package/src/gateway/adapters/telegram.ts +148 -0
  64. package/src/gateway/index.ts +31 -0
  65. package/src/gateway/manager.ts +176 -0
  66. package/src/gateway/types.ts +77 -0
  67. package/src/inbox/index.ts +500 -0
  68. package/src/ops/artifact-sync.ts +65 -0
  69. package/src/ops/autopilot.ts +338 -0
  70. package/src/ops/gc.ts +252 -0
  71. package/src/ops/index.ts +226 -0
  72. package/src/ops/project-serial.ts +52 -0
  73. package/src/ops/role-dispatch.ts +111 -0
  74. package/src/ops/runtime-scheduler.ts +447 -0
  75. package/src/ops/task-blocking.ts +65 -0
  76. package/src/ops/task-deps.ts +37 -0
  77. package/src/ops/task-workspace.ts +60 -0
  78. package/src/roles/index.ts +258 -0
  79. package/src/roles/prompt-assembler.ts +85 -0
  80. package/src/roles/workspace-role.ts +155 -0
  81. package/src/scheduler/index.ts +461 -0
  82. package/src/session/output-parser.ts +75 -0
  83. package/src/session/realtime-parser.ts +40 -0
  84. package/src/skills/builtin.ts +155 -0
  85. package/src/skills/skill-extractor.ts +452 -0
  86. package/src/skills/skill-md.ts +282 -0
  87. package/src/transport/http-api.ts +75 -0
  88. package/src/transport/index.ts +4 -0
  89. package/src/transport/local-pty.ts +119 -0
  90. package/src/transport/ssh.ts +176 -0
  91. package/src/transport/types.ts +20 -0
  92. package/src/workflows/index.ts +231 -0
  93. package/index.js +0 -1
  94. package/maestro-agent-0.0.1.tgz +0 -0
@@ -0,0 +1,258 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { now } from "../core/db";
5
+
6
+ export interface LoadedRole {
7
+ id: string;
8
+ name: string;
9
+ version: string;
10
+ role_md_path: string;
11
+ workspace_id?: string | null;
12
+ project_id?: string | null;
13
+ capabilities: string[];
14
+ preferred_runtimes: string[];
15
+ headcount: number;
16
+ extends?: string;
17
+ }
18
+
19
+ export interface RoleReloadState {
20
+ signature: string;
21
+ }
22
+
23
+ export function reloadRolesIfChanged(db: Database, rolesDir: string, state: RoleReloadState) {
24
+ const signature = roleDirectorySignature(rolesDir);
25
+ if (signature === state.signature) {
26
+ return { changed: false, roles: [] as LoadedRole[] };
27
+ }
28
+ state.signature = signature;
29
+ return { changed: true, roles: syncRolesFromDirectory(db, rolesDir) };
30
+ }
31
+
32
+ export function syncRolesFromDirectory(db: Database, rolesDir: string): LoadedRole[] {
33
+ if (!existsSync(rolesDir)) return [];
34
+ const roles: LoadedRole[] = [];
35
+
36
+ const topFiles = collectRoleMarkdownFiles(rolesDir, { includeBase: false });
37
+
38
+ for (const filePath of topFiles) {
39
+ const role = loadRoleFile(filePath, rolesDir);
40
+ roles.push(role);
41
+ upsertRole(db, role);
42
+ reloadIdleAgents(db, role);
43
+ }
44
+
45
+ return roles;
46
+ }
47
+
48
+ function collectRoleMarkdownFiles(rolesDir: string, opts: { includeBase: boolean }): string[] {
49
+ const files: string[] = [];
50
+ const scan = (dir: string) => {
51
+ if (!existsSync(dir)) return;
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ const path = join(dir, entry.name);
54
+ if (entry.isDirectory()) {
55
+ if (!opts.includeBase && entry.name === "base") continue;
56
+ scan(path);
57
+ continue;
58
+ }
59
+ if (entry.isFile() && entry.name.endsWith(".md")) files.push(path);
60
+ }
61
+ };
62
+
63
+ scan(rolesDir);
64
+ return files;
65
+ }
66
+
67
+ export function roleDirectorySignature(rolesDir: string): string {
68
+ if (!existsSync(rolesDir)) return "";
69
+ const files = collectRoleMarkdownFiles(rolesDir, { includeBase: true });
70
+
71
+ return files
72
+ .sort()
73
+ .map((path) => {
74
+ const stat = statSync(path);
75
+ return `${path}:${stat.mtimeMs}:${stat.size}`;
76
+ })
77
+ .join("|");
78
+ }
79
+
80
+ export function loadRoleFile(path: string, rolesDir?: string): LoadedRole {
81
+ const content = readFileSync(path, "utf-8");
82
+ const frontmatter = parseFrontmatter(content);
83
+ const id = String(frontmatter.id || filenameId(path));
84
+ return {
85
+ id,
86
+ name: String(frontmatter.name || id),
87
+ version: String(frontmatter.version || String(statSync(path).mtimeMs)),
88
+ role_md_path: path,
89
+ workspace_id: frontmatter.workspace_id || null,
90
+ project_id: frontmatter.project_id || null,
91
+ capabilities: parseList(frontmatter.capabilities),
92
+ preferred_runtimes: parseList(frontmatter.preferred_runtimes),
93
+ headcount: Number(frontmatter.default_headcount || frontmatter.headcount || 1),
94
+ extends: frontmatter.extends || undefined,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Build the full role prompt by resolving the extends inheritance chain.
100
+ * Returns the merged content (base first, role last) with frontmatter stripped.
101
+ * Automatically appends inbox-cheatsheet.md and safety.md from base/.
102
+ */
103
+ export function buildFullRolePrompt(rolesDir: string, roleMdPath: string): string {
104
+ const parts: string[] = [];
105
+
106
+ // Resolve inheritance chain (base → ... → role)
107
+ const chain = resolveInheritanceChain(rolesDir, roleMdPath);
108
+
109
+ // Collect content in order (ancestors first, role last)
110
+ for (const filePath of chain) {
111
+ const content = readFileSync(filePath, "utf-8");
112
+ parts.push(stripFrontmatter(content));
113
+ }
114
+
115
+ // Append inbox-cheatsheet and safety (if not already in chain)
116
+ const baseDir = join(rolesDir, "base");
117
+ const cheatsheetPath = join(baseDir, "inbox-cheatsheet.md");
118
+ const safetyPath = join(baseDir, "safety.md");
119
+
120
+ if (existsSync(cheatsheetPath) && !chain.includes(cheatsheetPath)) {
121
+ parts.push(readFileSync(cheatsheetPath, "utf-8"));
122
+ }
123
+ if (existsSync(safetyPath) && !chain.includes(safetyPath)) {
124
+ parts.push(readFileSync(safetyPath, "utf-8"));
125
+ }
126
+
127
+ return parts.join("\n\n---\n\n");
128
+ }
129
+
130
+ /**
131
+ * Resolve the inheritance chain for a role file.
132
+ * Returns an array of file paths from root ancestor to the role itself.
133
+ */
134
+ function resolveInheritanceChain(rolesDir: string, roleMdPath: string): string[] {
135
+ const chain: string[] = [];
136
+ const visited = new Set<string>();
137
+ let current: string | null = roleMdPath;
138
+ const maxDepth = 5;
139
+
140
+ // Walk up the extends chain
141
+ const stack: string[] = [];
142
+ while (current && stack.length < maxDepth) {
143
+ const resolved = resolveRolePath(rolesDir, current);
144
+ if (!resolved || !existsSync(resolved) || visited.has(resolved)) break;
145
+ visited.add(resolved);
146
+ stack.push(resolved);
147
+
148
+ const content = readFileSync(resolved, "utf-8");
149
+ const fm = parseFrontmatter(content);
150
+ current = fm.extends || null;
151
+ }
152
+
153
+ // Reverse so ancestors come first
154
+ stack.reverse();
155
+ chain.push(...stack);
156
+ return chain;
157
+ }
158
+
159
+ /**
160
+ * Resolve a role path reference (could be relative like "base/agent.md" or absolute)
161
+ */
162
+ function resolveRolePath(rolesDir: string, ref: string): string | null {
163
+ // If it's already an absolute path, use it
164
+ if (ref.startsWith("/") && existsSync(ref)) return ref;
165
+ // Resolve relative to rolesDir
166
+ const resolved = join(rolesDir, ref);
167
+ if (existsSync(resolved)) return resolved;
168
+ // Try as-is
169
+ if (existsSync(ref)) return ref;
170
+ return null;
171
+ }
172
+
173
+ function upsertRole(db: Database, role: LoadedRole) {
174
+ const ts = now();
175
+ db.run(
176
+ `INSERT INTO role (id, workspace_id, project_id, name, role_md_path, version, capabilities_json, preferred_runtimes_json, headcount, created_at, updated_at)
177
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
178
+ ON CONFLICT(id) DO UPDATE SET
179
+ workspace_id = excluded.workspace_id,
180
+ project_id = excluded.project_id,
181
+ name = excluded.name,
182
+ role_md_path = excluded.role_md_path,
183
+ version = excluded.version,
184
+ capabilities_json = excluded.capabilities_json,
185
+ preferred_runtimes_json = excluded.preferred_runtimes_json,
186
+ headcount = excluded.headcount,
187
+ updated_at = excluded.updated_at`,
188
+ [
189
+ role.id,
190
+ role.workspace_id || null,
191
+ role.project_id || null,
192
+ role.name,
193
+ role.role_md_path,
194
+ role.version,
195
+ JSON.stringify(role.capabilities),
196
+ JSON.stringify(role.preferred_runtimes),
197
+ role.headcount,
198
+ ts,
199
+ ts,
200
+ ]
201
+ );
202
+ }
203
+
204
+ function reloadIdleAgents(db: Database, role: LoadedRole) {
205
+ const rows = db.query("SELECT id, metrics_json FROM agent WHERE role_id = ? AND status = 'idle'").all(role.id) as any[];
206
+ const ts = now();
207
+ for (const agent of rows) {
208
+ const metrics = JSON.parse(agent.metrics_json || "{}");
209
+ metrics.role_version = role.version;
210
+ metrics.role_reloaded_at = ts;
211
+ db.run("UPDATE agent SET metrics_json = ?, last_active_at = ? WHERE id = ?", [
212
+ JSON.stringify(metrics),
213
+ ts,
214
+ agent.id,
215
+ ]);
216
+ }
217
+ }
218
+
219
+ export function parseFrontmatter(content: string): Record<string, string> {
220
+ if (!content.startsWith("---")) return {};
221
+ const end = content.indexOf("\n---", 3);
222
+ if (end === -1) return {};
223
+ const block = content.slice(3, end).trim();
224
+ const values: Record<string, string> = {};
225
+ for (const line of block.split("\n")) {
226
+ const index = line.indexOf(":");
227
+ if (index === -1) continue;
228
+ const key = line.slice(0, index).trim();
229
+ const value = line.slice(index + 1).trim();
230
+ values[key] = value;
231
+ }
232
+ return values;
233
+ }
234
+
235
+ export function stripFrontmatter(content: string): string {
236
+ if (!content.startsWith("---")) return content;
237
+ const end = content.indexOf("\n---", 3);
238
+ if (end === -1) return content;
239
+ return content.slice(end + 4).trim();
240
+ }
241
+
242
+ function parseList(value: unknown): string[] {
243
+ if (!value) return [];
244
+ const text = String(value).trim();
245
+ if (!text) return [];
246
+ if (text.startsWith("[") && text.endsWith("]")) {
247
+ return text
248
+ .slice(1, -1)
249
+ .split(",")
250
+ .map((item) => item.trim().replace(/^["']|["']$/g, ""))
251
+ .filter(Boolean);
252
+ }
253
+ return text.split(",").map((item) => item.trim()).filter(Boolean);
254
+ }
255
+
256
+ function filenameId(path: string): string {
257
+ return path.split("/").pop()!.replace(/\.md$/, "");
258
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import type { HubContext } from "../core/server";
4
+ import { buildFullRolePrompt } from "./index";
5
+
6
+ /**
7
+ * Assemble the four-layer prompt for an agent session:
8
+ * 1. [system] Hub runtime (base/agent.md + cheatsheet + safety — via buildFullRolePrompt inheritance)
9
+ * 2. [role] Role-specific content (resolved via extends chain)
10
+ * 3. [project] Project charter (if available)
11
+ * 4. [task] Task description + done_when criteria
12
+ */
13
+ export function assemblePrompt(ctx: HubContext, opts: {
14
+ agentId: string;
15
+ taskId?: string | null;
16
+ }): string {
17
+ const sections: string[] = [];
18
+ const rolesDir = join(ctx.hubDir, "roles");
19
+
20
+ // Resolve agent → role → role_md_path
21
+ const agent = ctx.db.query("SELECT * FROM agent WHERE id = ?").get(opts.agentId) as any;
22
+ if (!agent?.role_id) {
23
+ // No role assigned, return minimal prompt
24
+ sections.push("# MAESTRO Agent\n\nYou are an AI Agent in the MAESTRO system. No specific role has been assigned.");
25
+ } else {
26
+ const role = ctx.db.query("SELECT * FROM role WHERE id = ?").get(agent.role_id) as any;
27
+
28
+ // Layer 1+2: Role prompt (includes base via extends chain)
29
+ if (role?.role_md_path && existsSync(role.role_md_path)) {
30
+ const rolePrompt = buildFullRolePrompt(rolesDir, role.role_md_path);
31
+ sections.push(rolePrompt);
32
+ } else {
33
+ sections.push(`# Role: ${role?.name || agent.role_id}\n\nRole definition file not found.`);
34
+ }
35
+
36
+ // Layer 3: Project charter
37
+ if (role?.project_id) {
38
+ const project = ctx.db.query("SELECT * FROM project WHERE id = ?").get(role.project_id) as any;
39
+ if (project?.charter_template) {
40
+ sections.push(`# Project Charter: ${project.name}\n\n${project.charter_template}`);
41
+ }
42
+ } else if (role?.workspace_id) {
43
+ // Try to find project through workspace
44
+ const project = ctx.db.query(
45
+ "SELECT * FROM project WHERE workspace_id = ? ORDER BY created_at DESC LIMIT 1"
46
+ ).get(role.workspace_id) as any;
47
+ if (project?.charter_template) {
48
+ sections.push(`# Project Charter: ${project.name}\n\n${project.charter_template}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Layer 4: Task context
54
+ if (opts.taskId) {
55
+ const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(opts.taskId) as any;
56
+ if (task) {
57
+ let taskSection = `# Current Task\n\n**ID:** ${task.id}\n**Title:** ${task.title}\n`;
58
+ if (task.description) taskSection += `**Description:** ${task.description}\n`;
59
+ if (task.done_when_json) {
60
+ try {
61
+ const doneWhen = JSON.parse(task.done_when_json);
62
+ taskSection += `**Done When:** ${JSON.stringify(doneWhen)}\n`;
63
+ } catch {}
64
+ }
65
+ if (task.required_capabilities_json) {
66
+ taskSection += `**Required Capabilities:** ${task.required_capabilities_json}\n`;
67
+ }
68
+
69
+ // Include recent thread items
70
+ const threadItems = ctx.db.query(
71
+ "SELECT * FROM task_thread_item WHERE task_id = ? ORDER BY created_at DESC LIMIT 5"
72
+ ).all(opts.taskId) as any[];
73
+ if (threadItems.length > 0) {
74
+ taskSection += `\n## Recent Thread\n`;
75
+ for (const item of threadItems.reverse()) {
76
+ taskSection += `- [${item.author}] ${item.content}\n`;
77
+ }
78
+ }
79
+
80
+ sections.push(taskSection);
81
+ }
82
+ }
83
+
84
+ return sections.join("\n\n---\n\n");
85
+ }
@@ -0,0 +1,155 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import type { Database } from "bun:sqlite";
4
+ import { generateId, now } from "../core/db";
5
+
6
+ export interface WorkspaceRoleSpec {
7
+ workspaceId: string;
8
+ projectId?: string | null;
9
+ id?: string;
10
+ name: string;
11
+ capabilities?: string[];
12
+ preferredRuntimes?: string[];
13
+ headcount?: number;
14
+ runtimeId?: string | null;
15
+ hubDir?: string;
16
+ }
17
+
18
+ export interface WorkspaceRoleResult {
19
+ role: any;
20
+ agents: any[];
21
+ roleMdPath: string;
22
+ roleMdContent: string;
23
+ }
24
+
25
+ type PublishFn = (type: string, payload: any) => void;
26
+
27
+ export function workspaceRoleMdPath(hubDir: string | undefined, workspaceId: string, roleId: string): string {
28
+ return hubDir
29
+ ? join(hubDir, "roles", workspaceId, `${roleId}.md`)
30
+ : join("/roles", workspaceId, `${roleId}.md`);
31
+ }
32
+
33
+ export function renderWorkspaceRoleMarkdown(spec: WorkspaceRoleSpec & { roleId: string }): string {
34
+ const capabilities = JSON.stringify(spec.capabilities || []);
35
+ const preferredRuntimes = JSON.stringify(spec.preferredRuntimes || []);
36
+ const headcount = Math.max(1, Number(spec.headcount || 1));
37
+
38
+ return `---
39
+ id: ${spec.roleId}
40
+ name: ${spec.name}
41
+ workspace_id: ${spec.workspaceId}
42
+ ${spec.projectId ? `project_id: ${spec.projectId}` : ""}
43
+ capabilities: ${capabilities}
44
+ preferred_runtimes: ${preferredRuntimes}
45
+ headcount: ${headcount}
46
+ extends: base/agent.md
47
+ ---
48
+
49
+ # ${spec.name}
50
+
51
+ This role was created from the workspace role manager.
52
+ `;
53
+ }
54
+
55
+ export function createWorkspaceRole(
56
+ db: Database,
57
+ spec: WorkspaceRoleSpec,
58
+ publish?: PublishFn,
59
+ ): WorkspaceRoleResult {
60
+ const name = String(spec.name || "").trim();
61
+ if (!name) throw new Error("Role name is required");
62
+ if (!spec.workspaceId) throw new Error("workspaceId is required");
63
+
64
+ const roleId = buildWorkspaceRoleId(spec.workspaceId, spec.id || name);
65
+ const ts = now();
66
+ const roleMdPath = workspaceRoleMdPath(spec.hubDir, spec.workspaceId, roleId);
67
+ const roleMdContent = renderWorkspaceRoleMarkdown({ ...spec, roleId, name });
68
+
69
+ if (spec.hubDir) {
70
+ const roleDir = join(spec.hubDir, "roles", spec.workspaceId);
71
+ mkdirSync(roleDir, { recursive: true });
72
+ writeFileSync(roleMdPath, roleMdContent, "utf-8");
73
+ }
74
+
75
+ db.run(
76
+ `INSERT INTO role (id, workspace_id, project_id, name, role_md_path, capabilities_json, preferred_runtimes_json, headcount, created_at, updated_at)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
+ ON CONFLICT(id) DO UPDATE SET
79
+ workspace_id = excluded.workspace_id,
80
+ project_id = excluded.project_id,
81
+ name = excluded.name,
82
+ role_md_path = excluded.role_md_path,
83
+ capabilities_json = excluded.capabilities_json,
84
+ preferred_runtimes_json = excluded.preferred_runtimes_json,
85
+ headcount = excluded.headcount,
86
+ updated_at = excluded.updated_at`,
87
+ [
88
+ roleId,
89
+ spec.workspaceId,
90
+ spec.projectId || null,
91
+ name,
92
+ roleMdPath,
93
+ JSON.stringify(Array.isArray(spec.capabilities) ? spec.capabilities : []),
94
+ JSON.stringify(Array.isArray(spec.preferredRuntimes) ? spec.preferredRuntimes : []),
95
+ Math.max(1, Number(spec.headcount || 1)),
96
+ ts,
97
+ ts,
98
+ ],
99
+ );
100
+
101
+ const role = db.query("SELECT * FROM role WHERE id = ?").get(roleId) as any;
102
+ const safePublish = publish || (() => {});
103
+ const agents = spawnRoleAgents(db, role, {
104
+ runtimeId: spec.runtimeId || null,
105
+ count: Math.max(1, Number(spec.headcount || 1)),
106
+ hubDir: spec.hubDir,
107
+ publish: safePublish,
108
+ });
109
+
110
+ safePublish("role.created", { id: roleId, workspace_id: spec.workspaceId, name });
111
+ return { role, agents, roleMdPath, roleMdContent };
112
+ }
113
+
114
+ export function buildWorkspaceRoleId(workspaceId: string, rawId: string): string {
115
+ const baseId = String(rawId || "")
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, "_")
118
+ .replace(/^_+|_+$/g, "");
119
+ const scopedBaseId = baseId || generateId("role");
120
+ const scopedId = `${workspaceId}_${scopedBaseId}`;
121
+ return scopedId.replace(/[^a-z0-9_]+/g, "_");
122
+ }
123
+
124
+ export function spawnRoleAgents(
125
+ db: Database,
126
+ role: any,
127
+ opts: { runtimeId?: string | null; count?: number; hubDir?: string; publish?: PublishFn },
128
+ ): any[] {
129
+ const count = Math.max(1, Number(opts.count || role?.headcount || 1));
130
+ let runtimeId = opts.runtimeId || null;
131
+ let runtime = runtimeId ? (db.query("SELECT * FROM agent_runtime WHERE id = ?").get(runtimeId) as any) : null;
132
+ if (!runtime) {
133
+ runtime = db.query("SELECT * FROM agent_runtime WHERE status = 'online' ORDER BY created_at DESC LIMIT 1").get() as any;
134
+ runtimeId = runtime?.id || runtimeId;
135
+ }
136
+ if (!runtimeId || !runtime) return [];
137
+
138
+ if (!runtime) throw new Error(`Agent runtime not found: ${runtimeId}`);
139
+
140
+ const agents: any[] = [];
141
+ const ts = now();
142
+ for (let i = 0; i < count; i++) {
143
+ const agentId = generateId("agent");
144
+ const agentName = `${role.name}-${i + 1}`;
145
+ const workdir = null;
146
+ db.run(
147
+ "INSERT INTO agent (id, role_id, runtime_id, name, workdir, status, metrics_json, created_at, last_active_at) VALUES (?, ?, ?, ?, ?, 'idle', '{}', ?, ?)",
148
+ [agentId, role.id, runtimeId, agentName, workdir, ts, ts],
149
+ );
150
+ const agent = { id: agentId, name: agentName, role_id: role.id, runtime_id: runtimeId, status: "idle", workdir };
151
+ agents.push(agent);
152
+ opts.publish?.("agent.spawned", { id: agentId, name: agentName, runtime_id: runtimeId, role_id: role.id });
153
+ }
154
+ return agents;
155
+ }