@automagik/genie 4.260331.4 → 4.260331.6
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/CLAUDE.md +31 -5
- package/dist/genie.js +652 -615
- 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/skills/genie/SKILL.md +71 -233
- package/skills/genie/reference/lifecycle.md +65 -0
- 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/genie.ts +26 -3
- 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/index.ts +6 -0
- package/src/term-commands/agent/send.ts +16 -2
- package/src/term-commands/agents.ts +22 -3
- package/src/term-commands/dir.ts +0 -92
- package/src/term-commands/dispatch.ts +52 -3
- package/src/term-commands/msg.ts +54 -14
- package/src/term-commands/state.ts +35 -5
- package/src/term-commands/team.ts +23 -8
|
@@ -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();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Wish Resolution — namespace/slug parsing and path resolution.
|
|
3
|
+
* Run with: bun test src/lib/wish-resolve.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { parseWishRef, resolveWish } from './wish-resolve.js';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// parseWishRef (pure parsing — no filesystem)
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
describe('parseWishRef', () => {
|
|
16
|
+
test('parses namespace/slug format', () => {
|
|
17
|
+
const ref = parseWishRef('genie/fix-tmux-session-explosion');
|
|
18
|
+
expect(ref.namespace).toBe('genie');
|
|
19
|
+
expect(ref.slug).toBe('fix-tmux-session-explosion');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('parses bare slug without namespace', () => {
|
|
23
|
+
const ref = parseWishRef('fix-tmux-session-explosion');
|
|
24
|
+
expect(ref.namespace).toBeUndefined();
|
|
25
|
+
expect(ref.slug).toBe('fix-tmux-session-explosion');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('trims whitespace', () => {
|
|
29
|
+
const ref = parseWishRef(' genie/slug ');
|
|
30
|
+
expect(ref.namespace).toBe('genie');
|
|
31
|
+
expect(ref.slug).toBe('slug');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('throws on empty string', () => {
|
|
35
|
+
expect(() => parseWishRef('')).toThrow('cannot be empty');
|
|
36
|
+
expect(() => parseWishRef(' ')).toThrow('cannot be empty');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('throws on empty namespace', () => {
|
|
40
|
+
expect(() => parseWishRef('/slug')).toThrow('namespace is empty');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('throws on empty slug', () => {
|
|
44
|
+
expect(() => parseWishRef('genie/')).toThrow('slug is empty');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('throws on slug with nested slashes', () => {
|
|
48
|
+
expect(() => parseWishRef('genie/a/b')).toThrow('slug cannot contain "/"');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// resolveWish (filesystem-dependent)
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
describe('resolveWish', () => {
|
|
57
|
+
const TEST_DIR = '/tmp/wish-resolve-test';
|
|
58
|
+
const FAKE_REPOS = join(TEST_DIR, 'repos');
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
62
|
+
|
|
63
|
+
// Create a fake repo with a wish
|
|
64
|
+
const repoDir = join(FAKE_REPOS, 'myrepo');
|
|
65
|
+
const wishDir = join(repoDir, '.genie', 'wishes', 'fix-bug');
|
|
66
|
+
await mkdir(wishDir, { recursive: true });
|
|
67
|
+
await writeFile(join(wishDir, 'WISH.md'), '# Fix Bug Wish');
|
|
68
|
+
|
|
69
|
+
// Create a bare .git dir so resolveRepoSession doesn't fail
|
|
70
|
+
await mkdir(join(repoDir, '.git'), { recursive: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('throws for nonexistent repo namespace', async () => {
|
|
78
|
+
await expect(resolveWish('nonexistent/some-slug')).rejects.toThrow('not found at');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('throws for nonexistent wish in valid repo', async () => {
|
|
82
|
+
// This test requires a repo at the expected REPOS_BASE location.
|
|
83
|
+
// Since we can't mock the path, we test the error message format.
|
|
84
|
+
await expect(resolveWish('nonexistent/slug')).rejects.toThrow('not found');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('bare slug without namespace errors with helpful message', async () => {
|
|
88
|
+
// cwd won't have this wish
|
|
89
|
+
await expect(resolveWish('nonexistent-wish-slug')).rejects.toThrow('Use namespace/slug format');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('bare slug found in cwd resolves correctly', async () => {
|
|
93
|
+
// Temporarily change cwd to a dir with a wish
|
|
94
|
+
const repoDir = join(FAKE_REPOS, 'myrepo');
|
|
95
|
+
const originalCwd = process.cwd();
|
|
96
|
+
process.chdir(repoDir);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const result = await resolveWish('fix-bug');
|
|
100
|
+
expect(result.slug).toBe('fix-bug');
|
|
101
|
+
expect(result.repo).toBe(repoDir);
|
|
102
|
+
expect(result.wishPath).toContain('fix-bug/WISH.md');
|
|
103
|
+
expect(result.session).toBeTruthy();
|
|
104
|
+
} finally {
|
|
105
|
+
process.chdir(originalCwd);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wish Resolution — resolve namespace/slug references to concrete paths.
|
|
3
|
+
*
|
|
4
|
+
* Convention: `{namespace}/{slug}` where namespace = repo basename.
|
|
5
|
+
* Repos live at /home/genie/workspace/repos/{namespace}.
|
|
6
|
+
* WISH.md lives at {repo}/.genie/wishes/{slug}/WISH.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { resolveRepoSession } from './tmux.js';
|
|
12
|
+
|
|
13
|
+
const REPOS_BASE = '/home/genie/workspace/repos';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
interface WishRef {
|
|
20
|
+
namespace?: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ResolvedWish {
|
|
25
|
+
repo: string;
|
|
26
|
+
wishPath: string;
|
|
27
|
+
session: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Parsing
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a wish reference string into namespace and slug.
|
|
37
|
+
*
|
|
38
|
+
* Formats:
|
|
39
|
+
* "namespace/slug" → { namespace: "namespace", slug: "slug" }
|
|
40
|
+
* "slug" → { slug: "slug" }
|
|
41
|
+
*
|
|
42
|
+
* Only the first `/` is used as delimiter — slugs may not contain `/`.
|
|
43
|
+
*/
|
|
44
|
+
export function parseWishRef(ref: string): WishRef {
|
|
45
|
+
const trimmed = ref.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
throw new Error('Wish reference cannot be empty');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const slashIndex = trimmed.indexOf('/');
|
|
51
|
+
if (slashIndex === -1) {
|
|
52
|
+
return { slug: trimmed };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const namespace = trimmed.slice(0, slashIndex);
|
|
56
|
+
const slug = trimmed.slice(slashIndex + 1);
|
|
57
|
+
|
|
58
|
+
if (!namespace) {
|
|
59
|
+
throw new Error(`Invalid wish reference "${ref}": namespace is empty`);
|
|
60
|
+
}
|
|
61
|
+
if (!slug) {
|
|
62
|
+
throw new Error(`Invalid wish reference "${ref}": slug is empty`);
|
|
63
|
+
}
|
|
64
|
+
if (slug.includes('/')) {
|
|
65
|
+
throw new Error(`Invalid wish reference "${ref}": slug cannot contain "/"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { namespace, slug };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Resolution
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a wish reference to a concrete repo path, wish path, and tmux session.
|
|
77
|
+
*
|
|
78
|
+
* @param ref - Wish reference in "namespace/slug" or "slug" format
|
|
79
|
+
* @returns Resolved wish with repo, wishPath, session, and slug
|
|
80
|
+
* @throws If repo not found, wish not found, or bare slug without namespace
|
|
81
|
+
*/
|
|
82
|
+
export async function resolveWish(ref: string): Promise<ResolvedWish> {
|
|
83
|
+
const parsed = parseWishRef(ref);
|
|
84
|
+
|
|
85
|
+
if (!parsed.namespace) {
|
|
86
|
+
// Bare slug — check cwd first
|
|
87
|
+
const cwdWishPath = join(process.cwd(), '.genie', 'wishes', parsed.slug, 'WISH.md');
|
|
88
|
+
if (existsSync(cwdWishPath)) {
|
|
89
|
+
const repo = process.cwd();
|
|
90
|
+
const session = await resolveRepoSession(repo);
|
|
91
|
+
return {
|
|
92
|
+
repo,
|
|
93
|
+
wishPath: cwdWishPath,
|
|
94
|
+
session,
|
|
95
|
+
slug: parsed.slug,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Wish "${parsed.slug}" not found in current directory. Use namespace/slug format (e.g., genie/${parsed.slug}) to specify the repo.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Namespace provided — resolve repo
|
|
105
|
+
const repo = join(REPOS_BASE, parsed.namespace);
|
|
106
|
+
if (!existsSync(repo)) {
|
|
107
|
+
throw new Error(`Repository "${parsed.namespace}" not found at ${repo}. Available repos are in ${REPOS_BASE}.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verify WISH.md exists
|
|
111
|
+
const wishPath = join(repo, '.genie', 'wishes', parsed.slug, 'WISH.md');
|
|
112
|
+
if (!existsSync(wishPath)) {
|
|
113
|
+
throw new Error(`Wish "${parsed.slug}" not found in repo "${parsed.namespace}". Expected: ${wishPath}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = await resolveRepoSession(repo);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
repo,
|
|
120
|
+
wishPath,
|
|
121
|
+
session,
|
|
122
|
+
slug: parsed.slug,
|
|
123
|
+
};
|
|
124
|
+
}
|