@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +179 -170
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/db/migrations/016_team_spawner.sql +5 -0
- package/src/db/migrations/017_wishes_table.sql +18 -0
- package/src/genie-commands/session.ts +19 -7
- package/src/hooks/handlers/auto-spawn.ts +14 -0
- package/src/lib/agent-registry.ts +25 -5
- package/src/lib/claude-native-teams.ts +69 -45
- package/src/lib/protocol-router-spawn.ts +10 -1
- package/src/lib/qa-runner.ts +1 -1
- package/src/lib/team-auto-spawn.ts +7 -2
- package/src/lib/team-manager.test.ts +45 -0
- package/src/lib/team-manager.ts +20 -3
- package/src/lib/wish-resolve.test.ts +108 -0
- package/src/lib/wish-resolve.ts +124 -0
- package/src/lib/wish-sync.test.ts +141 -0
- package/src/lib/wish-sync.ts +182 -0
- package/src/term-commands/agent/send.ts +16 -2
- package/src/term-commands/agents.ts +22 -3
- package/src/term-commands/dispatch.ts +52 -3
- package/src/term-commands/msg.ts +46 -14
- package/src/term-commands/state.ts +35 -5
- package/src/term-commands/team.ts +15 -8
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260331.
|
|
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"
|
|
@@ -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
|
-
|
|
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:
|
|
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}
|
|
109
|
+
id: `${sanitized}-${sanitizedLeader}`,
|
|
98
110
|
paneId,
|
|
99
111
|
session: sessionName,
|
|
100
112
|
team: windowName,
|
|
101
|
-
role:
|
|
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:
|
|
122
|
+
nativeAgentId: `${sanitizedLeader}@${sanitized}`,
|
|
111
123
|
});
|
|
112
124
|
|
|
113
|
-
// Executor model: create agent identity + executor for
|
|
114
|
-
const agentIdentity = await registry.findOrCreateAgent(
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
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
|
|
442
|
+
* List all teams that have unread messages in their leader's inbox.
|
|
392
443
|
*
|
|
393
|
-
* Scans `~/.claude/teams/` for teams where
|
|
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 →
|
|
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 [];
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
706
|
-
const inbox = inboxPath(teamName,
|
|
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
|
-
|
|
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`,
|
package/src/lib/qa-runner.ts
CHANGED
|
@@ -435,7 +435,7 @@ async function runPreparedSpec(
|
|
|
435
435
|
team: teamName,
|
|
436
436
|
session: teamName,
|
|
437
437
|
cwd: worktreePath,
|
|
438
|
-
role: '
|
|
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:
|
|
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');
|
package/src/lib/team-manager.ts
CHANGED
|
@@ -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();
|