diff --git a/src/cli.ts b/src/cli.ts index 5cf197a9..0b7baf5d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -283,7 +283,7 @@ program.command('create ', { hidden: true }).description('[renamed]').acti // Run command - execute squads or individual agents program .command('run [target]') - .description('Run a squad, agent, or autopilot (no target = autopilot mode)') + .description('Run a squad or agent (no target = show available agents)') .option('-v, --verbose', 'Verbose output') .option('-d, --dry-run', 'Show what would be run without executing') .option('-a, --agent ', 'Run specific agent within squad') @@ -326,7 +326,8 @@ Examples: $ squads run engineering -w Run in background but tail logs $ squads run research --provider=google Use Gemini CLI instead of Claude $ squads run engineering/issue-solver --cloud Dispatch to cloud worker - $ squads run Autopilot mode (watch → decide → dispatch → learn) + $ squads run Show available agents grouped by squad + $ squads run --once Run one autopilot cycle then exit $ squads run --once --dry-run Preview one autopilot cycle $ squads run -i 15 --budget 50 Autopilot: 15min cycles, $50/day cap `) diff --git a/src/commands/run.ts b/src/commands/run.ts index 6dea1e43..632aefa7 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -250,9 +250,14 @@ export async function runCommand( return; } - // MODE 1: Autopilot — no target means run all squads continuously + // MODE 1: No target — show agent discovery or run autopilot if flags indicate intent if (!target) { - await runAutopilot(squadsDir, options); + const autopilotIntent = options.once || options.interval || options.budget || options.phased; + if (autopilotIntent) { + await runAutopilot(squadsDir, options); + } else { + await showAgentDiscovery(squadsDir); + } return; } @@ -329,6 +334,47 @@ export async function runCommand( } } +async function showAgentDiscovery(squadsDir: string): Promise { + const squads = listSquads(squadsDir); + + writeLine(); + writeLine(` ${gradient('squads')} ${colors.dim}run${RESET}`); + writeLine(); + + if (squads.length === 0) { + writeLine(` ${colors.dim}No squads found. Run \`squads init\` to create your first squad.${RESET}`); + writeLine(); + return; + } + + writeLine(` ${bold}Available agents${RESET} ${colors.dim}${squads.length} squad${squads.length === 1 ? '' : 's'}${RESET}`); + writeLine(); + + for (const squadName of squads) { + const squad = loadSquad(squadName); + if (!squad) continue; + + const agentCount = squad.agents.length; + writeLine(` ${colors.cyan}${bold}${squadName}${RESET} ${colors.dim}${agentCount} agent${agentCount === 1 ? '' : 's'}${RESET}`); + if (squad.mission) { + writeLine(` ${colors.dim}${squad.mission}${RESET}`); + } + + for (const agent of squad.agents) { + const role = agent.role ? ` ${colors.dim}${agent.role}${RESET}` : ''; + writeLine(` ${icons.empty} ${colors.cyan}${agent.name}${RESET}${role}`); + } + writeLine(); + } + + writeLine(` ${colors.dim}Run:${RESET}`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}${RESET} run squad conversation`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}/${RESET} run specific agent`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}${RESET} --task ${colors.cyan}"..."${RESET} run with a directive`); + writeLine(` ${colors.dim}$${RESET} squads run --once one autopilot cycle`); + writeLine(); +} + async function runSquad( squad: ReturnType, squadsDir: string, diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts index c2e6e9a6..79b16453 100644 --- a/src/lib/conversation.ts +++ b/src/lib/conversation.ts @@ -31,7 +31,7 @@ export function classifyAgent(agentName: string, roleDescription?: string): Agen const lower = roleDescription.toLowerCase(); if (lower.includes('orchestrat') || lower.includes('triage') || lower.includes('coordinat') || lower.includes('lead')) return 'lead'; if (lower.includes('scan') || lower.includes('monitor') || lower.includes('detect')) return 'scanner'; - if (lower.includes('verif') || lower.includes('critic')) return 'verifier'; + if (lower.includes('verif') || lower.includes('critic') || lower.includes('review') || lower.includes('check')) return 'verifier'; return 'worker'; } @@ -112,8 +112,8 @@ export function serializeTranscript(transcript: Transcript): string { } } - // If short conversation (≤6 turns or single cycle), return everything - if (turns.length <= 6 || cycleBoundaries.length <= 1) { + // If short conversation (≤5 turns or single cycle), return everything + if (turns.length <= 5 || cycleBoundaries.length <= 1) { return formatTurns(turns, transcript.turns.length); } @@ -128,6 +128,16 @@ export function serializeTranscript(transcript: Transcript): string { // Assemble const lines = ['## Conversation So Far\n']; + // Always preserve the initial brief (first turn) + const firstTurn = turns[0]; + lines.push(`**${firstTurn.agent} (${firstTurn.role}):**`); + lines.push(firstTurn.content); + lines.push(''); + + if (oldTurns.length > 1) { + lines.push(`*(${oldTurns.length - 1} earlier turns compacted)*\n`); + } + if (digest) { lines.push('### Prior Cycles (digest)'); lines.push(digest); diff --git a/src/lib/workflow.ts b/src/lib/workflow.ts index 340465d1..e2e4e519 100644 --- a/src/lib/workflow.ts +++ b/src/lib/workflow.ts @@ -304,7 +304,7 @@ ${squadContext}`; addTurn(transcript, lead.name, 'lead', planOutput, estimateTurnCost(options.model || 'sonnet')); // Check if lead declared done immediately (nothing to do) - const conv = detectConvergence(transcript, 100, costCeiling); + const conv = detectConvergence(transcript, options.maxTurns || 100, costCeiling); if (conv.converged) { return { transcript, turnCount: transcript.turns.length, totalCost: transcript.totalCost, converged: true, reason: conv.reason }; } diff --git a/test/commands/run.test.ts b/test/commands/run.test.ts index b9ed4fb4..e10ad9e7 100644 --- a/test/commands/run.test.ts +++ b/test/commands/run.test.ts @@ -175,13 +175,14 @@ vi.mock('../../src/lib/run-context.js', () => ({ // ── Imports (after mocks) ────────────────────────────────────────────────── import { runCommand, runSquadCommand } from '../../src/commands/run.js'; -import { findSquadsDir, loadSquad, listAgents, findSimilarSquads } from '../../src/lib/squad-parser.js'; +import { findSquadsDir, loadSquad, listAgents, listSquads, findSimilarSquads } from '../../src/lib/squad-parser.js'; import { writeLine } from '../../src/lib/terminal.js'; import { isProviderCLIAvailable } from '../../src/lib/llm-clis.js'; const mockFindSquadsDir = vi.mocked(findSquadsDir); const mockLoadSquad = vi.mocked(loadSquad); const mockListAgents = vi.mocked(listAgents); +const mockListSquads = vi.mocked(listSquads); const mockFindSimilarSquads = vi.mocked(findSimilarSquads); const mockWriteLine = vi.mocked(writeLine); const mockIsProviderCLIAvailable = vi.mocked(isProviderCLIAvailable); @@ -450,6 +451,59 @@ describe('runCommand', () => { }); }); +// ── Tests: agent discovery (no target) ──────────────────────────────────── +describe('agent discovery (no target)', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.SQUADS_SKIP_CHECKS = '1'; + exitSpy = makeExitSpy(); + }); + + afterEach(() => { + exitSpy.mockRestore(); + delete process.env.SQUADS_SKIP_CHECKS; + }); + + it('shows available agents grouped by squad when no target given', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue(['cli', 'engineering']); + mockLoadSquad + .mockReturnValueOnce({ name: 'cli', dir: 'cli', mission: 'Build the CLI', agents: [{ name: 'issue-solver', role: 'solve issues', trigger: 'manual' }], pipelines: [], triggers: { scheduled: [], event: [], manual: [] }, routines: [], dependencies: [], outputPath: '', goals: [] }) + .mockReturnValueOnce({ name: 'engineering', dir: 'engineering', mission: '', agents: [], pipelines: [], triggers: { scheduled: [], event: [], manual: [] }, routines: [], dependencies: [], outputPath: '', goals: [] }); + + await runCommand(null, {}); + + const calls = mockWriteLine.mock.calls.map(c => c[0]); + expect(calls.some(msg => msg?.toString().includes('Available agents'))).toBe(true); + expect(calls.some(msg => msg?.toString().includes('issue-solver'))).toBe(true); + }); + + it('shows "no squads found" when squads directory is empty', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue([]); + + await runCommand(null, {}); + + const calls = mockWriteLine.mock.calls.map(c => c[0]); + expect(calls.some(msg => msg?.toString().includes('No squads found'))).toBe(true); + }); + + it('does not show agent discovery when --once flag is set (autopilot intent)', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue([]); + + // once=true → autopilot intent → runAutopilot called, not showAgentDiscovery + // runAutopilot crashes on cognition mock returning {} (no beliefs) — that's fine, + // we only care that agent discovery was NOT triggered + await runCommand(null, { once: true }).catch(() => {/* autopilot may throw from mock */}); + + // listSquads is called by showAgentDiscovery, not by runAutopilot + expect(mockListSquads).not.toHaveBeenCalled(); + }); +}); + // ── Tests: runSquadCommand ───────────────────────────────────────────────── describe('runSquadCommand', () => { let exitSpy: ReturnType; diff --git a/test/lib/workflow.test.ts b/test/lib/workflow.test.ts index 501bc78d..498e6cc6 100644 --- a/test/lib/workflow.test.ts +++ b/test/lib/workflow.test.ts @@ -16,10 +16,15 @@ vi.mock('fs', () => ({ })); // Mock child_process before import -vi.mock('child_process', () => ({ - execSync: vi.fn(), - exec: vi.fn(), -})); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + exec: vi.fn(), + spawn: vi.fn(), + }; +}); // Mock squad-parser vi.mock('../../src/lib/squad-parser.js', () => ({ @@ -41,7 +46,7 @@ vi.mock('../../src/lib/conversation.js', async () => { }); import { existsSync, writeFileSync, mkdirSync } from 'fs'; -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import { findSquadsDir } from '../../src/lib/squad-parser.js'; import { runConversation, saveTranscript } from '../../src/lib/workflow.js'; import { createTranscript, addTurn } from '../../src/lib/conversation.js'; @@ -51,8 +56,37 @@ const mockExistsSync = vi.mocked(existsSync); const mockWriteFileSync = vi.mocked(writeFileSync); const mockMkdirSync = vi.mocked(mkdirSync); const mockExecSync = vi.mocked(execSync); +const mockSpawn = vi.mocked(spawn); const mockFindSquadsDir = vi.mocked(findSquadsDir); +/** + * Create a fake child_process spawn result that emits stdout then closes. + * Returns a plain object — cast to ReturnType at call site. + */ +function makeFakeChild(stdout: string) { + const stdoutCbs: ((chunk: Buffer) => void)[] = []; + const closeCbs: ((code: number) => void)[] = []; + return { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { + on: vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') stdoutCbs.push(cb); + }), + }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + if (event === 'close') { + closeCbs.push(cb as (code: number) => void); + setImmediate(() => { + stdoutCbs.forEach(fn => fn(Buffer.from(stdout))); + closeCbs.forEach(fn => fn(0)); + }); + } + }), + kill: vi.fn(), + }; +} + // Minimal squad fixture function makeSquad(overrides: Partial = {}): Squad { return { @@ -105,7 +139,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // agent file exists // Lead outputs a convergence phrase immediately - mockExecSync.mockReturnValue('Session complete. All PRs merged.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete. All PRs merged.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -121,7 +155,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // Each lead turn produces non-convergent output but we set very low cost ceiling - mockExecSync.mockReturnValue('Still working on it.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Still working on it.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -142,7 +176,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // Each turn produces non-convergent output with no cost (free) - mockExecSync.mockImplementation(() => 'Still working on it.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Still working on it.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -163,9 +197,13 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); const capturedPrompts: string[] = []; - mockExecSync.mockImplementation((cmd: string) => { - capturedPrompts.push(cmd); - return 'Session complete.' as never; + mockSpawn.mockImplementation(() => { + const child = makeFakeChild('Session complete.'); + child.stdin.write.mockImplementation((data: unknown) => { + capturedPrompts.push(typeof data === 'string' ? data : String(data)); + return true; + }); + return child as unknown as ReturnType; }); const squad = makeSquad({ @@ -190,7 +228,7 @@ describe('runConversation', () => { return false; }); - mockExecSync.mockReturnValue('Session complete.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete.') as unknown as ReturnType); const squad = makeSquad({ repo: 'agents-squads/squads-cli', @@ -206,7 +244,7 @@ describe('runConversation', () => { mockFindSquadsDir.mockReturnValue('/fake/.agents/squads'); mockExistsSync.mockReturnValue(true); - mockExecSync.mockReturnValue('Session complete.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete.') as unknown as ReturnType); const squad = makeSquad({ agents: [