@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.
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for Wish Sync — filesystem to PG wish indexing.
3
+ * Run with: bun test src/lib/wish-sync.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 { getConnection } from './db.js';
10
+ import { DB_AVAILABLE, setupTestSchema } from './test-db.js';
11
+ import { getWish, listWishes, parseWishStatus, syncWishes } from './wish-sync.js';
12
+
13
+ // ============================================================================
14
+ // parseWishStatus (pure parsing — no DB)
15
+ // ============================================================================
16
+
17
+ describe('parseWishStatus', () => {
18
+ test('parses status from markdown table', () => {
19
+ const content = '| **Status** | APPROVED |\n| **Slug** | test |';
20
+ expect(parseWishStatus(content)).toBe('APPROVED');
21
+ });
22
+
23
+ test('parses status with extra whitespace', () => {
24
+ const content = '| **Status** | IN_PROGRESS |';
25
+ expect(parseWishStatus(content)).toBe('IN_PROGRESS');
26
+ });
27
+
28
+ test('returns DRAFT when no status found', () => {
29
+ const content = '# Some wish\n\nNo table here.';
30
+ expect(parseWishStatus(content)).toBe('DRAFT');
31
+ });
32
+
33
+ test('handles DRAFT status', () => {
34
+ const content = '| **Status** | DRAFT |';
35
+ expect(parseWishStatus(content)).toBe('DRAFT');
36
+ });
37
+ });
38
+
39
+ // ============================================================================
40
+ // syncWishes + queries (DB-dependent)
41
+ // ============================================================================
42
+
43
+ describe.skipIf(!DB_AVAILABLE)('pg', () => {
44
+ let cleanupSchema: () => Promise<void>;
45
+ const TEST_DIR = '/tmp/wish-sync-test';
46
+ const TEST_REPO = join(TEST_DIR, 'test-repo');
47
+
48
+ async function setupTestRepo(): Promise<void> {
49
+ await rm(TEST_DIR, { recursive: true, force: true });
50
+
51
+ // Create a repo with two wishes
52
+ const wish1Dir = join(TEST_REPO, '.genie', 'wishes', 'fix-auth');
53
+ const wish2Dir = join(TEST_REPO, '.genie', 'wishes', 'add-search');
54
+ await mkdir(wish1Dir, { recursive: true });
55
+ await mkdir(wish2Dir, { recursive: true });
56
+
57
+ await writeFile(
58
+ join(wish1Dir, 'WISH.md'),
59
+ '# Fix Auth\n\n| Field | Value |\n|-------|-------|\n| **Status** | APPROVED |\n| **Slug** | fix-auth |\n',
60
+ );
61
+ await writeFile(
62
+ join(wish2Dir, 'WISH.md'),
63
+ '# Add Search\n\n| Field | Value |\n|-------|-------|\n| **Status** | DRAFT |\n| **Slug** | add-search |\n',
64
+ );
65
+ }
66
+
67
+ beforeAll(async () => {
68
+ cleanupSchema = await setupTestSchema();
69
+ await setupTestRepo();
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await rm(TEST_DIR, { recursive: true, force: true });
74
+ await cleanupSchema();
75
+ });
76
+
77
+ test('syncWishes upserts wishes from a repo', async () => {
78
+ const count = await syncWishes(TEST_REPO);
79
+ expect(count).toBe(2);
80
+
81
+ // Verify they're in PG
82
+ const sql = await getConnection();
83
+ const rows = await sql`SELECT * FROM wishes WHERE repo = ${TEST_REPO} ORDER BY slug`;
84
+ expect(rows.length).toBe(2);
85
+ expect(rows[0].slug).toBe('add-search');
86
+ expect(rows[0].status).toBe('DRAFT');
87
+ expect(rows[1].slug).toBe('fix-auth');
88
+ expect(rows[1].status).toBe('APPROVED');
89
+ });
90
+
91
+ test('syncWishes is idempotent — multiple runs do not duplicate', async () => {
92
+ await syncWishes(TEST_REPO);
93
+ await syncWishes(TEST_REPO);
94
+
95
+ const sql = await getConnection();
96
+ const rows = await sql`SELECT * FROM wishes WHERE repo = ${TEST_REPO}`;
97
+ expect(rows.length).toBe(2);
98
+ });
99
+
100
+ test('syncWishes updates status on re-sync', async () => {
101
+ // Update the WISH.md status
102
+ const wish1Path = join(TEST_REPO, '.genie', 'wishes', 'fix-auth', 'WISH.md');
103
+ await writeFile(
104
+ wish1Path,
105
+ '# Fix Auth\n\n| Field | Value |\n|-------|-------|\n| **Status** | DONE |\n| **Slug** | fix-auth |\n',
106
+ );
107
+
108
+ await syncWishes(TEST_REPO);
109
+
110
+ const sql = await getConnection();
111
+ const rows = await sql`SELECT status FROM wishes WHERE slug = ${'fix-auth'} AND repo = ${TEST_REPO}`;
112
+ expect(rows[0].status).toBe('DONE');
113
+ });
114
+
115
+ test('listWishes returns all wishes', async () => {
116
+ const wishes = await listWishes();
117
+ expect(wishes.length).toBeGreaterThanOrEqual(2);
118
+ });
119
+
120
+ test('listWishes filters by repo', async () => {
121
+ const wishes = await listWishes({ repo: TEST_REPO });
122
+ expect(wishes.length).toBe(2);
123
+ });
124
+
125
+ test('listWishes filters by status', async () => {
126
+ const wishes = await listWishes({ status: 'DRAFT' });
127
+ expect(wishes.some((w) => w.slug === 'add-search')).toBe(true);
128
+ });
129
+
130
+ test('getWish returns a single wish', async () => {
131
+ const wish = await getWish('add-search', TEST_REPO);
132
+ expect(wish).not.toBeNull();
133
+ expect(wish!.slug).toBe('add-search');
134
+ expect(wish!.status).toBe('DRAFT');
135
+ });
136
+
137
+ test('getWish returns null for nonexistent wish', async () => {
138
+ const wish = await getWish('nonexistent', TEST_REPO);
139
+ expect(wish).toBeNull();
140
+ });
141
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Wish Sync — sync .genie/wishes/ from filesystem to PG for cross-repo querying.
3
+ *
4
+ * WISH.md files are the source of truth. PG is the index.
5
+ * Sync is idempotent — multiple runs don't create duplicates.
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
9
+ import { basename, join } from 'node:path';
10
+ import { getConnection } from './db.js';
11
+
12
+ const REPOS_BASE = '/home/genie/workspace/repos';
13
+
14
+ // ============================================================================
15
+ // Status Parsing
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Parse the Status field from a WISH.md markdown table.
20
+ * Looks for `| **Status** | <value> |` pattern.
21
+ */
22
+ export function parseWishStatus(content: string): string {
23
+ const match = content.match(/\|\s*\*\*Status\*\*\s*\|\s*([^|]+)/i);
24
+ if (match) return match[1].trim();
25
+ return 'DRAFT';
26
+ }
27
+
28
+ // ============================================================================
29
+ // Filesystem Scanning
30
+ // ============================================================================
31
+
32
+ interface DiscoveredWish {
33
+ slug: string;
34
+ repo: string;
35
+ namespace: string;
36
+ status: string;
37
+ filePath: string;
38
+ }
39
+
40
+ /**
41
+ * Scan a single repo for wishes in .genie/wishes/\*\/WISH.md.
42
+ */
43
+ function scanRepoWishes(repoPath: string): DiscoveredWish[] {
44
+ const wishesDir = join(repoPath, '.genie', 'wishes');
45
+ if (!existsSync(wishesDir)) return [];
46
+
47
+ const results: DiscoveredWish[] = [];
48
+ const namespace = basename(repoPath);
49
+
50
+ try {
51
+ const entries = readdirSync(wishesDir, { withFileTypes: true });
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory()) continue;
54
+ const wishPath = join(wishesDir, entry.name, 'WISH.md');
55
+ if (!existsSync(wishPath)) continue;
56
+
57
+ try {
58
+ const content = readFileSync(wishPath, 'utf-8');
59
+ results.push({
60
+ slug: entry.name,
61
+ repo: repoPath,
62
+ namespace,
63
+ status: parseWishStatus(content),
64
+ filePath: wishPath,
65
+ });
66
+ } catch {
67
+ // Unreadable file — skip
68
+ }
69
+ }
70
+ } catch {
71
+ // Can't read wishes dir — skip
72
+ }
73
+
74
+ return results;
75
+ }
76
+
77
+ /**
78
+ * Discover all wishes across all repos in REPOS_BASE, or a single repo.
79
+ */
80
+ function discoverWishes(repoPath?: string): DiscoveredWish[] {
81
+ if (repoPath) return scanRepoWishes(repoPath);
82
+
83
+ if (!existsSync(REPOS_BASE)) return [];
84
+
85
+ const results: DiscoveredWish[] = [];
86
+ try {
87
+ const entries = readdirSync(REPOS_BASE, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (!entry.isDirectory()) continue;
90
+ results.push(...scanRepoWishes(join(REPOS_BASE, entry.name)));
91
+ }
92
+ } catch {
93
+ // Can't read repos base — return empty
94
+ }
95
+
96
+ return results;
97
+ }
98
+
99
+ // ============================================================================
100
+ // PG Sync
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Sync wishes from filesystem to PG.
105
+ * Upserts discovered wishes — idempotent.
106
+ *
107
+ * @param repoPath - If provided, only sync wishes from this repo. Otherwise scan all repos.
108
+ */
109
+ export async function syncWishes(repoPath?: string): Promise<number> {
110
+ const wishes = discoverWishes(repoPath);
111
+ if (wishes.length === 0) return 0;
112
+
113
+ const sql = await getConnection();
114
+
115
+ for (const wish of wishes) {
116
+ await sql`
117
+ INSERT INTO wishes (slug, repo, namespace, status, file_path)
118
+ VALUES (${wish.slug}, ${wish.repo}, ${wish.namespace}, ${wish.status}, ${wish.filePath})
119
+ ON CONFLICT (slug, repo) DO UPDATE SET
120
+ namespace = EXCLUDED.namespace,
121
+ status = EXCLUDED.status,
122
+ file_path = EXCLUDED.file_path,
123
+ updated_at = now()
124
+ `;
125
+ }
126
+
127
+ return wishes.length;
128
+ }
129
+
130
+ // ============================================================================
131
+ // Queries
132
+ // ============================================================================
133
+
134
+ interface WishRow {
135
+ id: number;
136
+ slug: string;
137
+ repo: string;
138
+ namespace: string | null;
139
+ status: string;
140
+ file_path: string;
141
+ created_at: string;
142
+ updated_at: string;
143
+ }
144
+
145
+ /**
146
+ * List wishes from PG with optional filters.
147
+ */
148
+ export async function listWishes(filters?: {
149
+ repo?: string;
150
+ status?: string;
151
+ namespace?: string;
152
+ }): Promise<WishRow[]> {
153
+ const sql = await getConnection();
154
+
155
+ if (filters?.repo && filters?.status) {
156
+ return sql`SELECT * FROM wishes WHERE repo = ${filters.repo} AND status = ${filters.status} ORDER BY updated_at DESC`;
157
+ }
158
+ if (filters?.repo) {
159
+ return sql`SELECT * FROM wishes WHERE repo = ${filters.repo} ORDER BY updated_at DESC`;
160
+ }
161
+ if (filters?.status) {
162
+ return sql`SELECT * FROM wishes WHERE status = ${filters.status} ORDER BY updated_at DESC`;
163
+ }
164
+ if (filters?.namespace) {
165
+ return sql`SELECT * FROM wishes WHERE namespace = ${filters.namespace} ORDER BY updated_at DESC`;
166
+ }
167
+
168
+ return sql`SELECT * FROM wishes ORDER BY updated_at DESC`;
169
+ }
170
+
171
+ /**
172
+ * Get a single wish by slug, optionally scoped to a repo.
173
+ */
174
+ export async function getWish(slug: string, repo?: string): Promise<WishRow | null> {
175
+ const sql = await getConnection();
176
+
177
+ const rows = repo
178
+ ? await sql`SELECT * FROM wishes WHERE slug = ${slug} AND repo = ${repo} LIMIT 1`
179
+ : await sql`SELECT * FROM wishes WHERE slug = ${slug} ORDER BY updated_at DESC LIMIT 1`;
180
+
181
+ return rows.length > 0 ? (rows[0] as WishRow) : null;
182
+ }
@@ -44,6 +44,18 @@ export function registerAgentSend(parent: Command): void {
44
44
  // Hierarchy ACL
45
45
  // ============================================================================
46
46
 
47
+ /** Check if an agent name matches the team's leader (by alias or actual name). */
48
+ async function isTeamLeader(agentName: string, teamName: string): Promise<boolean> {
49
+ if (agentName === 'team-lead') return true;
50
+ try {
51
+ const teamMgr = await import('../../lib/team-manager.js');
52
+ const config = await teamMgr.getTeam(teamName);
53
+ return agentName === config?.leader;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
47
59
  /**
48
60
  * Check hierarchy: sender can reach recipient if:
49
61
  * 1. sender is 'cli' (bypass)
@@ -74,8 +86,10 @@ async function checkHierarchy(from: string, to: string): Promise<{ allowed: bool
74
86
  // Siblings: same manager
75
87
  if (sender.reportsTo && sender.reportsTo === recipient.reportsTo) return { allowed: true };
76
88
 
77
- // Team-lead can reach anyone in their team
78
- if (from === 'team-lead' && sender.team && sender.team === recipient.team) return { allowed: true };
89
+ // Leader can reach anyone in their team
90
+ if (sender.team && sender.team === recipient.team && (await isTeamLeader(from, sender.team))) {
91
+ return { allowed: true };
92
+ }
79
93
 
80
94
  const manager = sender.reportsTo ?? 'your manager';
81
95
  return {
@@ -38,6 +38,19 @@ import { isPaneAlive } from '../lib/tmux.js';
38
38
  // Helper Functions
39
39
  // ============================================================================
40
40
 
41
+ /**
42
+ * Resolve the leader name for a team from team config.
43
+ * Falls back to 'team-lead' for legacy teams without a leader set.
44
+ */
45
+ async function resolveTeamLeaderName(teamNameOrDefault: string): Promise<string> {
46
+ try {
47
+ const config = await teamManager.getTeam(teamNameOrDefault);
48
+ return config?.leader || 'team-lead';
49
+ } catch {
50
+ return 'team-lead';
51
+ }
52
+ }
53
+
41
54
  /** Check if a process is alive by PID file. */
42
55
  function isRelayAlive(pidFile: string): boolean {
43
56
  const { readFileSync, existsSync } = require('node:fs');
@@ -79,6 +92,7 @@ async function ensureOtelRelay(team: string): Promise<boolean> {
79
92
  if (isRelayAlive(pidFile)) return true;
80
93
 
81
94
  const inboxDir = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'teams', team, 'inboxes');
95
+ const leaderInboxName = nativeTeams.sanitizeTeamName(await resolveTeamLeaderName(team));
82
96
  const escapedRelayDir = relayDir.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
83
97
  const escapedInboxDir = inboxDir.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
84
98
  const escapedPidFile = pidFile.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
@@ -103,7 +117,7 @@ import { join } from 'path';
103
117
 
104
118
  const RELAY_DIR = '${escapedRelayDir}';
105
119
  const INBOX_DIR = '${escapedInboxDir}';
106
- const INBOX = join(INBOX_DIR, 'team-lead.json');
120
+ const INBOX = join(INBOX_DIR, '${leaderInboxName}.json');
107
121
  const PID_FILE = '${escapedPidFile}';
108
122
  const PORT = ${OTEL_RELAY_PORT};
109
123
  const SILENCE_MS = 5000;
@@ -570,7 +584,9 @@ async function notifySpawnJoin(ctx: SpawnCtx, paneId: string): Promise<void> {
570
584
  cwd: ctx.cwd,
571
585
  planModeRequired: nt.planModeRequired,
572
586
  });
573
- await nativeTeams.writeNativeInbox(ctx.validated.team, 'team-lead', {
587
+ // Resolve the actual leader name for the inbox notification
588
+ const leaderName = await resolveTeamLeaderName(ctx.validated.team);
589
+ await nativeTeams.writeNativeInbox(ctx.validated.team, leaderName, {
574
590
  from: ctx.agentName,
575
591
  text: `Worker ${ctx.agentName} (${ctx.validated.provider}) joined team ${ctx.validated.team}. cwd: ${ctx.cwd}. Ready for tasks.`,
576
592
  summary: `${ctx.agentName} (${ctx.validated.provider}) joined`,
@@ -1430,7 +1446,10 @@ function formatGroupStatus(
1430
1446
  * Returns undefined if no wish context found.
1431
1447
  */
1432
1448
  export async function buildResumeContext(agent: registry.Agent): Promise<string | undefined> {
1433
- if (agent.role === 'team-lead' && agent.wishSlug) {
1449
+ // Check if this agent is a leader (by role 'team-lead' or matching the team's leader name)
1450
+ const isLeader =
1451
+ agent.role === 'team-lead' || (agent.team && agent.role === (await resolveTeamLeaderName(agent.team)));
1452
+ if (isLeader && agent.wishSlug) {
1434
1453
  try {
1435
1454
  const wishState = await import('../lib/wish-state.js');
1436
1455
  const state = await wishState.getState(agent.wishSlug, agent.repoPath);