@automagik/genie 4.260331.3 → 4.260331.5

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.
@@ -2,7 +2,7 @@
2
2
  "id": "genie",
3
3
  "name": "Genie",
4
4
  "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit",
5
- "version": "4.260331.3",
5
+ "version": "4.260331.5",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260331.3",
3
+ "version": "4.260331.5",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260331.3",
3
+ "version": "4.260331.5",
4
4
  "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
5
5
  "author": {
6
6
  "name": "Namastex Labs"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260331.3",
3
+ "version": "4.260331.5",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -0,0 +1,5 @@
1
+ -- 016: Team Spawner — track who created each team.
2
+ -- Adds `spawner` column to teams table for orchestrator tracking.
3
+ -- Idempotent: safe to re-run.
4
+
5
+ ALTER TABLE teams ADD COLUMN IF NOT EXISTS spawner TEXT;
@@ -0,0 +1,18 @@
1
+ -- 017: Wishes table — filesystem wish index for cross-repo querying.
2
+ -- Synced from .genie/wishes/*/WISH.md files in each repo.
3
+ -- Idempotent: safe to re-run.
4
+
5
+ CREATE TABLE IF NOT EXISTS wishes (
6
+ id SERIAL PRIMARY KEY,
7
+ slug TEXT NOT NULL,
8
+ repo TEXT NOT NULL,
9
+ namespace TEXT,
10
+ status TEXT DEFAULT 'DRAFT',
11
+ file_path TEXT NOT NULL,
12
+ created_at TIMESTAMPTZ DEFAULT now(),
13
+ updated_at TIMESTAMPTZ DEFAULT now(),
14
+ UNIQUE(slug, repo)
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
18
+ CREATE INDEX IF NOT EXISTS idx_wishes_namespace ON wishes(namespace) WHERE namespace IS NOT NULL;
@@ -62,12 +62,22 @@ interface SessionOptions {
62
62
  * The leadSessionId is a placeholder -- CC updates it internally once started.
63
63
  * CC recognizes itself as leader because --team-name is passed without --agent-id.
64
64
  */
65
+ async function resolveSessionLeaderName(teamName: string): Promise<string> {
66
+ try {
67
+ const { resolveLeaderName } = await import('../lib/team-manager.js');
68
+ return await resolveLeaderName(teamName);
69
+ } catch {
70
+ return 'team-lead'; // Fallback for legacy teams or when DB is unavailable
71
+ }
72
+ }
73
+
65
74
  async function ensureNativeTeamForLeader(teamName: string, cwd: string): Promise<void> {
66
- await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending');
75
+ const leaderName = await resolveSessionLeaderName(teamName);
76
+ await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending', leaderName);
67
77
 
68
78
  await registerNativeMember(teamName, {
69
79
  agentName: basename(cwd),
70
- agentType: 'team-lead',
80
+ agentType: leaderName,
71
81
  color: 'blue',
72
82
  cwd,
73
83
  });
@@ -93,12 +103,14 @@ async function registerSessionInRegistry(sessionName: string, windowName: string
93
103
  const paneId = (await tmux.executeTmux(`display -t ${shellQuote(target)} -p '#{pane_id}'`)).trim();
94
104
  const now = new Date().toISOString();
95
105
  const sanitized = sanitizeTeamName(windowName);
106
+ const leaderName = await resolveSessionLeaderName(windowName);
107
+ const sanitizedLeader = sanitizeTeamName(leaderName);
96
108
  await registry.register({
97
- id: `${sanitized}-team-lead`,
109
+ id: `${sanitized}-${sanitizedLeader}`,
98
110
  paneId,
99
111
  session: sessionName,
100
112
  team: windowName,
101
- role: 'team-lead',
113
+ role: leaderName,
102
114
  worktree: null,
103
115
  startedAt: now,
104
116
  state: 'working',
@@ -107,11 +119,11 @@ async function registerSessionInRegistry(sessionName: string, windowName: string
107
119
  provider: 'claude',
108
120
  transport: 'tmux',
109
121
  nativeTeamEnabled: true,
110
- nativeAgentId: `team-lead@${sanitized}`,
122
+ nativeAgentId: `${sanitizedLeader}@${sanitized}`,
111
123
  });
112
124
 
113
- // Executor model: create agent identity + executor for team-lead session
114
- const agentIdentity = await registry.findOrCreateAgent('team-lead', sanitized, 'team-lead');
125
+ // Executor model: create agent identity + executor for leader session
126
+ const agentIdentity = await registry.findOrCreateAgent(leaderName, sanitized, leaderName);
115
127
  await executorRegistry.terminateActiveExecutor(agentIdentity.id);
116
128
 
117
129
  let pid: number | null = null;
@@ -49,6 +49,17 @@ function buildSpawnArgs(template: {
49
49
  return args;
50
50
  }
51
51
 
52
+ /** Check if the recipient is the team's actual leader (dynamic name, not 'team-lead' alias). */
53
+ async function isRecipientLeader(recipient: string, teamName: string): Promise<boolean> {
54
+ try {
55
+ const { getTeam } = await import('../../lib/team-manager.js');
56
+ const config = await getTeam(teamName);
57
+ return !!config?.leader && recipient === config.leader;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
52
63
  export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> {
53
64
  const input = payload.tool_input;
54
65
  if (!input || input.type !== 'message') return;
@@ -59,6 +70,9 @@ export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> {
59
70
  const teamName = process.env.GENIE_TEAM ?? payload.team_name;
60
71
  if (!teamName) return;
61
72
 
73
+ // Skip auto-spawn for the team's actual leader (not just 'team-lead' alias)
74
+ if (await isRecipientLeader(recipient, teamName)) return;
75
+
62
76
  try {
63
77
  const registryMod = await import('../../lib/agent-registry.js');
64
78
  const tmuxMod = await import('../../lib/tmux.js');
@@ -340,14 +340,28 @@ export async function removeSubPane(workerId: string, paneId: string, _registryP
340
340
  await sql`UPDATE agents SET sub_panes = ${sql.json(filtered)} WHERE id = ${workerId}`;
341
341
  }
342
342
 
343
+ /** Resolve the dynamic leader name for a team (null if only 'team-lead' applies). */
344
+ async function resolveDynamicLeaderName(teamName: string): Promise<string | null> {
345
+ try {
346
+ const { getTeam } = await import('./team-manager.js');
347
+ const config = await getTeam(teamName);
348
+ return config?.leader && config.leader !== 'team-lead' ? config.leader : null;
349
+ } catch {
350
+ return null; // Fallback to team-lead only
351
+ }
352
+ }
353
+
343
354
  export async function getTeamLeadEntry(teamName: string, session?: string, repoPath?: string): Promise<Agent | null> {
344
355
  const sql = await getConnection();
345
356
  if (session) return findTeamLeadBySession(sql, teamName, session, repoPath);
346
357
  const legacyId = buildLegacyTeamLeadEntryId(teamName);
347
358
  const lr = await sql`SELECT * FROM agents WHERE id = ${legacyId}`;
348
359
  if (lr.length > 0) return rowToAgent(lr[0]);
349
- const sr =
350
- await sql`SELECT * FROM agents WHERE role = 'team-lead' AND team = ${teamName} ORDER BY started_at DESC LIMIT 1`;
360
+
361
+ const leaderName = await resolveDynamicLeaderName(teamName);
362
+ const sr = leaderName
363
+ ? await sql`SELECT * FROM agents WHERE (role = 'team-lead' OR role = ${leaderName}) AND team = ${teamName} ORDER BY started_at DESC LIMIT 1`
364
+ : await sql`SELECT * FROM agents WHERE role = 'team-lead' AND team = ${teamName} ORDER BY started_at DESC LIMIT 1`;
351
365
  return sr.length > 0 ? rowToAgent(sr[0]) : null;
352
366
  }
353
367
 
@@ -375,9 +389,15 @@ async function findTeamLeadBySession(
375
389
  const a = rowToAgent(legRows[0]);
376
390
  if (a.session === session && (!repoPath || a.repoPath === repoPath)) return a;
377
391
  }
378
- const scanRows = await sql<
379
- AgentRow[]
380
- >`SELECT * FROM agents WHERE role = 'team-lead' AND team = ${teamName} AND session = ${session} ${repoPath ? sql`AND repo_path = ${repoPath}` : sql``} LIMIT 1`;
392
+
393
+ const leaderName = await resolveDynamicLeaderName(teamName);
394
+ const scanRows = leaderName
395
+ ? await sql<
396
+ AgentRow[]
397
+ >`SELECT * FROM agents WHERE (role = 'team-lead' OR role = ${leaderName}) AND team = ${teamName} AND session = ${session} ${repoPath ? sql`AND repo_path = ${repoPath}` : sql``} LIMIT 1`
398
+ : await sql<
399
+ AgentRow[]
400
+ >`SELECT * FROM agents WHERE role = 'team-lead' AND team = ${teamName} AND session = ${session} ${repoPath ? sql`AND repo_path = ${repoPath}` : sql``} LIMIT 1`;
381
401
  return scanRows.length > 0 ? rowToAgent(scanRows[0]) : null;
382
402
  }
383
403
 
@@ -184,6 +184,7 @@ export async function ensureNativeTeam(
184
184
  teamName: string,
185
185
  description: string,
186
186
  leadSessionId: string,
187
+ leaderName?: string,
187
188
  ): Promise<NativeTeamConfig> {
188
189
  const dir = teamDir(teamName);
189
190
  const inboxDir = inboxesDir(teamName);
@@ -195,11 +196,12 @@ export async function ensureNativeTeam(
195
196
  if (existing) return existing;
196
197
 
197
198
  const sanitized = sanitizeTeamName(teamName);
199
+ const resolvedLeader = sanitizeTeamName(leaderName ?? 'team-lead');
198
200
  const config: NativeTeamConfig = {
199
201
  name: sanitized,
200
202
  description,
201
203
  createdAt: Date.now(),
202
- leadAgentId: `team-lead@${sanitized}`,
204
+ leadAgentId: `${resolvedLeader}@${sanitized}`,
203
205
  leadSessionId,
204
206
  members: [],
205
207
  };
@@ -387,12 +389,61 @@ export async function deleteNativeTeam(teamName: string): Promise<boolean> {
387
389
  // Inbox Scanning
388
390
  // ============================================================================
389
391
 
392
+ /** Extract the leader inbox name from a native team config's leadAgentId. */
393
+ function extractLeaderInboxName(config: NativeTeamConfig | null): string {
394
+ if (!config?.leadAgentId) return 'team-lead';
395
+ const atIdx = config.leadAgentId.indexOf('@');
396
+ return atIdx > 0 ? config.leadAgentId.slice(0, atIdx) : 'team-lead';
397
+ }
398
+
399
+ /** Scan a single team directory for unread leader inbox messages. */
400
+ async function scanTeamInbox(
401
+ base: string,
402
+ name: string,
403
+ ): Promise<{
404
+ teamName: string;
405
+ unreadCount: number;
406
+ workingDir: string | null;
407
+ firstUnreadText: string | null;
408
+ } | null> {
409
+ let config: NativeTeamConfig | null = null;
410
+ try {
411
+ const cfgContent = await readFile(join(base, name, 'config.json'), 'utf-8');
412
+ config = JSON.parse(cfgContent);
413
+ } catch {
414
+ // Config missing or malformed
415
+ }
416
+
417
+ const leaderInboxName = extractLeaderInboxName(config);
418
+ const inboxFile = join(base, name, 'inboxes', `${leaderInboxName}.json`);
419
+
420
+ let messages: NativeInboxMessage[];
421
+ try {
422
+ const content = await readFile(inboxFile, 'utf-8');
423
+ messages = JSON.parse(content);
424
+ } catch {
425
+ return null;
426
+ }
427
+
428
+ if (!Array.isArray(messages)) return null;
429
+ const unread = messages.filter((m) => m.read === false);
430
+ if (unread.length === 0) return null;
431
+
432
+ let workingDir: string | null = null;
433
+ if (config) {
434
+ const leadMember = config.members.find((m) => m.agentId === config?.leadAgentId || m.name === leaderInboxName);
435
+ if (leadMember?.cwd) workingDir = leadMember.cwd;
436
+ }
437
+
438
+ return { teamName: name, unreadCount: unread.length, workingDir, firstUnreadText: unread[0]?.text ?? null };
439
+ }
440
+
390
441
  /**
391
- * List all teams that have unread messages in their team-lead inbox.
442
+ * List all teams that have unread messages in their leader's inbox.
392
443
  *
393
- * Scans `~/.claude/teams/` for teams where `inboxes/team-lead.json`
444
+ * Scans `~/.claude/teams/` for teams where the leader's inbox
394
445
  * contains messages with `read: false`. Returns the team name, unread
395
- * count, and working directory (from config.json → members → team-lead → cwd).
446
+ * count, and working directory (from config.json → members → leader → cwd).
396
447
  */
397
448
  export async function listTeamsWithUnreadInbox(): Promise<
398
449
  Array<{ teamName: string; unreadCount: number; workingDir: string | null; firstUnreadText: string | null }>
@@ -402,7 +453,7 @@ export async function listTeamsWithUnreadInbox(): Promise<
402
453
  try {
403
454
  teamDirs = await readdir(base);
404
455
  } catch {
405
- return []; // No teams directory
456
+ return [];
406
457
  }
407
458
 
408
459
  const results: Array<{
@@ -413,40 +464,8 @@ export async function listTeamsWithUnreadInbox(): Promise<
413
464
  }> = [];
414
465
 
415
466
  for (const name of teamDirs) {
416
- // Read inbox messages
417
- const inboxFile = join(base, name, 'inboxes', 'team-lead.json');
418
- let messages: NativeInboxMessage[];
419
- try {
420
- const content = await readFile(inboxFile, 'utf-8');
421
- messages = JSON.parse(content);
422
- } catch {
423
- continue; // No inbox or invalid JSON
424
- }
425
-
426
- if (!Array.isArray(messages)) continue;
427
-
428
- const unread = messages.filter((m) => m.read === false);
429
- if (unread.length === 0) continue;
430
-
431
- // Get workingDir from config.json → members → team-lead → cwd
432
- let workingDir: string | null = null;
433
- try {
434
- const cfgContent = await readFile(join(base, name, 'config.json'), 'utf-8');
435
- const config: NativeTeamConfig = JSON.parse(cfgContent);
436
- const leadMember = config.members.find((m) => m.name === 'team-lead' || m.agentId.startsWith('team-lead@'));
437
- if (leadMember?.cwd) {
438
- workingDir = leadMember.cwd;
439
- }
440
- } catch {
441
- // Config missing or malformed — workingDir stays null
442
- }
443
-
444
- results.push({
445
- teamName: name,
446
- unreadCount: unread.length,
447
- workingDir,
448
- firstUnreadText: unread[0]?.text ?? null,
449
- });
467
+ const entry = await scanTeamInbox(base, name);
468
+ if (entry) results.push(entry);
450
469
  }
451
470
 
452
471
  return results;
@@ -550,7 +569,8 @@ async function readSessionMetadata(filePath: string): Promise<SessionMetadata> {
550
569
  */
551
570
  function rootScore(metadata: { teamName?: string; agentName?: string }): number {
552
571
  if (!metadata.teamName && !metadata.agentName) return 2;
553
- if (metadata.agentName === 'team-lead') return 1;
572
+ // Score leader sessions higher — matches both legacy "team-lead" and dynamic leader names
573
+ if (metadata.agentName === 'team-lead' || (metadata.teamName && !metadata.agentName)) return 1;
554
574
  return 0;
555
575
  }
556
576
 
@@ -663,6 +683,7 @@ export async function registerAsTeamLead(
663
683
  cwd?: string;
664
684
  tmuxPaneId?: string;
665
685
  color?: string;
686
+ leaderName?: string;
666
687
  },
667
688
  ): Promise<{ sessionId: string; config: NativeTeamConfig }> {
668
689
  const sessionId = await discoverClaudeSessionId(opts?.cwd);
@@ -673,8 +694,10 @@ export async function registerAsTeamLead(
673
694
  );
674
695
  }
675
696
 
697
+ const resolvedLeaderName = opts?.leaderName ?? 'team-lead';
698
+
676
699
  // Create or load the native team, using the real CC session ID
677
- const config = await ensureNativeTeam(teamName, `Genie team: ${teamName}`, sessionId);
700
+ const config = await ensureNativeTeam(teamName, `Genie team: ${teamName}`, sessionId, resolvedLeaderName);
678
701
 
679
702
  // Update leadSessionId if the team already existed with a stale ID
680
703
  if (config.leadSessionId !== sessionId) {
@@ -684,13 +707,14 @@ export async function registerAsTeamLead(
684
707
 
685
708
  // Register the leader as a member (CC expects the lead in the members array)
686
709
  const sanitized = sanitizeTeamName(teamName);
687
- const leadAgentId = `team-lead@${sanitized}`;
710
+ const sanitizedLeader = sanitizeTeamName(resolvedLeaderName);
711
+ const leadAgentId = `${sanitizedLeader}@${sanitized}`;
688
712
  const existingLead = config.members.find((m) => m.agentId === leadAgentId);
689
713
 
690
714
  const resolvedPaneId = opts?.tmuxPaneId ?? process.env.TMUX_PANE;
691
715
  if (!existingLead || !existingLead.isActive) {
692
716
  await registerNativeMember(teamName, {
693
- agentName: 'team-lead',
717
+ agentName: resolvedLeaderName,
694
718
  agentType: 'general-purpose',
695
719
  color: opts?.color ?? 'blue',
696
720
  tmuxPaneId: resolvedPaneId,
@@ -702,8 +726,8 @@ export async function registerAsTeamLead(
702
726
  await saveConfig(teamName, config);
703
727
  }
704
728
 
705
- // Ensure the team-lead inbox exists
706
- const inbox = inboxPath(teamName, 'team-lead');
729
+ // Ensure the leader's inbox exists
730
+ const inbox = inboxPath(teamName, resolvedLeaderName);
707
731
  if (!existsSync(inbox)) {
708
732
  await writeFile(inbox, '[]');
709
733
  }
@@ -245,7 +245,16 @@ export async function spawnWorkerFromTemplate(
245
245
  tmuxPaneId: paneId,
246
246
  cwd: repoPath,
247
247
  });
248
- await nativeTeams.writeNativeInbox(team, 'team-lead', {
248
+ // Resolve the actual leader name for inbox notification
249
+ let leaderInboxTarget = 'team-lead';
250
+ try {
251
+ const { getTeam } = await import('./team-manager.js');
252
+ const teamConfig = await getTeam(team);
253
+ if (teamConfig?.leader) leaderInboxTarget = teamConfig.leader;
254
+ } catch {
255
+ // Fallback to 'team-lead' for legacy teams
256
+ }
257
+ await nativeTeams.writeNativeInbox(team, leaderInboxTarget, {
249
258
  from: agentName,
250
259
  text: `Worker ${agentName} (${template.provider}) auto-spawned${resumeSessionId ? ' with --resume' : ''}. Ready for tasks.`,
251
260
  summary: `${agentName} auto-spawned`,
@@ -435,7 +435,7 @@ async function runPreparedSpec(
435
435
  team: teamName,
436
436
  session: teamName,
437
437
  cwd: worktreePath,
438
- role: 'team-lead',
438
+ role: 'qa',
439
439
  extraArgs: ['--append-system-prompt-file', promptFile],
440
440
  initialPrompt: `Execute the QA spec "${spec.name}" end-to-end right now. Do not stop after partial progress or a wait step. Continue until you validate the expectations, publish qa-report, and run team done. Your full instructions are in the system prompt.`,
441
441
  });
@@ -111,10 +111,15 @@ export async function ensureTeamLead(teamName: string, workingDir: string): Prom
111
111
  return { created: false, session: currentSession, window: sanitizeWindowName(teamName) };
112
112
  }
113
113
 
114
+ // Resolve the actual leader name from team config (falls back to 'team-lead' for legacy)
115
+ const { getTeam } = await import('./team-manager.js');
116
+ const teamConfig = await getTeam(teamName);
117
+ const leaderName = teamConfig?.leader || 'team-lead';
118
+
114
119
  // Create native team structure
115
- await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending');
120
+ await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending', leaderName);
116
121
  await registerNativeMember(teamName, {
117
- agentName: 'team-lead',
122
+ agentName: leaderName,
118
123
  agentType: 'general-purpose',
119
124
  color: 'blue',
120
125
  cwd: workingDir,
@@ -17,7 +17,9 @@ import {
17
17
  hireAgent,
18
18
  listMembers,
19
19
  listTeams,
20
+ resolveLeaderName,
20
21
  setTeamStatus,
22
+ updateTeamConfig,
21
23
  validateBranchName,
22
24
  } from './team-manager.js';
23
25
  import { DB_AVAILABLE, setupTestSchema } from './test-db.js';
@@ -274,6 +276,49 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => {
274
276
  });
275
277
  });
276
278
 
279
+ describe('leader and spawner', () => {
280
+ test('new team gets leader and spawner when set via updateTeamConfig', async () => {
281
+ const config = await createTeam('feat/leader-test', TEST_REPO, 'dev');
282
+ config.leader = 'fix-tmux-session-explosion';
283
+ config.spawner = 'sofia';
284
+ await updateTeamConfig(config.name, config);
285
+
286
+ const updated = await getTeam('feat/leader-test');
287
+ expect(updated!.leader).toBe('fix-tmux-session-explosion');
288
+ expect(updated!.spawner).toBe('sofia');
289
+ });
290
+
291
+ test('resolveLeaderName returns leader for teams with leader set', async () => {
292
+ const config = await createTeam('feat/leader-resolve', TEST_REPO, 'dev');
293
+ config.leader = 'my-wish-slug';
294
+ await updateTeamConfig(config.name, config);
295
+
296
+ const name = await resolveLeaderName('feat/leader-resolve');
297
+ expect(name).toBe('my-wish-slug');
298
+ });
299
+
300
+ test('resolveLeaderName falls back to team-lead for legacy teams', async () => {
301
+ await createTeam('feat/legacy-leader', TEST_REPO, 'dev');
302
+ // No leader set — legacy team
303
+ const name = await resolveLeaderName('feat/legacy-leader');
304
+ expect(name).toBe('team-lead');
305
+ });
306
+
307
+ test('resolveLeaderName throws for nonexistent team', async () => {
308
+ expect(resolveLeaderName('nonexistent-team')).rejects.toThrow('not found');
309
+ });
310
+
311
+ test('spawner persisted in PG teams table', async () => {
312
+ const config = await createTeam('feat/spawner-pg', TEST_REPO, 'dev');
313
+ config.spawner = 'genie-pm';
314
+ await updateTeamConfig(config.name, config);
315
+
316
+ const sql = await getConnection();
317
+ const rows = await sql`SELECT spawner FROM teams WHERE name = ${'feat/spawner-pg'}`;
318
+ expect(rows[0].spawner).toBe('genie-pm');
319
+ });
320
+ });
321
+
277
322
  describe('disbandTeam', () => {
278
323
  test('removes clone directory and config from PG', async () => {
279
324
  const config = await createTeam('feat/disband-test', TEST_REPO, 'dev');
@@ -57,6 +57,8 @@ export interface TeamConfig {
57
57
  tmuxSessionName?: string;
58
58
  /** Wish slug this team is working on (set via --wish). */
59
59
  wishSlug?: string;
60
+ /** Agent name (or 'cli') that created this team — workers report completion here. */
61
+ spawner?: string;
60
62
  /** ISO timestamp when the team was archived (null if not archived). */
61
63
  archivedAt?: string;
62
64
  }
@@ -92,6 +94,7 @@ interface TeamConfigRow {
92
94
  native_teams_enabled?: boolean;
93
95
  tmux_session_name?: string;
94
96
  wish_slug?: string;
97
+ spawner?: string;
95
98
  archived_at?: Date | string | null;
96
99
  }
97
100
 
@@ -111,6 +114,7 @@ function rowToTeamConfig(row: TeamConfigRow): TeamConfig {
111
114
  if (row.native_teams_enabled) config.nativeTeamsEnabled = row.native_teams_enabled;
112
115
  if (row.tmux_session_name) config.tmuxSessionName = row.tmux_session_name;
113
116
  if (row.wish_slug) config.wishSlug = row.wish_slug;
117
+ if (row.spawner) config.spawner = row.spawner;
114
118
  if (row.archived_at) {
115
119
  config.archivedAt = row.archived_at instanceof Date ? row.archived_at.toISOString() : String(row.archived_at);
116
120
  }
@@ -311,7 +315,7 @@ export async function createTeam(name: string, repo: string, baseBranch = 'dev')
311
315
  INSERT INTO teams (
312
316
  name, repo, base_branch, worktree_path, leader,
313
317
  members, status, native_team_parent_session_id,
314
- native_teams_enabled, tmux_session_name, wish_slug, created_at
318
+ native_teams_enabled, tmux_session_name, wish_slug, spawner, created_at
315
319
  ) VALUES (
316
320
  ${config.name}, ${config.repo}, ${config.baseBranch},
317
321
  ${config.worktreePath}, ${config.leader ?? null},
@@ -319,7 +323,7 @@ export async function createTeam(name: string, repo: string, baseBranch = 'dev')
319
323
  ${config.nativeTeamParentSessionId ?? null},
320
324
  ${config.nativeTeamsEnabled ?? false},
321
325
  ${config.tmuxSessionName ?? null}, ${config.wishSlug ?? null},
322
- ${config.createdAt}
326
+ ${config.spawner ?? null}, ${config.createdAt}
323
327
  ) ON CONFLICT (name) DO NOTHING
324
328
  `;
325
329
 
@@ -570,7 +574,8 @@ export async function updateTeamConfig(name: string, config: TeamConfig): Promis
570
574
  native_team_parent_session_id = ${config.nativeTeamParentSessionId ?? null},
571
575
  native_teams_enabled = ${config.nativeTeamsEnabled ?? false},
572
576
  tmux_session_name = ${config.tmuxSessionName ?? null},
573
- wish_slug = ${config.wishSlug ?? null}
577
+ wish_slug = ${config.wishSlug ?? null},
578
+ spawner = ${config.spawner ?? null}
574
579
  WHERE name = ${name}
575
580
  `;
576
581
  }
@@ -623,6 +628,18 @@ export async function killTeamMembers(teamName: string): Promise<void> {
623
628
  }
624
629
  }
625
630
 
631
+ /**
632
+ * Resolve the leader name for a team.
633
+ * Returns config.leader for teams that have it set, falls back to "team-lead" for legacy teams.
634
+ */
635
+ export async function resolveLeaderName(teamName: string): Promise<string> {
636
+ const config = await getTeam(teamName);
637
+ if (!config) {
638
+ throw new Error(`Team "${teamName}" not found.`);
639
+ }
640
+ return config.leader || 'team-lead';
641
+ }
642
+
626
643
  /** Set team lifecycle status. */
627
644
  export async function setTeamStatus(teamName: string, status: TeamStatus): Promise<void> {
628
645
  const sql = await getConnection();