diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 82169bae7..ffd3524d8 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -129,9 +129,10 @@ async function main(): Promise { console.log(` ${BOLD}(default)${RESET} Launch interactive shell (no args)`); console.log(` Flags: --global (init in personal squad directory)`); console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`); - console.log(` Flags: --sdk (generate squad.config.ts with SDK builder syntax)`); - console.log(` --global (init in personal squad directory)`); - console.log(` --no-workflows (skip GitHub workflow installation)`); + console.log(` Flags: --sdk (SDK builder syntax)`); + console.log(` --roles (use base roles)`); + console.log(` --global (personal squad dir)`); + console.log(` --no-workflows (skip CI setup)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -245,7 +246,8 @@ async function main(): Promise { const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : process.cwd(); const noWorkflows = args.includes('--no-workflows'); const sdk = args.includes('--sdk'); - runInit(dest, { includeWorkflows: !noWorkflows, sdk }).catch(err => { + const roles = args.includes('--roles'); + runInit(dest, { includeWorkflows: !noWorkflows, sdk, roles }).catch(err => { fatal(err.message); }); return; diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index e6fb696c0..8286a8f48 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -102,6 +102,8 @@ export interface RunInitOptions { includeWorkflows?: boolean; /** If true, generate squad.config.ts with SDK builder syntax (default: false) */ sdk?: boolean; + /** If true, use built-in base roles instead of fictional universe casting (default: false) */ + roles?: boolean; } /** @@ -208,6 +210,13 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi stampVersion(agentPath, version); } + // Persist --roles flag for the REPL to pick up during casting + if (options.roles) { + const rolesMarker = path.join(squadDir, '.init-roles'); + fs.writeFileSync(rolesMarker, '1', 'utf-8'); + success(`base roles enabled — team will use built-in role catalog`); + } + // Report .init-prompt storage if (options.prompt) { success(`.init-prompt stored — team will be cast when you start squad`); diff --git a/packages/squad-cli/src/cli/shell/commands.ts b/packages/squad-cli/src/cli/shell/commands.ts index 10a6628ea..b6faee9bb 100644 --- a/packages/squad-cli/src/cli/shell/commands.ts +++ b/packages/squad-cli/src/cli/shell/commands.ts @@ -6,6 +6,7 @@ import { listSessions, loadSessionById, type SessionData } from './session-store import { formatAgentLine, getStatusTag } from './agent-status.js'; import type { ShellMessage } from './types.js'; import path from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { runNapSync, formatNapReport } from '../core/nap.js'; export interface CommandContext { @@ -129,7 +130,7 @@ function handleHelp(args: string[]): CommandResult { '/agents — List team members', '/sessions — Past sessions', '/resume — Restore session', - '/init — Set up your team', + '/init [--roles] — Set up your team', '/nap — Context hygiene', '/version — Show version', '/clear — Clear screen', @@ -151,7 +152,7 @@ function handleHelp(args: string[]): CommandResult { ' /agents — List all team members', ' /sessions — List saved sessions', ' /resume — Restore a past session', - ' /init — Set up your team', + ' /init — Set up your team (add --roles for base role catalog)', ' /nap — Context hygiene (compress, prune, archive)', ' /version — Show version', ' /clear — Clear the screen', @@ -218,11 +219,19 @@ function handleNap(args: string[], context: CommandContext): CommandResult { } function handleInit(args: string[], context: CommandContext): CommandResult { - // Check if args contain an inline prompt - const prompt = args.join(' ').trim(); + // Check for --roles flag + const useBaseRoles = args.includes('--roles'); + const filteredArgs = args.filter(a => a !== '--roles'); + const prompt = filteredArgs.join(' ').trim(); + + if (useBaseRoles) { + // Write .init-roles marker for the casting flow to pick up + const rolesMarker = path.join(context.teamRoot, '.squad', '.init-roles'); + try { mkdirSync(path.dirname(rolesMarker), { recursive: true }); } catch { /* ignore */ } + try { writeFileSync(rolesMarker, '1', 'utf-8'); } catch { /* ignore */ } + } if (prompt) { - // Inline prompt provided: /init "Build a snake game" return { handled: true, triggerInitCast: { prompt }, @@ -240,6 +249,7 @@ function handleInit(args: string[], context: CommandContext): CommandResult { 'create agent files, and route your work — all automatically.', '', 'Example: "Build a React app with a Node.js backend"', + useBaseRoles ? 'Mode: Using built-in base roles (--roles)' : 'Mode: Fictional universe casting (default)', '', `Team file: ${context.teamRoot}/.squad/team.md`, ].join('\n'), diff --git a/packages/squad-cli/src/cli/shell/coordinator.ts b/packages/squad-cli/src/cli/shell/coordinator.ts index 01087aa88..2b27d9218 100644 --- a/packages/squad-cli/src/cli/shell/coordinator.ts +++ b/packages/squad-cli/src/cli/shell/coordinator.ts @@ -34,13 +34,80 @@ export interface CoordinatorConfig { routingPath?: string; /** Path to team.md */ teamPath?: string; + /** When true, include the base roles catalog in the init prompt. Default: false (fictional universe casting). */ + useBaseRoles?: boolean; } /** * Build an Init Mode system prompt for team casting. * Used when team.md exists but has no roster entries. + * + * When `config.useBaseRoles` is true (opt-in via `--roles`), the prompt + * includes the built-in base roles catalog so the LLM maps agents to + * curated role IDs. Otherwise (default), the LLM casts from a fictional + * universe with free-form role names — the beloved casting experience. */ export function buildInitModePrompt(config: CoordinatorConfig): string { + if (config.useBaseRoles) { + return buildBaseRolesInitPrompt(); + } + return buildUniverseCastingInitPrompt(); +} + +/** + * Default init prompt — fictional universe casting (no base roles catalog). + */ +function buildUniverseCastingInitPrompt(): string { + return `You are the Squad Coordinator in Init Mode. + +This project has a Squad scaffold (.squad/ directory) but no team has been cast yet. +The user's message describes what they want to build or work on. + +Your job: Propose a team of 4-5 AI agents based on what the user wants to do. + +## Rules +1. Analyze the user's message to understand the project (language, stack, scope) +2. Pick a fictional universe for character names (e.g., Alien, The Usual Suspects, Blade Runner, The Matrix, Heat, Star Wars). Pick ONE universe and use it consistently. +3. Propose 4-5 agents with roles that match the project needs +4. Scribe and Ralph are always included automatically — do NOT include them in your proposal + +## Response Format — you MUST use this EXACT format: + +INIT_TEAM: +- {Name} | {Role} | {scope: 2-4 words describing expertise} +- {Name} | {Role} | {scope} +- {Name} | {Role} | {scope} +- {Name} | {Role} | {scope} +UNIVERSE: {universe name} +PROJECT: {1-sentence project description} + +## Example + +If user says "Build a React app with a Node backend": + +INIT_TEAM: +- Ripley | Lead | Architecture, code review, decisions +- Dallas | Frontend Dev | React, components, styling +- Kane | Backend Dev | Node.js, APIs, database +- Lambert | Tester | Tests, quality, edge cases +UNIVERSE: Alien +PROJECT: A React and Node.js web application + +## Important +- Use character names that feel natural, not forced +- Roles should match project needs (don't always use the same 4 roles) +- For CLI projects: maybe skip Frontend, add DevOps or SDK Expert +- For data projects: add Data Engineer, skip Frontend +- Keep scope descriptions short (2-4 words each) +- Respond ONLY with the INIT_TEAM block — no other text +`; +} + +/** + * Opt-in base roles init prompt — includes the curated role catalog. + * Activated by `squad init --roles` or `/init --roles`. + */ +function buildBaseRolesInitPrompt(): string { const catalog = new Map( listRoles().map((role: { id: string; title: string }) => [role.id, role]), ); @@ -123,10 +190,10 @@ PROJECT: {1-sentence project description} If user says "Build a React app with a Node backend": INIT_TEAM: -- Ripley | Lead | Architecture, code review, decisions -- Dallas | Frontend Dev | React, components, styling -- Kane | Backend Dev | Node.js, APIs, database -- Lambert | Tester | Tests, quality, edge cases +- Ripley | lead | Architecture, code review, decisions +- Dallas | frontend | React, components, styling +- Kane | backend | Node.js, APIs, database +- Lambert | tester | Tests, quality, edge cases UNIVERSE: Alien PROJECT: A React and Node.js web application diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index b9960c417..498d0462c 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -856,7 +856,14 @@ export async function runShell(): Promise { // Create a temporary Init Mode coordinator session let initSession: SquadSession | null = null; try { - const initSysPrompt = buildInitModePrompt({ teamRoot }); + // Check for .init-roles marker (set by `squad init --roles` or `/init --roles`) + const initRolesMarker = join(teamRoot, '.squad', '.init-roles'); + const useBaseRoles = existsSync(initRolesMarker); + // Consume the marker immediately — it's been read, no need to persist + if (useBaseRoles) { + try { unlinkSync(initRolesMarker); } catch { /* ignore */ } + } + const initSysPrompt = buildInitModePrompt({ teamRoot, useBaseRoles }); initSession = await client.createSession({ streaming: true, systemMessage: { mode: 'append', content: initSysPrompt }, @@ -977,6 +984,8 @@ export async function runShell(): Promise { try { unlinkSync(initPromptFile); } catch { /* ignore */ } } + // Note: .init-roles marker is already cleaned up in handleInitCast (consumed on read) + // Invalidate the old coordinator session so the next dispatch builds one // with the real team roster if (coordinatorSession) { diff --git a/test/init-base-roles.test.ts b/test/init-base-roles.test.ts new file mode 100644 index 000000000..2f022775a --- /dev/null +++ b/test/init-base-roles.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for base roles opt-in behavior (Issue #379). + * + * Verifies: + * - buildInitModePrompt defaults to fictional universe casting (no base roles catalog) + * - buildInitModePrompt includes base roles catalog only when useBaseRoles is true + * - .init-roles marker file is written by init when --roles is passed + * - .init-roles marker is cleaned up after casting + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + buildInitModePrompt, + type CoordinatorConfig, +} from '../packages/squad-cli/src/cli/shell/coordinator.js'; + +describe('buildInitModePrompt — base roles opt-in (#379)', () => { + let teamRoot: string; + + beforeEach(async () => { + teamRoot = await mkdtemp(join(tmpdir(), 'squad-init-roles-')); + mkdirSync(join(teamRoot, '.squad'), { recursive: true }); + }); + + afterEach(async () => { + await rm(teamRoot, { recursive: true, force: true }); + }); + + it('default prompt uses fictional universe casting (no base roles catalog)', () => { + const prompt = buildInitModePrompt({ teamRoot }); + + // Should instruct to pick a fictional universe + expect(prompt).toContain('Pick a fictional universe'); + expect(prompt).toContain('INIT_TEAM:'); + + // Should NOT include the base roles catalog section + expect(prompt).not.toContain('## Built-in Base Roles'); + expect(prompt).not.toContain('Prefer these over inventing new roles'); + expect(prompt).not.toContain('marketing-strategist'); + expect(prompt).not.toContain('compliance-legal'); + }); + + it('prompt with useBaseRoles=true includes base roles catalog', () => { + const prompt = buildInitModePrompt({ teamRoot, useBaseRoles: true }); + + // Should still instruct fictional universe for character names + expect(prompt).toContain('Pick a fictional universe'); + expect(prompt).toContain('INIT_TEAM:'); + + // Should include the base roles catalog section + expect(prompt).toContain('## Built-in Base Roles'); + expect(prompt).toContain('Prefer these over inventing new roles'); + expect(prompt).toContain('marketing-strategist'); + expect(prompt).toContain('compliance-legal'); + expect(prompt).toContain('lead'); + expect(prompt).toContain('backend'); + expect(prompt).toContain('frontend'); + }); + + it('prompt with useBaseRoles=false matches default (no catalog)', () => { + const defaultPrompt = buildInitModePrompt({ teamRoot }); + const explicitFalse = buildInitModePrompt({ teamRoot, useBaseRoles: false }); + + expect(explicitFalse).toBe(defaultPrompt); + }); +}); + +describe('.init-roles marker file lifecycle', () => { + let teamRoot: string; + + beforeEach(async () => { + teamRoot = await mkdtemp(join(tmpdir(), 'squad-init-roles-marker-')); + mkdirSync(join(teamRoot, '.squad'), { recursive: true }); + }); + + afterEach(async () => { + await rm(teamRoot, { recursive: true, force: true }); + }); + + it('.init-roles marker does not exist by default', () => { + expect(existsSync(join(teamRoot, '.squad', '.init-roles'))).toBe(false); + }); + + it('.init-roles marker can be created and detected', () => { + const markerPath = join(teamRoot, '.squad', '.init-roles'); + writeFileSync(markerPath, '1', 'utf-8'); + expect(existsSync(markerPath)).toBe(true); + }); +});