@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.
@@ -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
+ }
@@ -37,4 +37,10 @@ export function registerAgentCommands(program: Command): void {
37
37
  registerAgentBrief(agent);
38
38
  registerAgentLog(agent);
39
39
  registerAgentSend(agent);
40
+
41
+ agent.on('command:*', (operands: string[]) => {
42
+ const cmd = operands[0];
43
+ const available = agent.commands.map((c) => c.name()).join(', ');
44
+ agent.error(`Unknown agent command '${cmd}'. Available: ${available}`);
45
+ });
40
46
  }
@@ -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);
@@ -33,7 +33,6 @@ import { printSyncResult, syncAgentDirectory } from '../lib/agent-sync.js';
33
33
  import { getActor, recordAuditEvent } from '../lib/audit.js';
34
34
  import { ALL_BUILTINS } from '../lib/builtin-agents.js';
35
35
  import { contractPath } from '../lib/genie-config.js';
36
- import { findOmniAgent, registerAgentInOmni, resolveOmniApiUrl } from '../lib/omni-registration.js';
37
36
 
38
37
  export function registerDirNamespace(program: Command): void {
39
38
  const dir = program.command('dir').description('Agent directory management');
@@ -420,94 +419,3 @@ function printBuiltinsTable(): void {
420
419
  }
421
420
  console.log('');
422
421
  }
423
-
424
- // ============================================================================
425
- // Agent namespace — genie agent register
426
- // ============================================================================
427
-
428
- interface RegisterOptions {
429
- dir: string;
430
- repo?: string;
431
- promptMode: string;
432
- model?: string;
433
- roles?: string[];
434
- global?: boolean;
435
- skipOmni?: boolean;
436
- }
437
-
438
- async function handleOmniRegistration(
439
- name: string,
440
- options: { model?: string; roles?: string[]; global?: boolean },
441
- ): Promise<void> {
442
- const omniUrl = await resolveOmniApiUrl();
443
- if (!omniUrl) return;
444
-
445
- console.log(`\nRegistering in Omni (${omniUrl})...`);
446
-
447
- const existingId = await findOmniAgent(name);
448
- if (existingId) {
449
- console.log(` Agent already exists in Omni: ${existingId}`);
450
- await directory.edit(name, { omniAgentId: existingId }, { global: options.global });
451
- console.log(' Linked existing Omni agent to directory entry.');
452
- return;
453
- }
454
-
455
- const omniAgentId = await registerAgentInOmni(name, {
456
- model: options.model,
457
- roles: options.roles,
458
- });
459
- if (omniAgentId) {
460
- await directory.edit(name, { omniAgentId }, { global: options.global });
461
- console.log(` Omni agent created: ${omniAgentId}`);
462
- console.log(' Session isolation: per-person + per-channel');
463
- }
464
- }
465
-
466
- async function handleAgentRegister(name: string, options: RegisterOptions): Promise<void> {
467
- const promptMode = validatePromptMode(options.promptMode);
468
-
469
- const roles = normalizeRoles(options.roles);
470
- const entry = await directory.add(
471
- {
472
- name,
473
- dir: resolvePath(options.dir),
474
- repo: options.repo ? resolvePath(options.repo) : undefined,
475
- promptMode,
476
- model: options.model,
477
- roles,
478
- },
479
- { global: options.global },
480
- );
481
-
482
- const scope = options.global ? 'global' : 'project';
483
- console.log(`Agent "${entry.name}" registered (${scope}).`);
484
- printEntry(entry);
485
-
486
- if (!options.skipOmni) {
487
- await handleOmniRegistration(name, { ...options, roles });
488
- }
489
- }
490
-
491
- export function registerAgentNamespace(program: Command): void {
492
- const agent = program.command('agent').description('Agent lifecycle management');
493
-
494
- agent
495
- .command('register <name>')
496
- .description('Register an agent locally and auto-register in Omni when configured')
497
- .requiredOption('--dir <path>', 'Agent folder (CWD + AGENTS.md)')
498
- .option('--repo <path>', 'Default git repo (overridden by team)')
499
- .option('--prompt-mode <mode>', 'Prompt mode: append or system', 'append')
500
- .option('--model <model>', 'Default model (sonnet, opus, codex)')
501
- .option('--roles <roles...>', 'Built-in roles this agent can orchestrate')
502
- .option('--global', 'Write to global directory instead of project')
503
- .option('--skip-omni', 'Skip Omni auto-registration')
504
- .action(async (name: string, options: RegisterOptions) => {
505
- try {
506
- await handleAgentRegister(name, options);
507
- } catch (error) {
508
- const message = error instanceof Error ? error.message : String(error);
509
- console.error(`Error: ${message}`);
510
- process.exit(1);
511
- }
512
- });
513
- }
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
  import type { Command } from 'commander';
26
26
  import * as protocolRouter from '../lib/protocol-router.js';
27
+ import { parseWishRef, resolveWish } from '../lib/wish-resolve.js';
27
28
  import type { GroupDefinition } from '../lib/wish-state.js';
28
29
  import * as wishState from '../lib/wish-state.js';
29
30
  import { handleWorkerSpawn } from './agents.js';
@@ -291,6 +292,28 @@ function buildFallbackWaves(content: string): Wave[] {
291
292
  ];
292
293
  }
