diff --git a/.changeset/fix-init-noargs-createteam.md b/.changeset/fix-init-noargs-createteam.md new file mode 100644 index 000000000..ff673f4b6 --- /dev/null +++ b/.changeset/fix-init-noargs-createteam.md @@ -0,0 +1,11 @@ +--- +"@bradygaster/squad-cli": patch +--- + +fix: `/init` with no args now accepts follow-up message as cast prompt, and `createTeam` correctly creates `team.md`/`routing.md` in fresh projects + +Two related bugs in the TUI init flow: + +1. After `/init` (no args) showed guidance text, the user's follow-up message hit the "No Squad team found" guard instead of starting team casting. Fixed by tracking `awaitingInitPrompt` state in `App.tsx` and bypassing the team-file guard in `handleDispatch` when `skipCastConfirmation` is explicitly set. + +2. After confirming a team proposal, `createTeam` silently skipped creating `team.md` and `routing.md` in a fresh project (no `.squad/` directory), causing the coordinator to immediately say "no team yet" after showing "Team hired!". Fixed with else-branches that create both files from scratch when they don't exist. diff --git a/packages/squad-cli/src/cli/core/cast.ts b/packages/squad-cli/src/cli/core/cast.ts index dacd77524..500f6ddd7 100644 --- a/packages/squad-cli/src/cli/core/cast.ts +++ b/packages/squad-cli/src/cli/core/cast.ts @@ -410,9 +410,10 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom membersCreated.push(member.name); } - // Update team.md — preserve content before and after ## Members + // Create or update team.md const teamPath = join(squadDir, 'team.md'); if (existsSync(teamPath)) { + // Update existing — preserve content before and after ## Members const content = await readFile(teamPath, 'utf8'); const membersIdx = content.indexOf('## Members'); if (membersIdx !== -1) { @@ -428,14 +429,59 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom await writeFile(teamPath, newContent); filesCreated.push(teamPath); } + } else { + // Create from scratch — fresh project with no prior .squad/ directory + const projectName = proposal.projectDescription + ? proposal.projectDescription.slice(0, 80).replace(/\n/g, ' ') + : 'Squad Project'; + const freshContent = [ + '# Squad Team', + '', + `> ${projectName}`, + '', + '## Coordinator', + '', + '| Name | Role | Notes |', + '|------|------|-------|', + '| Squad | Coordinator | Routes work, enforces handoffs and reviewer gates. |', + '', + buildMembersTable(allMembers), + '## Project Context', + '', + `- **Project:** ${projectName}`, + `- **Created:** ${new Date().toISOString().split('T')[0]}`, + '', + ].join('\n'); + await writeFile(teamPath, freshContent); + filesCreated.push(teamPath); } - // Update routing.md — append routing table + // Create or update routing.md const routingPath = join(squadDir, 'routing.md'); + const routingTable = buildRoutingTable(allMembers); if (existsSync(routingPath)) { + // Update existing — append routing table const content = await readFile(routingPath, 'utf8'); - const table = buildRoutingTable(allMembers); - await writeFile(routingPath, content.trimEnd() + '\n\n' + table + '\n'); + await writeFile(routingPath, content.trimEnd() + '\n\n' + routingTable + '\n'); + filesCreated.push(routingPath); + } else { + // Create from scratch + const freshRouting = [ + '# Squad Routing', + '', + '## Work Type Rules', + '', + '| Work Type | Primary Agent | Fallback |', + '|-----------|---------------|----------|', + '', + '## Governance', + '', + '- Route based on work type and agent expertise', + '- Update this file as team capabilities evolve', + '', + routingTable, + ].join('\n'); + await writeFile(routingPath, freshRouting); filesCreated.push(routingPath); } diff --git a/packages/squad-cli/src/cli/shell/commands.ts b/packages/squad-cli/src/cli/shell/commands.ts index 42b811e85..10a6628ea 100644 --- a/packages/squad-cli/src/cli/shell/commands.ts +++ b/packages/squad-cli/src/cli/shell/commands.ts @@ -26,6 +26,12 @@ export interface CommandResult { clear?: boolean; /** When true, the shell should trigger init casting with the provided prompt. */ triggerInitCast?: { prompt: string }; + /** + * When true, the shell should enter "awaiting init prompt" mode: + * the next user message will be treated as a team-cast request. + * Set when `/init` is run with no inline prompt. + */ + awaitInitPrompt?: boolean; } /** @@ -223,9 +229,10 @@ function handleInit(args: string[], context: CommandContext): CommandResult { }; } - // No prompt: guide the user + // No prompt: guide the user and enter "awaiting init prompt" mode return { handled: true, + awaitInitPrompt: true, output: [ 'To cast your Squad team, just type what you want to build.', '', diff --git a/packages/squad-cli/src/cli/shell/components/App.tsx b/packages/squad-cli/src/cli/shell/components/App.tsx index 2ad5f9b17..89e1e244f 100644 --- a/packages/squad-cli/src/cli/shell/components/App.tsx +++ b/packages/squad-cli/src/cli/shell/components/App.tsx @@ -58,6 +58,11 @@ export const App: React.FC = ({ registry, renderer, teamRoot, version, const [activityHint, setActivityHint] = useState(undefined); const [agentActivities, setAgentActivities] = useState>(new Map()); const [welcome, setWelcome] = useState(() => loadWelcomeData(teamRoot)); + /** + * True after a no-args `/init` so the next user message is treated as a + * team-cast request (equivalent to `/init `). + */ + const [awaitingInitPrompt, setAwaitingInitPrompt] = useState(false); const messagesRef = useRef([]); const ctrlCRef = useRef(0); const ctrlCTimerRef = useRef | null>(null); @@ -177,6 +182,32 @@ export const App: React.FC = ({ registry, renderer, teamRoot, version, const knownAgents = registry.getAll().map(a => a.name); const parsed = parseInput(input, knownAgents); + // If we're awaiting an init prompt and the user sent a non-slash message, + // treat it as an inline /init cast request. + if (awaitingInitPrompt && parsed.type !== 'slash_command') { + setAwaitingInitPrompt(false); + if (!onDispatch) { + appendMessages(prev => [...prev, { + role: 'system' as const, + content: 'SDK not connected. Try: (1) squad doctor to check setup, (2) check your internet connection, (3) restart the shell to reconnect.', + timestamp: new Date(), + }]); + return; + } + const castParsed: ParsedInput = { + type: 'coordinator', + raw: input, + content: input, + skipCastConfirmation: false, // show confirmation, same as freeform cast + }; + setProcessing(true); + onDispatch(castParsed).finally(() => { + setProcessing(false); + setAgents([...registry.getAll()]); + }); + return; + } + if (parsed.type === 'slash_command') { const result = executeCommand(parsed.command!, parsed.args ?? [], { registry, @@ -214,6 +245,11 @@ export const App: React.FC = ({ registry, renderer, teamRoot, version, return; } + if (result.awaitInitPrompt) { + // No-args /init: show the guidance and wait for the user's next message + setAwaitingInitPrompt(true); + } + if (result.output) { appendMessages(prev => [...prev, { role: 'system' as const, @@ -238,7 +274,7 @@ export const App: React.FC = ({ registry, renderer, teamRoot, version, } setAgents([...registry.getAll()]); - }, [registry, renderer, teamRoot, exit, onDispatch, appendMessages]); + }, [registry, renderer, teamRoot, exit, onDispatch, appendMessages, awaitingInitPrompt]); const rosterAgents = welcome?.agents ?? []; diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index a6b8c2bf6..40d6d3b7b 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -1021,6 +1021,13 @@ export async function runShell(): Promise { // Guard: require a Squad team before processing work requests const teamFile = join(teamRoot, '.squad', 'team.md'); if (!existsSync(teamFile)) { + // When skipCastConfirmation is explicitly set (true or false), the message + // was routed from an /init flow (inline or follow-up), so bypass the guard + // and go straight to Init Mode casting even without a team.md. + if (parsed.skipCastConfirmation !== undefined) { + await handleInitCast(parsed, parsed.skipCastConfirmation); + return; + } shellApi?.addMessage({ role: 'system', content: '\u26A0 No Squad team found. Run /init to create your team first.', diff --git a/test/cast-parser.test.ts b/test/cast-parser.test.ts index c52737558..b900c0402 100644 --- a/test/cast-parser.test.ts +++ b/test/cast-parser.test.ts @@ -1,10 +1,15 @@ /** - * Tests for parseCastResponse — the REPL casting parser. - * Ensures robust parsing of various model response formats. + * Tests for parseCastResponse and createTeam — the REPL casting engine. + * Ensures robust parsing of various model response formats and correct + * file scaffolding for both fresh and pre-initialised projects. */ -import { describe, it, expect } from 'vitest'; -import { parseCastResponse } from '../packages/squad-cli/src/cli/core/cast.js'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parseCastResponse, createTeam, type CastProposal } from '../packages/squad-cli/src/cli/core/cast.js'; describe('parseCastResponse', () => { it('parses strict INIT_TEAM format', () => { @@ -160,3 +165,122 @@ PROJECT: Something`; } }); }); + +// ── createTeam ───────────────────────────────────────────────────── + +const minimalProposal: CastProposal = { + universe: 'Alien', + projectDescription: 'A React and Node.js web application', + members: [ + { name: 'Ripley', role: 'Lead', scope: 'Architecture, code review', emoji: '🏗️' }, + { name: 'Dallas', role: 'Frontend Dev', scope: 'React, UI, components', emoji: '⚛️' }, + { name: 'Kane', role: 'Backend Dev', scope: 'Node.js, APIs, database', emoji: '🔧' }, + ], +}; + +describe('createTeam', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'squad-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('fresh project — no .squad/ directory', () => { + it('creates team.md with ## Members section and data rows', async () => { + await createTeam(tempDir, minimalProposal); + + const teamPath = join(tempDir, '.squad', 'team.md'); + expect(existsSync(teamPath)).toBe(true); + + const content = await readFile(teamPath, 'utf-8'); + expect(content).toContain('## Members'); + expect(content).toContain('| Ripley |'); + expect(content).toContain('| Dallas |'); + expect(content).toContain('| Kane |'); + }); + + it('creates routing.md from scratch', async () => { + await createTeam(tempDir, minimalProposal); + + const routingPath = join(tempDir, '.squad', 'routing.md'); + expect(existsSync(routingPath)).toBe(true); + + const content = await readFile(routingPath, 'utf-8'); + expect(content).toContain('# Squad Routing'); + }); + + it('includes project description in team.md header', async () => { + await createTeam(tempDir, minimalProposal); + + const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); + expect(content).toContain('A React and Node.js web application'); + }); + + it('team.md passes hasRosterEntries check (coordinator can read it)', async () => { + // Import hasRosterEntries to verify the coordinator will recognise the team + const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js'); + + await createTeam(tempDir, minimalProposal); + const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); + expect(hasRosterEntries(content)).toBe(true); + }); + + it('adds built-in Scribe and Ralph when not in proposal', async () => { + const result = await createTeam(tempDir, minimalProposal); + expect(result.membersCreated).toContain('Scribe'); + expect(result.membersCreated).toContain('Ralph'); + }); + + it('creates agent charter and history files for each member', async () => { + const result = await createTeam(tempDir, minimalProposal); + for (const name of result.membersCreated) { + const base = join(tempDir, '.squad', 'agents', name.toLowerCase()); + expect(existsSync(join(base, 'charter.md'))).toBe(true); + expect(existsSync(join(base, 'history.md'))).toBe(true); + } + }); + }); + + describe('existing project — .squad/ with empty team.md', () => { + beforeEach(async () => { + const squadDir = join(tempDir, '.squad'); + await mkdir(squadDir, { recursive: true }); + await writeFile(join(squadDir, 'team.md'), [ + '# Squad Team', + '', + '> Pre-existing project', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + '', + '## Project Context', + '', + '- **Project:** Pre-existing', + '', + ].join('\n')); + }); + + it('updates the Members section without clobbering surrounding content', async () => { + await createTeam(tempDir, minimalProposal); + + const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); + expect(content).toContain('Pre-existing project'); + expect(content).toContain('## Project Context'); + expect(content).toContain('| Ripley |'); + }); + + it('team.md passes hasRosterEntries after update', async () => { + const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js'); + + await createTeam(tempDir, minimalProposal); + const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); + expect(hasRosterEntries(content)).toBe(true); + }); + }); +}); diff --git a/test/init-autocast.test.ts b/test/init-autocast.test.ts index b0d255dfb..e46e1547b 100644 --- a/test/init-autocast.test.ts +++ b/test/init-autocast.test.ts @@ -378,6 +378,94 @@ describe('triggerInitCast signal — App.tsx dispatch contract', () => { }); }); +// =========================================================================== +// 5b. awaitInitPrompt signal — no-args /init follow-up flow (#216) +// =========================================================================== + +describe('awaitInitPrompt signal — no-args /init follow-up flow', () => { + let context: CommandContext; + + beforeEach(() => { + context = makeCommandContext('/test'); + }); + + it('sets awaitInitPrompt=true when no args given', () => { + const result = executeCommand('init', [], context); + expect(result.handled).toBe(true); + expect(result.awaitInitPrompt).toBe(true); + }); + + it('sets awaitInitPrompt=true for whitespace-only args', () => { + const result = executeCommand('init', [' ', ' '], context); + expect(result.handled).toBe(true); + expect(result.awaitInitPrompt).toBe(true); + }); + + it('does NOT set awaitInitPrompt when inline prompt is provided', () => { + const result = executeCommand('init', ['Build', 'a', 'snake', 'game'], context); + expect(result.handled).toBe(true); + expect(result.triggerInitCast).toBeDefined(); + expect(result.awaitInitPrompt).toBeUndefined(); + }); + + it('awaitInitPrompt result has guidance output text', () => { + const result = executeCommand('init', [], context); + expect(result.awaitInitPrompt).toBe(true); + expect(result.output).toBeDefined(); + expect(result.output).toContain('just type what you want to build'); + }); + + it('awaitInitPrompt output includes team.md path', () => { + const result = executeCommand('init', [], context); + expect(result.output).toContain('/test/.squad/team.md'); + }); +}); + +// =========================================================================== +// 5c. handleDispatch guard bypass — skipCastConfirmation=false with no team.md +// =========================================================================== + +describe('handleDispatch guard — skipCastConfirmation bypasses missing team.md check', () => { + it('follow-up init ParsedInput has skipCastConfirmation=false (not undefined), bypassing guard', () => { + // App.tsx sets skipCastConfirmation: false for follow-up-after-/init messages. + // false !== undefined, so the guard check `parsed.skipCastConfirmation !== undefined` + // evaluates to true and the cast path is taken rather than the error path. + const followUpParsed = { + type: 'coordinator' as const, + raw: 'Build a Marine Asset Integrity tool', + content: 'Build a Marine Asset Integrity tool', + skipCastConfirmation: false as const, + }; + expect(followUpParsed.skipCastConfirmation).toBe(false); + expect(followUpParsed.skipCastConfirmation !== undefined).toBe(true); + }); + + it('plain coordinator ParsedInput has skipCastConfirmation=undefined — guard is applied', () => { + // Regular messages have no skipCastConfirmation, so the guard runs normally + // and shows the "No Squad team found" error when team.md is absent. + const regularParsed = { + type: 'coordinator' as const, + raw: 'Do something', + content: 'Do something', + }; + expect(regularParsed.skipCastConfirmation).toBeUndefined(); + expect((regularParsed as { skipCastConfirmation?: boolean }).skipCastConfirmation !== undefined).toBe(false); + }); + + it('inline /init ParsedInput has skipCastConfirmation=true — guard bypassed and confirmation skipped', () => { + // App.tsx sets skipCastConfirmation: true for inline /init "prompt" messages. + // The guard is bypassed AND the confirmation dialog is skipped. + const inlineParsed = { + type: 'coordinator' as const, + raw: 'Build a REST API', + content: 'Build a REST API', + skipCastConfirmation: true as const, + }; + expect(inlineParsed.skipCastConfirmation).toBe(true); + expect(inlineParsed.skipCastConfirmation !== undefined).toBe(true); + }); +}); + // =========================================================================== // 6. Ctrl+C abort — activeInitSession lifecycle // ===========================================================================