From 4bcc3b84ea9861fb6f23148e8703cf2ad3a4bcbf Mon Sep 17 00:00:00 2001 From: williamhallatt Date: Fri, 6 Mar 2026 12:44:19 +1000 Subject: [PATCH 1/3] fix: /init with no args now accepts follow-up message as cast prompt (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user runs '/init' with no inline prompt, the TUI showed guidance text but did not track any state. The user's next message then hit the 'No Squad team found' guard in handleDispatch and showed an error instead of starting team casting. Changes: - commands.ts: add awaitInitPrompt?: boolean to CommandResult; set it in the no-args /init path alongside the existing guidance output - App.tsx: track awaitingInitPrompt state; when set, route the next non-slash message as a coordinator cast with skipCastConfirmation: false (shows confirmation dialog, same as freeform cast) - index.ts handleDispatch: when skipCastConfirmation is explicitly set (true or false, not undefined) and team.md is absent, bypass the 'No Squad team found' guard and call handleInitCast directly This means the full init flow now works end-to-end: /init → guidance text + set awaitingInitPrompt → routed as cast → handleInitCast → team proposed y → finalizeCast → team files created → re-dispatched to the new team Tests added in test/init-autocast.test.ts covering: - awaitInitPrompt=true on no-args /init - awaitInitPrompt=true on whitespace-only args - awaitInitPrompt undefined when inline prompt provided - guard bypass contract for skipCastConfirmation values --- packages/squad-cli/src/cli/shell/commands.ts | 9 +- .../src/cli/shell/components/App.tsx | 38 +++++++- packages/squad-cli/src/cli/shell/index.ts | 7 ++ test/init-autocast.test.ts | 88 +++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) 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/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 // =========================================================================== From 6be77905836d7cd18fe1038697959e85b3f5531a Mon Sep 17 00:00:00 2001 From: williamhallatt Date: Fri, 6 Mar 2026 13:04:59 +1000 Subject: [PATCH 2/3] fix: createTeam now creates team.md and routing.md in fresh projects In a fresh project (no .squad/ directory), createTeam was only running the 'if (existsSync(teamPath))' update path, silently skipping both team.md and routing.md creation. After finalizeCast, dispatchToCoordinator would build a new session, buildCoordinatorPrompt would fail to read team.md, and the coordinator would fall through to the 'NO TEAM CONFIGURED' prompt -- telling the user to run /init again. Fix: add else-branches to create team.md and routing.md from scratch when the files don't yet exist. The fresh team.md includes a proper ## Members section with data rows so hasRosterEntries() returns true and the coordinator enters Team Mode correctly. Tests: 9 new createTeam integration tests in cast-parser.test.ts covering: - fresh project creates team.md with ## Members data rows - fresh project creates routing.md - project description included in header - hasRosterEntries passes after createTeam (coordinator regression guard) - built-in Scribe and Ralph added - agent charter and history files created - existing project Members section updated without clobbering surrounding content - existing project passes hasRosterEntries after update --- packages/squad-cli/src/cli/core/cast.ts | 54 +++++++++- test/cast-parser.test.ts | 132 +++++++++++++++++++++++- 2 files changed, 178 insertions(+), 8 deletions(-) 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/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); + }); + }); +}); From f8ea3287c16a1359646a0ac4499ab562dc3507c4 Mon Sep 17 00:00:00 2001 From: williamhallatt Date: Fri, 6 Mar 2026 13:16:12 +1000 Subject: [PATCH 3/3] chore: add changeset for init no-args and createTeam fixes --- .changeset/fix-init-noargs-createteam.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/fix-init-noargs-createteam.md 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.