groove-dev 0.21.6 → 0.22.0

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.
@@ -54,7 +54,8 @@ export class DiscordGateway extends BaseGateway {
54
54
  if (!msg.content.startsWith('/')) return;
55
55
 
56
56
  const userId = msg.author.id;
57
- const [command, ...args] = msg.content.slice(1).split(/\s+/);
57
+ const [rawCmd, ...args] = msg.content.slice(1).split(/\s+/);
58
+ const command = rawCmd.toLowerCase();
58
59
 
59
60
  // Auto-capture channelId
60
61
  if (!this.config.chatId) {
@@ -199,3 +199,98 @@ export function schedulesText(schedules) {
199
199
  return `${status} ${s.name} — ${s.cronDescription || s.cron}${running}`;
200
200
  }).join('\n');
201
201
  }
202
+
203
+ /**
204
+ * Format journalist brief for chat.
205
+ */
206
+ export function briefText(status, lastSynthesis) {
207
+ const lines = ['\ud83d\udcf0 Project Brief'];
208
+
209
+ if (status) {
210
+ const state = status.synthesizing ? 'synthesizing...' : status.running ? 'active' : 'idle';
211
+ lines.push(`Journalist: ${state} | ${status.cycleCount || 0} cycles`);
212
+ }
213
+
214
+ if (lastSynthesis) {
215
+ if (lastSynthesis.summary) {
216
+ lines.push('');
217
+ lines.push(lastSynthesis.summary);
218
+ }
219
+ if (lastSynthesis.projectMap) {
220
+ const map = truncate(lastSynthesis.projectMap, 1500);
221
+ lines.push('');
222
+ lines.push(map);
223
+ }
224
+ } else {
225
+ lines.push('No synthesis available yet. Journalist runs after agents produce output.');
226
+ }
227
+
228
+ return lines.join('\n');
229
+ }
230
+
231
+ /**
232
+ * Format token usage summary for chat.
233
+ */
234
+ export function tokensText(summary) {
235
+ if (!summary) return 'No token data available.';
236
+
237
+ const lines = [
238
+ '\ud83d\udcca Token Usage',
239
+ `Agents: ${summary.agentCount} | Turns: ${summary.totalTurns} | Session: ${formatDuration(summary.sessionDurationMs)}`,
240
+ `Tokens: ${formatTokens(summary.totalTokens)} (${formatTokens(summary.totalInputTokens)} in / ${formatTokens(summary.totalOutputTokens)} out)`,
241
+ `Cost: ${formatCost(summary.totalCostUsd)}`,
242
+ ];
243
+
244
+ if (summary.cacheHitRate > 0) {
245
+ lines.push(`Cache: ${Math.round(summary.cacheHitRate * 100)}% hit rate (${formatTokens(summary.cacheReadTokens)} reads)`);
246
+ }
247
+
248
+ if (summary.savings && summary.savings.total > 0) {
249
+ lines.push('');
250
+ lines.push(`\ud83d\udcb0 Savings: ${formatTokens(summary.savings.total)} tokens (${summary.savings.percentage}%)`);
251
+ if (summary.savings.fromRotation > 0) lines.push(` Rotation: ${formatTokens(summary.savings.fromRotation)}`);
252
+ if (summary.savings.fromConflictPrevention > 0) lines.push(` Conflict prevention: ${formatTokens(summary.savings.fromConflictPrevention)}`);
253
+ if (summary.savings.fromColdStartSkip > 0) lines.push(` Cold-start skip: ${formatTokens(summary.savings.fromColdStartSkip)}`);
254
+ }
255
+
256
+ return lines.join('\n');
257
+ }
258
+
259
+ /**
260
+ * Format agent log output for chat.
261
+ */
262
+ export function logText(agentName, lines) {
263
+ if (!lines || lines.length === 0) return `No log output for ${agentName}.`;
264
+ return `\ud83d\udccb Log: ${agentName} (last ${lines.length} lines)\n\n${lines.join('\n')}`;
265
+ }
266
+
267
+ /**
268
+ * Format a recommended team plan for chat approval.
269
+ */
270
+ export function planText(agents, description) {
271
+ if (!agents || agents.length === 0) return 'Empty plan.';
272
+
273
+ const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
274
+ const phase2 = agents.filter((a) => a.phase === 2);
275
+
276
+ const lines = [
277
+ `\ud83d\udccb Plan: ${truncate(description || 'New project', 200)}`,
278
+ '',
279
+ `Team: ${agents.length} agents (${phase1.length} builders${phase2.length > 0 ? `, ${phase2.length} QC` : ''})`,
280
+ '',
281
+ ];
282
+
283
+ for (const a of phase1) {
284
+ const scope = a.scope?.length > 0 ? ` [${a.scope.join(', ')}]` : '';
285
+ const model = a.model && a.model !== 'auto' ? ` (${a.model})` : '';
286
+ lines.push(` Phase 1: ${a.role}${model}${scope}`);
287
+ if (a.prompt) lines.push(` ${truncate(a.prompt, 120)}`);
288
+ }
289
+
290
+ for (const a of phase2) {
291
+ lines.push(` Phase 2: ${a.role} (auto-spawns after Phase 1)`);
292
+ if (a.prompt) lines.push(` ${truncate(a.prompt, 120)}`);
293
+ }
294
+
295
+ return lines.join('\n');
296
+ }
@@ -4,7 +4,8 @@
4
4
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
5
5
  import { resolve } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
- import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, truncate, formatTokens } from './formatter.js';
7
+ import { validateAgentConfig } from '../validate.js';
8
+ import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, briefText, tokensText, logText, planText, truncate, formatTokens } from './formatter.js';
8
9
 
9
10
  const GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
10
11
 
@@ -49,10 +50,13 @@ const NEVER_FORWARD = new Set(['state', 'agent:output', 'file:changed', 'ollama:
49
50
  const COALESCE_WINDOW = 3000; // 3 seconds
50
51
  const NEVER_COALESCE = new Set(['approval:request']); // Always send immediately
51
52
 
53
+ // Team lead role priority — first match wins
54
+ const LEAD_PRIORITY = ['qc', 'fullstack', 'lead', 'senior', 'pm', 'planner'];
55
+
52
56
  // Commands that require 'full' permission (mutate state)
53
- const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate']);
57
+ const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate', 'instruct', 'plan']);
54
58
  // Commands allowed in 'read-only' mode
55
- const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help']);
59
+ const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help', 'log', 'query', 'brief', 'tokens']);
56
60
 
57
61
  export class GatewayManager {
58
62
  constructor(daemon) {
@@ -61,6 +65,7 @@ export class GatewayManager {
61
65
  mkdirSync(this.gatewaysDir, { recursive: true });
62
66
  this.gateways = new Map(); // id -> gateway instance
63
67
  this._coalesceTimers = new Map(); // eventType -> { timer, events[] }
68
+ this._pendingPlans = new Map(); // agentId -> { gatewayId, timestamp }
64
69
  this._originalBroadcast = null;
65
70
  this._load();
66
71
  }
@@ -335,6 +340,18 @@ export class GatewayManager {
335
340
  return this._cmdTeams();
336
341
  case 'schedules':
337
342
  return this._cmdSchedules();
343
+ case 'instruct':
344
+ return await this._cmdInstruct(args);
345
+ case 'query':
346
+ return await this._cmdQuery(args, gateway);
347
+ case 'log':
348
+ return this._cmdLog(args);
349
+ case 'plan':
350
+ return await this._cmdPlan(args, gateway);
351
+ case 'brief':
352
+ return this._cmdBrief();
353
+ case 'tokens':
354
+ return this._cmdTokens();
338
355
  case 'help':
339
356
  return this._cmdHelp();
340
357
  default:
@@ -383,30 +400,159 @@ export class GatewayManager {
383
400
  return { text: `\u2705 Spawned ${agent.name || agent.id} (${role})` };
384
401
  }
385
402
 
403
+ // -------------------------------------------------------------------
404
+ // Team-First Resolver
405
+ // -------------------------------------------------------------------
406
+
407
+ /**
408
+ * Resolve identifier to a team or agent.
409
+ * Priority: team name → team prefix → exact agent ID → agent name → agent prefix → role
410
+ */
411
+ _resolveTarget(identifier) {
412
+ const agents = this.daemon.registry.getAll();
413
+ const teams = this.daemon.teams.list();
414
+ const lower = identifier.toLowerCase();
415
+
416
+ // 1. Team name (case-insensitive) — primary target
417
+ const team = teams.find((t) => t.name.toLowerCase() === lower);
418
+ if (team) {
419
+ return { type: 'team', team, agents: agents.filter((a) => a.teamId === team.id) };
420
+ }
421
+
422
+ // 2. Team name prefix
423
+ const teamPrefix = teams.filter((t) => t.name.toLowerCase().startsWith(lower));
424
+ if (teamPrefix.length === 1) {
425
+ return { type: 'team', team: teamPrefix[0], agents: agents.filter((a) => a.teamId === teamPrefix[0].id) };
426
+ }
427
+
428
+ // 3. Exact agent ID
429
+ const byId = agents.find((a) => a.id === identifier);
430
+ if (byId) return { type: 'agent', agent: byId };
431
+
432
+ // 4. Exact agent name (case-insensitive)
433
+ const byName = agents.find((a) => (a.name || '').toLowerCase() === lower);
434
+ if (byName) return { type: 'agent', agent: byName };
435
+
436
+ // 5. Agent name/ID prefix
437
+ const byPrefix = agents.filter((a) =>
438
+ a.id.toLowerCase().startsWith(lower) ||
439
+ (a.name || '').toLowerCase().startsWith(lower)
440
+ );
441
+ if (byPrefix.length === 1) return { type: 'agent', agent: byPrefix[0] };
442
+
443
+ // 6. Role match (only if one agent has that role)
444
+ const byRole = agents.filter((a) => (a.role || '').toLowerCase() === lower);
445
+ if (byRole.length === 1) return { type: 'agent', agent: byRole[0] };
446
+
447
+ // Ambiguous
448
+ if (byPrefix.length > 1 || teamPrefix.length > 1) {
449
+ const names = [
450
+ ...teamPrefix.map((t) => `team:${t.name}`),
451
+ ...byPrefix.map((a) => a.name || a.id),
452
+ ];
453
+ return { type: 'ambiguous', matches: names };
454
+ }
455
+
456
+ return null;
457
+ }
458
+
459
+ /**
460
+ * Resolve to agent only (for kill, rotate — not team-aware).
461
+ */
462
+ _resolveAgent(identifier) {
463
+ const result = this._resolveTarget(identifier);
464
+ if (!result) return { error: `Not found: ${identifier}. Try /agents or /teams.` };
465
+ if (result.type === 'ambiguous') return { error: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
466
+ if (result.type === 'team') return { error: `"${result.team.name}" is a team (${result.agents.length} agents). Use a specific agent name.` };
467
+ return { agent: result.agent };
468
+ }
469
+
470
+ /**
471
+ * Find the team lead — the senior agent who routes messages.
472
+ * Priority: QC > fullstack > lead > senior > PM > planner > first running
473
+ */
474
+ _findTeamLead(agents) {
475
+ const running = agents.filter((a) => a.status === 'running' || a.status === 'starting');
476
+ if (running.length === 0) return null;
477
+ for (const keyword of LEAD_PRIORITY) {
478
+ const match = running.find((a) => (a.role || '').toLowerCase().includes(keyword));
479
+ if (match) return match;
480
+ }
481
+ return running[0];
482
+ }
483
+
484
+ // -------------------------------------------------------------------
485
+ // Helpers
486
+ // -------------------------------------------------------------------
487
+
488
+ _readAgentLog(agent, lineCount = 20) {
489
+ const logDir = resolve(this.daemon.grooveDir, 'logs');
490
+ const name = (agent.name || agent.id).replace(/[^a-zA-Z0-9_-]/g, '_');
491
+ const logPath = resolve(logDir, `${name}.log`);
492
+ if (!existsSync(logPath)) return null;
493
+ const content = readFileSync(logPath, 'utf8');
494
+ const allLines = content.split('\n').filter(Boolean);
495
+ return allLines.slice(-lineCount);
496
+ }
497
+
498
+ /**
499
+ * Find recommended-team.json — same logic as api.js findRecommendedTeam()
500
+ */
501
+ _findRecommendedTeam() {
502
+ const agents = this.daemon.registry.getAll();
503
+ for (const agent of agents) {
504
+ if (agent.workingDir) {
505
+ const p = resolve(agent.workingDir, '.groove', 'recommended-team.json');
506
+ if (existsSync(p)) return p;
507
+ }
508
+ }
509
+ const p = resolve(this.daemon.grooveDir, 'recommended-team.json');
510
+ if (existsSync(p)) return p;
511
+ return null;
512
+ }
513
+
514
+ // -------------------------------------------------------------------
515
+ // Command Implementations
516
+ // -------------------------------------------------------------------
517
+
386
518
  _cmdKill(args) {
387
- if (args.length === 0) return { text: 'Usage: /kill <agent-id>' };
388
- const id = args[0];
389
- this.daemon.processes.kill(id);
390
- return { text: `\u26d4 Killed agent ${id}` };
519
+ if (args.length === 0) return { text: 'Usage: /kill <agent-name>' };
520
+ const { agent, error } = this._resolveAgent(args[0]);
521
+ if (error) return { text: error };
522
+ this.daemon.processes.kill(agent.id);
523
+ return { text: `\u26d4 Killed ${agent.name || agent.id}` };
391
524
  }
392
525
 
393
526
  _cmdApprove(args) {
394
527
  if (args.length === 0) return { text: 'Usage: /approve <approval-id>' };
528
+ // Check if approving a pending plan
529
+ const planId = args[0];
530
+ if (this._pendingPlans.has(planId)) {
531
+ return this._launchPlan(planId);
532
+ }
395
533
  this.daemon.supervisor.approve(args[0]);
396
534
  return { text: `\u2705 Approved: ${args[0]}` };
397
535
  }
398
536
 
399
537
  _cmdReject(args) {
400
538
  if (args.length === 0) return { text: 'Usage: /reject <approval-id> [reason]' };
539
+ // Check if rejecting a pending plan
540
+ const planId = args[0];
541
+ if (this._pendingPlans.has(planId)) {
542
+ this._pendingPlans.delete(planId);
543
+ return { text: `\u274c Plan discarded.` };
544
+ }
401
545
  const reason = args.slice(1).join(' ') || undefined;
402
546
  this.daemon.supervisor.reject(args[0], reason);
403
547
  return { text: `\u274c Rejected: ${args[0]}${reason ? ` — ${reason}` : ''}` };
404
548
  }
405
549
 
406
550
  async _cmdRotate(args) {
407
- if (args.length === 0) return { text: 'Usage: /rotate <agent-id>' };
408
- await this.daemon.rotator.rotate(args[0]);
409
- return { text: `\u{1f504} Rotating agent ${args[0]}...` };
551
+ if (args.length === 0) return { text: 'Usage: /rotate <agent-name>' };
552
+ const { agent, error } = this._resolveAgent(args[0]);
553
+ if (error) return { text: error };
554
+ await this.daemon.rotator.rotate(agent.id);
555
+ return { text: `\u{1f504} Rotating ${agent.name || agent.id}...` };
410
556
  }
411
557
 
412
558
  _cmdTeams() {
@@ -419,18 +565,330 @@ export class GatewayManager {
419
565
  return { text: schedulesText(schedules) };
420
566
  }
421
567
 
568
+ /**
569
+ * Instruct a team or agent. Team-first: routes to team lead.
570
+ */
571
+ async _cmdInstruct(args) {
572
+ if (args.length < 2) return { text: 'Usage: /instruct <team> <message>' };
573
+ const target = args[0];
574
+ const message = args.slice(1).join(' ');
575
+
576
+ const result = this._resolveTarget(target);
577
+ if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
578
+ if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
579
+
580
+ // Team target — route to team lead
581
+ if (result.type === 'team') {
582
+ const lead = this._findTeamLead(result.agents);
583
+ if (!lead) return { text: `No running agents in team ${result.team.name}. Use /plan to start a new project.` };
584
+
585
+ const resumed = !!lead.sessionId;
586
+ const newAgent = resumed
587
+ ? await this.daemon.processes.resume(lead.id, message)
588
+ : await this.daemon.rotator.rotate(lead.id, { additionalPrompt: message });
589
+
590
+ this.daemon.audit.log('agent.instruct', { id: lead.id, newId: newAgent.id, resumed, source: 'gateway', team: result.team.name });
591
+ return { text: `\u2705 Sent to ${lead.name} (${result.team.name} lead): ${truncate(message, 100)}` };
592
+ }
593
+
594
+ // Direct agent target (fallback)
595
+ const agent = result.agent;
596
+ const resumed = !!agent.sessionId;
597
+ const newAgent = resumed
598
+ ? await this.daemon.processes.resume(agent.id, message)
599
+ : await this.daemon.rotator.rotate(agent.id, { additionalPrompt: message });
600
+
601
+ this.daemon.audit.log('agent.instruct', { id: agent.id, newId: newAgent.id, resumed, source: 'gateway' });
602
+ return { text: `\u2705 Instructed ${agent.name || agent.id}: ${truncate(message, 100)}` };
603
+ }
604
+
605
+ /**
606
+ * Query a team or agent — journalist synthesis, no disruption.
607
+ */
608
+ async _cmdQuery(args, gateway) {
609
+ if (args.length < 2) return { text: 'Usage: /query <team> <question>' };
610
+ const target = args[0];
611
+ const question = args.slice(1).join(' ');
612
+
613
+ const result = this._resolveTarget(target);
614
+ if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
615
+ if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
616
+
617
+ if (result.type === 'team') {
618
+ const active = result.agents.filter((a) => a.status === 'running' || a.status === 'completed');
619
+ if (active.length === 0) return { text: `No active agents in team ${result.team.name}.` };
620
+
621
+ await gateway.send(`\u{1f914} Querying team ${result.team.name} (${active.length} agents)...`).catch(() => {});
622
+
623
+ const agentContexts = active.map((a) => {
624
+ const activity = this.daemon.classifier?.agentWindows?.[a.id] || [];
625
+ const recent = activity.slice(-10).map((e) => e.data || e.text || '').join('\n');
626
+ return [
627
+ `Agent "${a.name}" (${a.role}, ${a.status})`,
628
+ `Scope: ${(a.scope || []).join(', ') || 'unrestricted'}`,
629
+ a.prompt ? `Task: ${a.prompt}` : '',
630
+ recent ? `Recent activity:\n${recent}` : '',
631
+ ].filter(Boolean).join('\n');
632
+ }).join('\n\n');
633
+
634
+ const prompt = [
635
+ `You are answering a question about team "${result.team.name}" with ${active.length} agents.`,
636
+ `\nTeam members:\n${agentContexts}`,
637
+ `\nUser question: ${question}`,
638
+ '\nSynthesize a concise answer based on the team\'s collective context.',
639
+ ].join('\n');
640
+
641
+ const response = await this.daemon.journalist.callHeadless(prompt);
642
+ return { text: `\ud83d\udcac Team ${result.team.name}:\n${truncate(response, 3000)}` };
643
+ }
644
+
645
+ // Single agent query
646
+ const agent = result.agent;
647
+ await gateway.send(`\u{1f914} Querying ${agent.name || agent.id}...`).catch(() => {});
648
+
649
+ const activity = this.daemon.classifier?.agentWindows?.[agent.id] || [];
650
+ const recentActivity = activity.slice(-20).map((e) => e.data || e.text || '').join('\n');
651
+
652
+ const prompt = [
653
+ `You are answering a question about agent "${agent.name}" (role: ${agent.role}).`,
654
+ `File scope: ${(agent.scope || []).join(', ') || 'unrestricted'}`,
655
+ `Provider: ${agent.provider}, Tokens used: ${agent.tokensUsed || 0}`,
656
+ agent.prompt ? `Original task: ${agent.prompt}` : '',
657
+ recentActivity ? `\nRecent activity:\n${recentActivity}` : '',
658
+ `\nUser question: ${question}`,
659
+ '\nAnswer concisely based on the agent context above.',
660
+ ].filter(Boolean).join('\n');
661
+
662
+ const response = await this.daemon.journalist.callHeadless(prompt);
663
+ return { text: `\ud83d\udcac ${agent.name || agent.id}:\n${truncate(response, 3000)}` };
664
+ }
665
+
666
+ /**
667
+ * View logs for a team or agent.
668
+ */
669
+ _cmdLog(args) {
670
+ if (args.length === 0) return { text: 'Usage: /log <team> [lines]' };
671
+ const target = args[0];
672
+ const lineCount = Math.min(parseInt(args[1], 10) || 20, 50);
673
+
674
+ const result = this._resolveTarget(target);
675
+ if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
676
+ if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
677
+
678
+ if (result.type === 'team') {
679
+ const sections = [];
680
+ const perAgent = Math.max(Math.floor(lineCount / Math.max(result.agents.length, 1)), 5);
681
+ for (const agent of result.agents) {
682
+ const lines = this._readAgentLog(agent, perAgent);
683
+ if (lines && lines.length > 0) {
684
+ sections.push(`\u2014 ${agent.name || agent.id} (${agent.role}) \u2014\n${lines.join('\n')}`);
685
+ }
686
+ }
687
+ if (sections.length === 0) return { text: `No logs for team ${result.team.name}.` };
688
+ return { text: `\ud83d\udccb Team ${result.team.name} logs:\n\n${sections.join('\n\n')}` };
689
+ }
690
+
691
+ const agent = result.agent;
692
+ const lines = this._readAgentLog(agent, lineCount);
693
+ if (!lines) return { text: `No log file found for ${agent.name || agent.id}.` };
694
+ return { text: logText(agent.name || agent.id, lines) };
695
+ }
696
+
697
+ // -------------------------------------------------------------------
698
+ // Plan → Approve → Build Flow
699
+ // -------------------------------------------------------------------
700
+
701
+ /**
702
+ * Start a new project — spawns a planner, tracks it for gateway feedback.
703
+ */
704
+ async _cmdPlan(args, gateway) {
705
+ if (args.length === 0) return { text: 'Usage: /plan <description of what to build>' };
706
+ const description = args.join(' ');
707
+
708
+ await gateway.send(`\u{1f4cb} Planning: ${truncate(description, 200)}\nSpawning planner agent...`).catch(() => {});
709
+
710
+ const agent = await this.daemon.processes.spawn({
711
+ role: 'planner',
712
+ prompt: description,
713
+ });
714
+
715
+ // Track this planner so we send results back to the right gateway
716
+ this._pendingPlans.set(agent.id, {
717
+ gatewayId: gateway.config.id,
718
+ description,
719
+ timestamp: Date.now(),
720
+ });
721
+
722
+ this.daemon.audit.log('gateway.plan', { agentId: agent.id, source: 'gateway', gatewayId: gateway.config.id });
723
+ return { text: `\u{1f9e0} Planner ${agent.name} is analyzing the codebase and building a team plan.\nYou'll get the plan here for approval when it's ready.` };
724
+ }
725
+
726
+ /**
727
+ * Called from _routeEvent when a planner agent completes.
728
+ * Reads recommended-team.json and sends plan to chat for approval.
729
+ */
730
+ _handlePlannerComplete(agentId) {
731
+ const plan = this._pendingPlans.get(agentId);
732
+ if (!plan) return; // Not a gateway-initiated planner
733
+
734
+ const gw = this.gateways.get(plan.gatewayId);
735
+ if (!gw || !gw.connected) {
736
+ this._pendingPlans.delete(agentId);
737
+ return;
738
+ }
739
+
740
+ const teamPath = this._findRecommendedTeam();
741
+ if (!teamPath) {
742
+ gw.send('\u274c Planner finished but no team plan was generated. Try again with a more specific description.').catch(() => {});
743
+ this._pendingPlans.delete(agentId);
744
+ return;
745
+ }
746
+
747
+ try {
748
+ const agents = JSON.parse(readFileSync(teamPath, 'utf8'));
749
+ if (!Array.isArray(agents) || agents.length === 0) {
750
+ gw.send('\u274c Planner generated an empty team plan.').catch(() => {});
751
+ this._pendingPlans.delete(agentId);
752
+ return;
753
+ }
754
+
755
+ // Store the plan data for launch
756
+ plan.agents = agents;
757
+ plan.teamPath = teamPath;
758
+
759
+ const summary = planText(agents, plan.description);
760
+ const approveMsg = `\n\nApprove this plan?\n/approve ${agentId} — launch the team\n/reject ${agentId} — discard\n/plan <edits> — replan with changes`;
761
+
762
+ gw.send(summary + approveMsg, { planId: agentId }).catch(() => {});
763
+ } catch (err) {
764
+ gw.send(`\u274c Error reading plan: ${err.message}`).catch(() => {});
765
+ this._pendingPlans.delete(agentId);
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Launch a team from an approved plan.
771
+ */
772
+ _launchPlan(planId) {
773
+ const plan = this._pendingPlans.get(planId);
774
+ if (!plan || !plan.agents) return { text: 'Plan not found or already launched.' };
775
+
776
+ const agents = plan.agents;
777
+ const defaultDir = this.daemon.config?.defaultWorkingDir || undefined;
778
+
779
+ // Separate phases
780
+ const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
781
+ let phase2 = agents.filter((a) => a.phase === 2);
782
+
783
+ // Auto-add QC if planner forgot
784
+ if (phase2.length === 0 && phase1.length >= 2) {
785
+ phase2 = [{
786
+ role: 'fullstack', phase: 2, scope: [],
787
+ prompt: 'QC Senior Dev: All builder agents have completed. Audit their changes for correctness, fix any issues, run tests, build the project, commit all changes, and launch. Output the localhost URL.',
788
+ }];
789
+ }
790
+
791
+ // Spawn phase 1
792
+ const spawned = [];
793
+ const phase1Ids = [];
794
+ const spawnAll = async () => {
795
+ for (const config of phase1) {
796
+ try {
797
+ const validated = validateAgentConfig({
798
+ role: config.role,
799
+ scope: config.scope || [],
800
+ prompt: config.prompt || '',
801
+ provider: config.provider || 'claude-code',
802
+ model: config.model || 'auto',
803
+ permission: config.permission || 'auto',
804
+ workingDir: config.workingDir || defaultDir,
805
+ name: config.name || undefined,
806
+ });
807
+ const agent = await this.daemon.processes.spawn(validated);
808
+ spawned.push(agent);
809
+ phase1Ids.push(agent.id);
810
+ } catch (err) {
811
+ console.log(`[Groove:Gateway] Failed to spawn ${config.role}: ${err.message}`);
812
+ }
813
+ }
814
+
815
+ // Register phase 2 for auto-spawn
816
+ if (phase2.length > 0 && phase1Ids.length > 0) {
817
+ this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
818
+ this.daemon._pendingPhase2.push({
819
+ waitFor: phase1Ids,
820
+ agents: phase2.map((c) => ({
821
+ role: c.role, scope: c.scope || [], prompt: c.prompt || '',
822
+ provider: c.provider || 'claude-code', model: c.model || 'auto',
823
+ permission: c.permission || 'auto',
824
+ workingDir: c.workingDir || defaultDir,
825
+ name: c.name || undefined,
826
+ })),
827
+ });
828
+ }
829
+
830
+ this.daemon.audit.log('team.launch', {
831
+ phase1: spawned.length, phase2Pending: phase2.length,
832
+ agents: spawned.map((a) => a.role), source: 'gateway',
833
+ });
834
+
835
+ // Notify via gateway
836
+ const gw = this.gateways.get(plan.gatewayId);
837
+ if (gw?.connected) {
838
+ const names = spawned.map((a) => `${a.name} (${a.role})`).join(', ');
839
+ const msg = `\u{1f680} Team launched! ${spawned.length} agents building${phase2.length > 0 ? `, ${phase2.length} QC agents queued` : ''}.\n${names}`;
840
+ gw.send(msg).catch(() => {});
841
+ }
842
+ };
843
+
844
+ // Fire and forget — spawn is async but we return immediately
845
+ spawnAll().catch((err) => console.log(`[Groove:Gateway] Launch error: ${err.message}`));
846
+ this._pendingPlans.delete(planId);
847
+
848
+ return { text: `\u2705 Launching team (${phase1.length} agents)...` };
849
+ }
850
+
851
+ // -------------------------------------------------------------------
852
+ // Intelligence + Help
853
+ // -------------------------------------------------------------------
854
+
855
+ _cmdBrief() {
856
+ const status = this.daemon.journalist.getStatus();
857
+ const lastSynthesis = this.daemon.journalist.getLastSynthesis();
858
+ return { text: briefText(status, lastSynthesis) };
859
+ }
860
+
861
+ _cmdTokens() {
862
+ const summary = this.daemon.tokens.getSummary();
863
+ return { text: tokensText(summary) };
864
+ }
865
+
422
866
  _cmdHelp() {
423
867
  return {
424
868
  text: [
425
869
  'Groove Commands:',
870
+ '',
871
+ 'Talk to Teams:',
872
+ '/instruct <team> <message> — send to team lead',
873
+ '/query <team> <question> — ask without disrupting work',
874
+ '/log <team> [lines] — view team logs',
875
+ '/plan <description> — plan + build a new project',
876
+ '',
877
+ 'Fleet:',
426
878
  '/status — daemon status + active agents',
427
879
  '/agents — list all agents',
428
- '/spawn <role> [--name X] [--prompt "Y"] — spawn agent',
429
- '/kill <id> — kill agent',
430
- '/approve <id> — approve pending request',
431
- '/reject <id> [reason] — reject request',
432
- '/rotate <id> — rotate agent context',
433
880
  '/teams — list teams',
881
+ '/spawn <role> [--name X] [--prompt "Y"] — manual spawn',
882
+ '/kill <name> — kill agent',
883
+ '/rotate <name> — rotate agent context',
884
+ '',
885
+ 'Intelligence:',
886
+ '/brief — journalist project summary',
887
+ '/tokens — token usage + savings',
888
+ '',
889
+ 'Workflow:',
890
+ '/approve <id> — approve plan or request',
891
+ '/reject <id> [reason] — reject plan or request',
434
892
  '/schedules — list schedules',
435
893
  '/help — this message',
436
894
  ].join('\n'),
@@ -448,6 +906,11 @@ export class GatewayManager {
448
906
  if (!message || !message.type) return;
449
907
  if (NEVER_FORWARD.has(message.type)) return;
450
908
 
909
+ // Intercept planner completions for the plan→approve→build flow
910
+ if (message.type === 'agent:exit' && message.status === 'completed' && message.agentId) {
911
+ this._handlePlannerComplete(message.agentId);
912
+ }
913
+
451
914
  for (const gw of this.gateways.values()) {
452
915
  if (!gw.connected || !gw.config.enabled) continue;
453
916
  if (!this._shouldNotify(gw, message)) continue;