Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,10 @@ async function main(): Promise<void> {
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 <team-repo-path>`);
console.log(` Creates .squad/config.json pointing to an external team root`);
console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`);
Expand Down Expand Up @@ -245,7 +246,8 @@ async function main(): Promise<void> {
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;
Expand Down
9 changes: 9 additions & 0 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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`);
Expand Down
20 changes: 15 additions & 5 deletions packages/squad-cli/src/cli/shell/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -129,7 +130,7 @@ function handleHelp(args: string[]): CommandResult {
'/agents — List team members',
'/sessions — Past sessions',
'/resume <id> — Restore session',
'/init — Set up your team',
'/init [--roles] — Set up your team',
'/nap — Context hygiene',
'/version — Show version',
'/clear — Clear screen',
Expand All @@ -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',
Expand Down Expand Up @@ -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 },
Expand All @@ -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'),
Expand Down
75 changes: 71 additions & 4 deletions packages/squad-cli/src/cli/shell/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
);
Expand Down Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion packages/squad-cli/src/cli/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,14 @@ export async function runShell(): Promise<void> {
// 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 },
Expand Down Expand Up @@ -977,6 +984,8 @@ export async function runShell(): Promise<void> {
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) {
Expand Down
94 changes: 94 additions & 0 deletions test/init-base-roles.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading