@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.
@@ -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();
@@ -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
+ }