@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.
@@ -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
  }
@@ -494,6 +526,14 @@ export function registerSendInboxCommands(program: Command): void {
494
526
  .option('--to <agent>', 'Recipient agent name (default: team-lead)', 'team-lead')
495
527
  .option('--from <sender>', 'Sender ID (auto-detected from context)')
496
528
  .option('--team <name>', 'Explicit team context for sender/recipient resolution')
529
+ .addHelpText(
530
+ 'after',
531
+ `
532
+ Examples:
533
+ genie send 'start task #3' --to engineer # Message a specific agent
534
+ genie send 'status update' --to team-lead # Report to team lead
535
+ genie send 'deploy ready' --team my-feature # Message within team context`,
536
+ )
497
537
  .action(async (body: string, options: { to: string; from?: string; team?: string }) => {
498
538
  try {
499
539
  await handleSend(body, options);
@@ -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
 
@@ -29,6 +29,14 @@ export function registerTeamNamespace(program: Command): void {
29
29
  .option('--tmux-session <name>', 'Tmux session to place team window in (default: derived from repo path)')
30
30
  .option('--session <name>', 'Alias for --tmux-session (deprecated)')
31
31
  .option('--no-spawn', 'Create team and copy wish without spawning the leader (useful for testing)')
32
+ .addHelpText(
33
+ 'after',
34
+ `
35
+ Examples:
36
+ genie team create my-feature --repo . # Create team in current repo
37
+ genie team create my-feature --repo . --wish my-feature-slug # Create team with a wish
38
+ genie team create hotfix --repo . --branch main # Create from main branch`,
39
+ )
32
40
  .action(
33
41
  async (
34
42
  name: string,
@@ -245,7 +253,7 @@ export function registerTeamNamespace(program: Command): void {
245
253
  // Team Create Handler (extracted for cognitive complexity)
246
254
  // ============================================================================
247
255
 
248
- async function handleTeamCreate(
256
+ export async function handleTeamCreate(
249
257
  name: string,
250
258
  options: { repo: string; branch: string; wish?: string; tmuxSession?: string; spawn?: boolean },
251
259
  ): Promise<void> {
@@ -328,6 +336,11 @@ async function spawnLeaderWithWish(
328
336
  config.tmuxSessionName = tmuxSession;
329
337
  await teamManager.updateTeamConfig(config.name, config);
330
338
 
339
+ // Set leader name = wish slug, spawner = caller identity
340
+ config.leader = slug;
341
+ config.spawner = process.env.GENIE_AGENT_NAME || 'cli';
342
+ await teamManager.updateTeamConfig(config.name, config);
343
+
331
344
  // Locate WISH.md in source repo
332
345
  const sourceWishPath = join(resolvedRepo, '.genie', 'wishes', slug, 'WISH.md');
333
346
  if (!existsSync(sourceWishPath)) {
@@ -342,17 +355,19 @@ async function spawnLeaderWithWish(
342
355
  await copyFile(sourceWishPath, destWishPath);
343
356
  console.log(` Wish: copied ${slug}/WISH.md into worktree`);
344
357
 
345
- // Hire the standard team: team-lead + engineer + reviewer + qa + fix
346
- const standardTeam = ['team-lead', 'engineer', 'reviewer', 'qa', 'fix'];
358
+ // Hire the standard team: leader + engineer + reviewer + qa + fix
359
+ const leaderName = config.leader || 'team-lead';
360
+ const standardTeam = [leaderName, 'engineer', 'reviewer', 'qa', 'fix'];
347
361
  for (const role of standardTeam) {
348
362
  await teamManager.hireAgent(config.name, role);
349
363
  }
350
364
  console.log(` Team: hired ${standardTeam.join(', ')}`);
351
365
 
352
366
  // 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', {
367
+ const members = standardTeam.filter((r) => r !== leaderName).join(', ');
368
+ const spawner = config.spawner || 'cli';
369
+ 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.`;
370
+ await handleWorkerSpawn(leaderName, {
356
371
  provider: 'claude',
357
372
  team: config.name,
358
373
  cwd: config.worktreePath,
@@ -362,9 +377,9 @@ async function spawnLeaderWithWish(
362
377
 
363
378
  // Deliver kickoff prompt via mailbox as backup (durable, queued to disk)
364
379
  const protocolRouter = await import('../lib/protocol-router.js');
365
- const result = await protocolRouter.sendMessage(config.worktreePath, 'cli', 'team-lead', kickoffPrompt);
380
+ const result = await protocolRouter.sendMessage(config.worktreePath, 'cli', leaderName, kickoffPrompt);
366
381
  if (!result.delivered) {
367
- console.warn(`⚠ Backup delivery to team-lead failed: ${result.reason ?? 'unknown'}`);
382
+ console.warn(`⚠ Backup delivery to ${leaderName} failed: ${result.reason ?? 'unknown'}`);
368
383
  }
369
384
  console.log(' Leader: spawned and working');
370
385
  }