@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.
@@ -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',
@@ -99,6 +99,29 @@ async function findMemberByPane(teamName: string, paneId: string): Promise<strin
99
99
  }
100
100
  }
101
101
 
102
+ // ============================================================================
103
+ // Leader Alias Resolution
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Resolve the 'team-lead' alias to the actual leader name for a given team context.
108
+ * Falls back to 'team-lead' for legacy teams without a leader set.
109
+ */
110
+ async function resolveLeaderAlias(recipient: string, teamContext?: string): Promise<string> {
111
+ if (recipient !== 'team-lead') return recipient;
112
+
113
+ const teamManager = await getTeamManager();
114
+
115
+ // Try explicit team context first
116
+ const teamName = teamContext ?? process.env.GENIE_TEAM;
117
+ if (teamName) {
118
+ const config = await teamManager.getTeam(teamName);
119
+ if (config?.leader) return config.leader;
120
+ }
121
+
122
+ return 'team-lead';
123
+ }
124
+
102
125
  // ============================================================================
103
126
  // Scope Checking
104
127
  // ============================================================================
@@ -124,11 +147,12 @@ export async function checkSendScope(_repoPath: string, sender: string, recipien
124
147
  return `Scope violation: "${recipient}" is not in sender's team(s): ${teamNames}`;
125
148
  }
126
149
 
