diff --git a/packages/mosaic/framework/fleet/examples/coding.yaml b/packages/mosaic/framework/fleet/examples/coding.yaml new file mode 100644 index 0000000..878787b --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/coding.yaml @@ -0,0 +1,32 @@ +version: 1 +transport: tmux +tmux: + socket_name: mosaic-factory + holder_session: _holder +defaults: + working_directory: ~ +runtimes: + claude: + reset_command: /clear + pi: + reset_command: /new +agents: + - name: orchestrator + runtime: claude + class: orchestrator + persistent_persona: true + - name: coder0 + runtime: pi + class: implementer + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: coder1 + runtime: pi + class: implementer + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: reviewer + runtime: pi + class: reviewer + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true diff --git a/packages/mosaic/framework/fleet/examples/general.yaml b/packages/mosaic/framework/fleet/examples/general.yaml new file mode 100644 index 0000000..621fc01 --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/general.yaml @@ -0,0 +1,22 @@ +version: 1 +transport: tmux +tmux: + socket_name: mosaic-factory + holder_session: _holder +defaults: + working_directory: ~ +runtimes: + claude: + reset_command: /clear + pi: + reset_command: /new +agents: + - name: orchestrator + runtime: claude + class: orchestrator + persistent_persona: true + - name: generalist + runtime: pi + class: worker + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true diff --git a/packages/mosaic/framework/fleet/examples/hybrid.yaml b/packages/mosaic/framework/fleet/examples/hybrid.yaml new file mode 100644 index 0000000..e69e225 --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/hybrid.yaml @@ -0,0 +1,32 @@ +version: 1 +transport: tmux +tmux: + socket_name: mosaic-factory + holder_session: _holder +defaults: + working_directory: ~ +runtimes: + claude: + reset_command: /clear + pi: + reset_command: /new +agents: + - name: orchestrator + runtime: claude + class: orchestrator + persistent_persona: true + - name: coder0 + runtime: pi + class: implementer + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: researcher0 + runtime: pi + class: researcher + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: reviewer + runtime: pi + class: reviewer + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true diff --git a/packages/mosaic/framework/fleet/examples/research.yaml b/packages/mosaic/framework/fleet/examples/research.yaml new file mode 100644 index 0000000..9eaf6d8 --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/research.yaml @@ -0,0 +1,32 @@ +version: 1 +transport: tmux +tmux: + socket_name: mosaic-factory + holder_session: _holder +defaults: + working_directory: ~ +runtimes: + claude: + reset_command: /clear + pi: + reset_command: /new +agents: + - name: orchestrator + runtime: claude + class: orchestrator + persistent_persona: true + - name: researcher0 + runtime: pi + class: researcher + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: researcher1 + runtime: pi + class: researcher + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true + - name: analyst + runtime: pi + class: analyst + model_hint: openai-codex/gpt-5.5:high + reset_between_tasks: true diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index c62cea5..0b641d5 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -17,8 +17,10 @@ import { buildTmuxListPanesCommand, buildTmuxListSessionsCommand, classifySendResult, + countOrchestrators, detectDrift, enableFleetUnits, + FLEET_PROFILES, generateAgentEnv, getDefaultOperatorSourceLabel, getDefaultTenantAndHost, @@ -28,16 +30,19 @@ import { loadFleetRoster, mergeAgentEnv, parseHeartbeat, + parseInitProfile, parseSystemdShow, parseTmuxListPanes, parseTmuxListSessions, registerFleetCommand, resolveFleetPaths, + resolvePresetFilename, RUNTIME_ACCEPTABLE_COMMANDS, VERIFY_DEFAULT_TIMEOUT_MS, VERIFY_POLL_INTERVAL_MS, type AgentPsRow, type CommandRunner, + type FleetProfile, type FleetRoster, type InteractiveRunner, type SleepFn, @@ -2132,3 +2137,270 @@ describe('agent send --verify', () => { expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000); }); }); + +// --------------------------------------------------------------------------- +// Fleet Phase F1: config-type presets + AI-free init wizard +// --------------------------------------------------------------------------- + +describe('fleet preset rosters', () => { + const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples'); + + it.each(['general', 'coding', 'research', 'hybrid'] as FleetProfile[])( + '%s preset: loads via loadFleetRoster and has exactly one orchestrator', + async (preset) => { + const rosterPath = join(examplesDir, `${preset}.yaml`); + const roster = await loadFleetRoster(rosterPath); + expect(countOrchestrators(roster)).toBe(1); + expect(roster.agents.find((a) => a.name === 'orchestrator')).toBeDefined(); + }, + ); + + it('general preset: orchestrator + one generalist worker', async () => { + const roster = await loadFleetRoster(join(examplesDir, 'general.yaml')); + expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']); + expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude'); + expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi'); + }); + + it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => { + const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml')); + expect(roster.agents.map((a) => a.name)).toEqual([ + 'orchestrator', + 'coder0', + 'coder1', + 'reviewer', + ]); + }); + + it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => { + const roster = await loadFleetRoster(join(examplesDir, 'research.yaml')); + expect(roster.agents.map((a) => a.name)).toEqual([ + 'orchestrator', + 'researcher0', + 'researcher1', + 'analyst', + ]); + }); + + it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => { + const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml')); + expect(roster.agents.map((a) => a.name)).toEqual([ + 'orchestrator', + 'coder0', + 'researcher0', + 'reviewer', + ]); + }); + + it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => { + for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) { + const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`)); + const workers = roster.agents.filter((a) => a.name !== 'orchestrator'); + for (const worker of workers) { + expect(worker.runtime).toBe('pi'); + expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high'); + } + } + }); + + it('orchestrator in new presets uses claude runtime with persistent_persona', async () => { + for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) { + const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`)); + const orch = roster.agents.find((a) => a.name === 'orchestrator'); + expect(orch?.runtime).toBe('claude'); + expect(orch?.persistentPersona).toBe(true); + } + }); + + it('new presets are sanitized: no operator identity tokens', async () => { + for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) { + const text = await readFile(join(examplesDir, `${preset}.yaml`), 'utf8'); + expect(text).not.toMatch(/jarvis|jason|woltje/i); + // working_directory must not reference ~/src or /home + expect(text).not.toMatch(/~\/src|\/home\//); + } + }); +}); + +describe('parseInitProfile', () => { + it('accepts all six fleet profiles', () => { + expect(parseInitProfile('general')).toBe('general'); + expect(parseInitProfile('coding')).toBe('coding'); + expect(parseInitProfile('research')).toBe('research'); + expect(parseInitProfile('hybrid')).toBe('hybrid'); + expect(parseInitProfile('minimal')).toBe('minimal'); + expect(parseInitProfile('local-canary')).toBe('local-canary'); + }); + + it('rejects unknown profiles with a message listing all valid names', () => { + expect(() => parseInitProfile('typo')).toThrow('Unsupported fleet profile'); + expect(() => parseInitProfile('typo')).toThrow('general'); + expect(() => parseInitProfile('typo')).toThrow('coding'); + }); + + it('FLEET_PROFILES contains all six valid profile names', () => { + expect(FLEET_PROFILES).toContain('general'); + expect(FLEET_PROFILES).toContain('coding'); + expect(FLEET_PROFILES).toContain('research'); + expect(FLEET_PROFILES).toContain('hybrid'); + expect(FLEET_PROFILES).toContain('minimal'); + expect(FLEET_PROFILES).toContain('local-canary'); + }); +}); + +describe('resolvePresetFilename', () => { + it.each(FLEET_PROFILES)('maps %s to %s.yaml', (profile) => { + expect(resolvePresetFilename(profile)).toBe(`${profile}.yaml`); + }); +}); + +describe('fleet init wizard', () => { + let cleanup: string | undefined; + + afterEach(async () => { + if (cleanup) { + await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; + } + }); + + it('defaults to general when stdin is not a TTY and no --profile is given', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'fleet', 'roster.yaml'); + const frameworkRoot = resolve(process.cwd(), 'framework'); + const stderrMessages: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => { + stderrMessages.push(String(msg)); + return true; + }); + const program = new Command(); + program.exitOverride(); + // isStdinTTY: false simulates non-interactive environment + registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false }); + + try { + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--write', + ]); + const content = await readFile(rosterPath, 'utf8'); + // Should have written the general preset + expect(content).toContain('name: orchestrator'); + expect(content).toContain('name: generalist'); + // Stderr should explain the fallback + expect(stderrMessages.join('')).toMatch(/defaulting to fleet profile "general"/); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('uses --profile to select preset without wizard (non-TTY path)', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'fleet', 'roster.yaml'); + const frameworkRoot = resolve(process.cwd(), 'framework'); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false }); + + try { + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--profile', + 'coding', + '--write', + ]); + const content = await readFile(rosterPath, 'utf8'); + expect(content).toContain('name: coder0'); + expect(content).toContain('name: reviewer'); + } finally { + // cleanup handled by afterEach + } + }); + + it('written roster has exactly one orchestrator agent (countOrchestrators validation)', async () => { + cleanup = await tempDir(); + const frameworkRoot = resolve(process.cwd(), 'framework'); + for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) { + const rosterPath = join(cleanup, `${preset}-roster.yaml`); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false }); + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--profile', + preset, + '--write', + ]); + const roster = await loadFleetRoster(rosterPath); + expect(countOrchestrators(roster)).toBe(1); + } + }); + + it('re-init with --write and existing roster requires --force (R8 idempotency)', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'fleet', 'roster.yaml'); + const frameworkRoot = resolve(process.cwd(), 'framework'); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false }); + + // First write + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--profile', + 'general', + '--write', + ]); + + // Second write without --force must fail + await expect( + program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--profile', + 'general', + '--write', + ]), + ).rejects.toThrow('Fleet roster already exists'); + + // With --force must succeed + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + '--roster', + rosterPath, + 'init', + '--profile', + 'coding', + '--write', + '--force', + ]); + const content = await readFile(rosterPath, 'utf8'); + expect(content).toContain('name: coder0'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index a5e1b61..54ad1b9 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -4,6 +4,7 @@ import { homedir, hostname, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import * as readline from 'node:readline'; import type { Command } from 'commander'; import YAML from 'yaml'; @@ -41,6 +42,11 @@ export interface FleetCommandDeps { sleepFn?: SleepFn; mosaicHome?: string; frameworkRoot?: string; + /** + * Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY. + * Tests stub this to simulate interactive or non-interactive environments. + */ + isStdinTTY?: boolean; } interface RawFleetRoster { @@ -799,19 +805,42 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = cmd .command('init') .description('Initialize a local fleet roster') - .option('--profile ', 'Roster profile: minimal or local-canary', 'minimal') + .option( + '--profile ', + `Roster profile: ${FLEET_PROFILES.join(', ')} (skips interactive wizard)`, + ) .option('--write', 'Write the roster to Mosaic home') .option('--force', 'Overwrite an existing roster when used with --write') - .action(async (opts: { profile: string; write?: boolean; force?: boolean }) => { + .action(async (opts: { profile?: string; write?: boolean; force?: boolean }) => { const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); - const profile = parseInitProfile(opts.profile); - const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`); + + let profile: FleetProfile; + if (opts.profile !== undefined) { + // Explicit --profile flag: validate and use it (non-interactive path). + profile = parseInitProfile(opts.profile); + } else { + // No --profile: use wizard when stdin is a TTY, else default to 'general'. + const isTTY = deps.isStdinTTY ?? process.stdin.isTTY ?? false; + if (isTTY) { + profile = await promptFleetProfile(); + } else { + process.stderr.write( + 'Note: stdin is not a TTY; defaulting to fleet profile "general". ' + + 'Use --profile to select a different preset.\n', + ); + profile = 'general'; + } + } + + const source = join(frameworkRoot, 'fleet', 'examples', resolvePresetFilename(profile)); const content = await readFile(source, 'utf8'); + if (!opts.write) { console.log(content.trimEnd()); return; } + const destination = commandOpts.roster ?? activePaths.rosterPath; if (!opts.force && (await canRead(destination))) { throw new Error( @@ -820,7 +849,23 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = } await mkdir(dirname(destination), { recursive: true }); await writeFile(destination, content); - console.log(`Wrote fleet roster: ${destination}`); + + // Validate: exactly one orchestrator required (R5) — friendly summary on success. + const written = await loadFleetRoster(destination); + const orchCount = countOrchestrators(written); + if (orchCount !== 1) { + process.stderr.write( + `Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`, + ); + console.log( + `Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`, + ); + } else { + const workerCount = written.agents.length - 1; + console.log( + `Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`, + ); + } }); cmd @@ -1668,11 +1713,96 @@ function splitCommand(command: string[]): [string, string[]] { return [bin, args]; } -function parseInitProfile(profile: string): 'minimal' | 'local-canary' { - if (profile === 'minimal' || profile === 'local-canary') { - return profile; +/** All supported fleet profile names. */ +export type FleetProfile = + | 'general' + | 'coding' + | 'research' + | 'hybrid' + | 'minimal' + | 'local-canary'; + +/** The list of all valid fleet profile names, for wizard menus and error messages. */ +export const FLEET_PROFILES: readonly FleetProfile[] = [ + 'general', + 'coding', + 'research', + 'hybrid', + 'minimal', + 'local-canary', +]; + +/** + * Maps a fleet profile name to its example YAML filename (without the path). + * Pure function — testable without I/O. + */ +export function resolvePresetFilename(profile: FleetProfile): string { + return `${profile}.yaml`; +} + +/** + * Validate and normalise a fleet profile name string. + * Throws with a clear message on unknown values. + */ +export function parseInitProfile(profile: string): FleetProfile { + if ((FLEET_PROFILES as readonly string[]).includes(profile)) { + return profile as FleetProfile; } - throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`); + throw new Error(`Unsupported fleet profile "${profile}". Use: ${FLEET_PROFILES.join(', ')}.`); +} + +/** + * Count orchestrator agents in a parsed roster. + * Returns the count; callers assert === 1. + */ +export function countOrchestrators(roster: FleetRoster): number { + return roster.agents.filter((a) => a.className === 'orchestrator').length; +} + +/** + * Prompt interactively for a fleet profile via stdin readline. + * AI-free: no LLM calls — pure readline menu. + * Resolves with the chosen profile string, or rejects on I/O error. + */ +function promptFleetProfile(): Promise { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const menu = [ + '', + 'Choose a fleet configuration type:', + ' 1) general — orchestrator + generalist worker', + ' 2) coding — orchestrator + coder0 + coder1 + reviewer', + ' 3) research — orchestrator + researcher0 + researcher1 + analyst', + ' 4) hybrid — orchestrator + coder0 + researcher0 + reviewer', + ' 5) minimal — single canary-pi agent (no orchestrator)', + ' 6) local-canary — legacy canary preset with lead + coder + reviewer', + '', + ].join('\n'); + process.stdout.write(menu); + rl.question('Enter number or name [1]: ', (answer) => { + rl.close(); + const trimmed = answer.trim(); + // Map numeric shortcut → name + const byNumber: Record = { + '1': 'general', + '2': 'coding', + '3': 'research', + '4': 'hybrid', + '5': 'minimal', + '6': 'local-canary', + '': 'general', // default on empty enter + }; + if (trimmed in byNumber) { + resolve(byNumber[trimmed]!); + return; + } + try { + resolve(parseInitProfile(trimmed)); + } catch (err) { + reject(err); + } + }); + }); } function writeCommandOutput(result: CommandResult): void {