293
294
 
295
+ // ============================================================================
296
+ // Leader Resolution
297
+ // ============================================================================
298
+
299
+ /**
300
+ * Resolve the leader name for --to in dispatch prompts.
301
+ * Uses GENIE_TEAM to look up the team config's leader field.
302
+ * Falls back to 'team-lead' for legacy teams.
303
+ */
304
+ async function resolveLeaderTarget(): Promise<string> {
305
+ const teamName = process.env.GENIE_TEAM;
306
+ if (!teamName) return 'team-lead';
307
+
308
+ try {
309
+ const teamManager = await import('../lib/team-manager.js');
310
+ const config = await teamManager.getTeam(teamName);
311
+ return config?.leader || 'team-lead';
312
+ } catch {
313
+ return 'team-lead';
314
+ }
315
+ }
316
+
294
317
  // ============================================================================
295
318
  // Auto-Orchestration (fire-and-forget)
296
319
  // ============================================================================
@@ -333,7 +356,28 @@ export function detectWorkMode(
333
356
  * notifying the team-lead.
334
357
  */
335
358
  async function autoOrchestrateCommand(slug: string): Promise<void> {
336
- const wishPath = join(process.cwd(), '.genie', 'wishes', slug, 'WISH.md');
359
+ let wishPath: string;
360
+ let actualSlug = slug;
361
+
362
+ // Check for namespace/slug format — resolve and auto-create team
363
+ const parsed = parseWishRef(slug);
364
+ if (parsed.namespace) {
365
+ const resolved = await resolveWish(slug);
366
+ wishPath = resolved.wishPath;
367
+ actualSlug = resolved.slug;
368
+
369
+ // Auto-create team using the resolved repo and session
370
+ const { handleTeamCreate } = await import('./team.js');
371
+ await handleTeamCreate(actualSlug, {
372
+ repo: resolved.repo,
373
+ branch: 'dev',
374
+ wish: actualSlug,
375
+ tmuxSession: resolved.session,
376
+ });
377
+ return; // handleTeamCreate spawns the leader, which runs the full lifecycle
378
+ }
379
+
380
+ wishPath = join(process.cwd(), '.genie', 'wishes', slug, 'WISH.md');
337
381
 
338
382
  if (!existsSync(wishPath)) {
339
383
  console.error(`❌ Wish not found: ${wishPath}`);
@@ -341,6 +385,9 @@ async function autoOrchestrateCommand(slug: string): Promise<void> {
341
385
  process.exit(1);
342
386
  }
343
387
 
388
+ // Best-effort: sync wish to PG index (non-blocking)
389
+ import('../lib/wish-sync.js').then((ws) => ws.syncWishes(process.cwd())).catch(() => {});
390
+
344
391
  const content = await readFile(wishPath, 'utf-8');
345
392
  const groups = parseWishGroups(content);
346
393
  const waves = parseExecutionStrategy(content);
@@ -545,7 +592,8 @@ async function workDispatchCommand(agentName: string, ref: string): Promise<void
545
592
  console.log(` Group: ${group}`);
546
593
 
547
594
  const effectiveRole = `${agentName}-${group}`;
548
- const workPrompt = `Execute Group ${group} of wish "${slug}". Your full context is in the system prompt. Read the wish at ${wishPath} if needed. Implement all deliverables, run validation, and report completion.\n\nWhen done:\n1. Run: genie done ${slug}#${group}\n2. Run: genie send 'Group ${group} complete. <summary>' --to team-lead`;
595
+ const leaderTarget = await resolveLeaderTarget();
596
+ const workPrompt = `Execute Group ${group} of wish "${slug}". Your full context is in the system prompt. Read the wish at ${wishPath} if needed. Implement all deliverables, run validation, and report completion.\n\nWhen done:\n1. Run: genie done ${slug}#${group}\n2. Run: genie send 'Group ${group} complete. <summary>' --to ${leaderTarget}`;
549
597
  await handleWorkerSpawn(agentName, {
550
598
  provider: 'claude',
551
599
  team: process.env.GENIE_TEAM ?? 'genie',
@@ -620,7 +668,8 @@ async function reviewCommand(agentName: string, ref: string): Promise<void> {
620
668
  console.log(` Group: ${group}`);
621
669
  if (diff) console.log(` Diff: ${diff.split('\n').length} lines`);
622
670
 
623
- const reviewPrompt = `Review "${ref}". Your context and diff are in the system prompt. Evaluate against acceptance criteria and return SHIP, FIX-FIRST, or BLOCKED with severity-tagged findings.\n\nWhen done, report your verdict:\nRun: genie send '<SHIP|FIX-FIRST|BLOCKED> — <summary>' --to team-lead`;
671
+ const reviewLeaderTarget = await resolveLeaderTarget();
672
+ const reviewPrompt = `Review "${ref}". Your context and diff are in the system prompt. Evaluate against acceptance criteria and return SHIP, FIX-FIRST, or BLOCKED with severity-tagged findings.\n\nWhen done, report your verdict:\nRun: genie send '<SHIP|FIX-FIRST|BLOCKED> — <summary>' --to ${reviewLeaderTarget}`;
624
673
  await handleWorkerSpawn(agentName, {
625
674
  provider: 'claude',
626
675
  team: process.env.GENIE_TEAM ?? 'genie',