127
- /** Build the list of teams the sender belongs to, including env-based team-lead membership. */
150
+ /** Build the list of teams the sender belongs to, including env-based leader membership. */
128
151
  function resolveSenderTeams(teams: teamManagerTypes.TeamConfig[], sender: string): teamManagerTypes.TeamConfig[] {
129
152
  let senderTeams = teams.filter((t) => t.members.includes(sender));
130
153
 
131
- if (sender === 'team-lead') {
154
+ // If sender is the leader (by name or 'team-lead' alias), include the leader's team
155
+ if (sender === 'team-lead' || teams.some((t) => t.leader === sender)) {
132
156
  const envTeam = process.env.GENIE_TEAM;
133
157
  if (envTeam) {
134
158
  const leaderTeam = teams.find((t) => t.name === envTeam);
@@ -141,9 +165,10 @@ function resolveSenderTeams(teams: teamManagerTypes.TeamConfig[], sender: string
141
165
  return senderTeams;
142
166
  }
143
167
 
144
- /** Check whether a recipient is reachable within a given team (direct member, team-lead, or prefixed name). */
168
+ /** Check whether a recipient is reachable within a given team (direct member, leader, or prefixed name). */
145
169
  function isRecipientInTeam(team: teamManagerTypes.TeamConfig, recipient: string): boolean {
146
- if (team.members.includes(recipient) || recipient === 'team-lead') return true;
170
+ // Direct member, legacy team-lead alias, or actual leader name
171
+ if (team.members.includes(recipient) || recipient === 'team-lead' || recipient === team.leader) return true;
147
172
  if (recipient.startsWith(`${team.name}-`)) {
148
173
  const roleOnly = recipient.slice(team.name.length + 1);
149
174
  if (team.members.includes(roleOnly)) return true;
@@ -161,9 +186,13 @@ async function findAgentTeam(_repoPath: string, agentName: string): Promise<team
161
186
  const memberTeam = teams.find((t) => t.members.includes(agentName));
162
187
  if (memberTeam) return memberTeam;
163
188
 
164
- if (agentName === 'team-lead') {
189
+ // Match by leader name or legacy 'team-lead' alias
190
+ if (agentName === 'team-lead' || teams.some((t) => t.leader === agentName)) {
165
191
  const envTeam = process.env.GENIE_TEAM;
166
192
  if (envTeam) return teams.find((t) => t.name === envTeam) ?? null;
193
+ // Also find by leader field directly
194
+ const leaderTeam = teams.find((t) => t.leader === agentName);
195
+ if (leaderTeam) return leaderTeam;
167
196
  }
168
197
 
169
198
  return null;
@@ -430,14 +459,17 @@ async function handleSend(body: string, options: { to: string; from?: string; te
430
459
  const repoPath = process.cwd();
431
460
  const from = options.from ?? (await detectSenderIdentity(options.team));
432
461
 
433
- const scopeError = await checkSendScope(repoPath, from, options.to);
462
+ // Resolve 'team-lead' alias to actual leader name
463
+ const to = await resolveLeaderAlias(options.to, options.team);
464
+
465
+ const scopeError = await checkSendScope(repoPath, from, to);
434
466
  if (scopeError) {
435
467
  console.error(`Error: ${scopeError}`);
436
468
  process.exit(1);
437
469
  }
438
470
 
439
471
  const senderActor = localActor(from);
440
- const recipientActor = localActor(options.to);
472
+ const recipientActor = localActor(to);
441
473
 
442
474
  const conv = await ts.findOrCreateConversation({
443
475
  type: 'dm',
@@ -448,19 +480,19 @@ async function handleSend(body: string, options: { to: string; from?: string; te
448
480
  await ts.addMember(conv.id, senderActor);
449
481
  await ts.addMember(conv.id, recipientActor);
450
482
 
451
- const mailboxMessage = await mailbox.send(repoPath, from, options.to, body);
483
+ const mailboxMessage = await mailbox.send(repoPath, from, to, body);
452
484
  const msg = await ts.sendMessage(conv.id, senderActor, body);
453
485
 
454
486
  // Emit runtime event for real-time observability (fire-and-forget)
455
487
  try {
456
488
  const { publishSubjectEvent } = await import('../lib/runtime-events.js');
457
- await publishSubjectEvent(repoPath, `genie.msg.${options.to}`, {
489
+ await publishSubjectEvent(repoPath, `genie.msg.${to}`, {
458
490
  kind: 'message',
459
491
  agent: from,
460
492
  direction: 'out',
461
- peer: options.to,
493
+ peer: to,
462
494
  text: body,
463
- data: { messageId: msg.id, conversationId: conv.id, from, to: options.to },
495
+ data: { messageId: msg.id, conversationId: conv.id, from, to },
464
496
  source: 'mailbox',
465
497
  });
466
498
  } catch {
@@ -468,16 +500,16 @@ async function handleSend(body: string, options: { to: string; from?: string; te
468
500
  }
469
501
 
470
502
  // Best-effort native inbox bridge
471
- const bridged = await bridgeToNativeInbox(from, options.to, body, options.team).catch((err) => {
503
+ const bridged = await bridgeToNativeInbox(from, to, body, options.team).catch((err) => {
472
504
  const reason = err instanceof Error ? err.message : String(err);
473
505
  console.warn(`[genie send] Native inbox bridge failed: ${reason}`);
474
506
  return false;
475
507
  });
476
508
  if (bridged) {
477
- await mailbox.markDelivered(repoPath, options.to, mailboxMessage.id).catch(() => {});
509
+ await mailbox.markDelivered(repoPath, to, mailboxMessage.id).catch(() => {});
478
510
  }
479
511
 
480
- console.log(`Message sent to "${options.to}".`);
512
+ console.log(`Message sent to "${to}".`);
481
513
  console.log(` ID: ${msg.id}`);
482
514
  console.log(` Conversation: ${conv.id}`);
483
515
  }
@@ -235,7 +235,27 @@ function autoKillPane(): void {
235
235
  // ============================================================================
236
236
 
237
237
  /**
238
- * Notify team-lead of wave or wish completion via protocol-router.
238
+ * Resolve the leader name and spawner for the current team context.
239
+ * Falls back to 'team-lead' for legacy teams.
240
+ */
241
+ async function resolveNotificationTargets(): Promise<{ leader: string; spawner?: string }> {
242
+ const teamName = process.env.GENIE_TEAM;
243
+ if (!teamName) return { leader: 'team-lead' };
244
+
245
+ try {
246
+ const teamManager = await import('../lib/team-manager.js');
247
+ const config = await teamManager.getTeam(teamName);
248
+ return {
249
+ leader: config?.leader || 'team-lead',
250
+ spawner: config?.spawner,
251
+ };
252
+ } catch {
253
+ return { leader: 'team-lead' };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Notify leader (and spawner) of wave or wish completion via protocol-router.
239
259
  * Best-effort — failures are logged but do not block the done flow.
240
260
  */
241
261
  async function notifyWaveCompletion(
@@ -246,17 +266,27 @@ async function notifyWaveCompletion(
246
266
  try {
247
267
  const protocolRouter = await import('../lib/protocol-router.js');
248
268
  const repoPath = process.cwd();
269
+ const { leader, spawner } = await resolveNotificationTargets();
270
+
249
271
  const message = wishComplete
250
272
  ? `WISH COMPLETE — all groups done: [${waveResult.waveGroups.join(', ')}]. Run \`genie team done\` to clean up.`
251
273
  : `${waveResult.waveName} complete. All groups done: [${waveResult.waveGroups.join(', ')}]. Run /review or advance to next wave.`;
252
- const result = await protocolRouter.sendMessage(repoPath, 'cli', 'team-lead', message);
274
+
275
+ // Notify leader
276
+ const result = await protocolRouter.sendMessage(repoPath, 'cli', leader, message);
253
277
  if (result && typeof result === 'object' && 'delivered' in result && !result.delivered) {
254
- console.warn(' ⚠️ Wave-complete notification may not have been delivered.');
278
+ console.warn(` ⚠️ Wave-complete notification to ${leader} may not have been delivered.`);
255
279
  } else {
256
- console.log(' Notified team-lead of wave completion.');
280
+ console.log(` Notified ${leader} of wave completion.`);
281
+ }
282
+
283
+ // Also notify spawner if different from leader
284
+ if (spawner && spawner !== leader && spawner !== 'cli') {
285
+ await protocolRouter.sendMessage(repoPath, 'cli', spawner, message).catch(() => {});
286
+ console.log(` Notified spawner (${spawner}) of wave completion.`);
257
287
  }
258
288
  } catch {
259
- console.warn(' ⚠️ Could not notify team-lead (messaging unavailable).');
289
+ console.warn(' ⚠️ Could not notify leader (messaging unavailable).');
260
290
  }
261
291
  }
262
292
 
@@ -245,7 +245,7 @@ export function registerTeamNamespace(program: Command): void {
245
245
  // Team Create Handler (extracted for cognitive complexity)
246
246
  // ============================================================================
247
247
 
248
- async function handleTeamCreate(
248
+ export async function handleTeamCreate(
249
249
  name: string,
250
250
  options: { repo: string; branch: string; wish?: string; tmuxSession?: string; spawn?: boolean },
251
251
  ): Promise<void> {
@@ -328,6 +328,11 @@ async function spawnLeaderWithWish(
328
328
  config.tmuxSessionName = tmuxSession;
329
329
  await teamManager.updateTeamConfig(config.name, config);
330
330
 
331
+ // Set leader name = wish slug, spawner = caller identity
332
+ config.leader = slug;
333
+ config.spawner = process.env.GENIE_AGENT_NAME || 'cli';
334
+ await teamManager.updateTeamConfig(config.name, config);
335
+
331
336
  // Locate WISH.md in source repo
332
337
  const sourceWishPath = join(resolvedRepo, '.genie', 'wishes', slug, 'WISH.md');
333
338
  if (!existsSync(sourceWishPath)) {
@@ -342,17 +347,19 @@ async function spawnLeaderWithWish(
342
347
  await copyFile(sourceWishPath, destWishPath);
343
348
  console.log(` Wish: copied ${slug}/WISH.md into worktree`);
344
349
 
345
- // Hire the standard team: team-lead + engineer + reviewer + qa + fix
346
- const standardTeam = ['team-lead', 'engineer', 'reviewer', 'qa', 'fix'];
350
+ // Hire the standard team: leader + engineer + reviewer + qa + fix
351
+ const leaderName = config.leader || 'team-lead';
352
+ const standardTeam = [leaderName, 'engineer', 'reviewer', 'qa', 'fix'];
347
353
  for (const role of standardTeam) {
348
354
  await teamManager.hireAgent(config.name, role);
349
355
  }
350
356
  console.log(` Team: hired ${standardTeam.join(', ')}`);
351
357
 
352
358
  // Spawn leader — AGENTS.md comes from the built-in resolver, prompt delivered as initialPrompt
353
- const members = standardTeam.filter((r) => r !== 'team-lead').join(', ');
354
- const kickoffPrompt = `Your team is "${config.name}". Repo: ${config.repo}. Branch: ${config.name}. Worktree: ${config.worktreePath}. Wish slug: ${slug}. Your team members are: ${members} (already hired — genie work will spawn them automatically). Read the wish at .genie/wishes/${slug}/WISH.md and execute the full lifecycle autonomously.`;
355
- await handleWorkerSpawn('team-lead', {
359
+ const members = standardTeam.filter((r) => r !== leaderName).join(', ');
360
+ const spawner = config.spawner || 'cli';
361
+ const kickoffPrompt = `Your team is "${config.name}". Repo: ${config.repo}. Branch: ${config.name}. Worktree: ${config.worktreePath}. Wish slug: ${slug}. Your team members are: ${members} (already hired — genie work will spawn them automatically). Report completion to: ${spawner} (via genie send --to ${spawner}). Read the wish at .genie/wishes/${slug}/WISH.md and execute the full lifecycle autonomously.`;
362
+ await handleWorkerSpawn(leaderName, {
356
363
  provider: 'claude',
357
364
  team: config.name,
358
365
  cwd: config.worktreePath,
@@ -362,9 +369,9 @@ async function spawnLeaderWithWish(
362
369
 
363
370
  // Deliver kickoff prompt via mailbox as backup (durable, queued to disk)
364
371
  const protocolRouter = await import('../lib/protocol-router.js');
365
- const result = await protocolRouter.sendMessage(config.worktreePath, 'cli', 'team-lead', kickoffPrompt);
372
+ const result = await protocolRouter.sendMessage(config.worktreePath, 'cli', leaderName, kickoffPrompt);
366
373
  if (!result.delivered) {
367
- console.warn(`⚠ Backup delivery to team-lead failed: ${result.reason ?? 'unknown'}`);
374
+ console.warn(`⚠ Backup delivery to ${leaderName} failed: ${result.reason ?? 'unknown'}`);
368
375
  }
369
376
  console.log(' Leader: spawned and working');
370
377
  }