/** * `mosaic fleet provision --profile ` — turn a declared SYSTEM TYPE (a * profile) into a concrete fleet roster (North Star H3). * * A profile (fleet/profiles/.yaml) is a DECLARATIVE mapping from a system * type to a persona roster + org topology (H2). This command MATERIALIZES that * declaration into the concrete `roster.yaml` shape the live fleet consumes — the * same shape `fleet.ts` parses (version/transport/tmux/defaults/runtimes/agents). * * DRY-RUN-FIRST + REVIEWABLE: with no --write it prints the roster it WOULD * generate plus a topology summary and writes nothing. --write persists it, and * REFUSES to clobber an existing roster.yaml without --force (protects operator * customizations). * * DRY: profile parsing/validation is reused wholesale from fleet-profiles.ts * (loadProfile/validateProfile) and persona resolution from fleet-personas.ts * (resolvePersona, override-aware). This module owns ONLY the profile→roster * generation policy, documented inline below so each default is reviewable. */ import { access, mkdir, writeFile } from 'node:fs/promises'; import { constants } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; import YAML from 'yaml'; import { loadProfile, validateProfile, type FleetProfile, type ProfileRosterEntry, defaultProfilesDir, defaultRolesDir, listPersonaClassesWithOverrides, } from './fleet-profiles.js'; import { defaultOverrideDir, extractClassesFromDir, resolvePersonaFrom, type PersonaLayer, } from './fleet-personas.js'; function defaultMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); } // --------------------------------------------------------------------------- // GENERATION RULES — each default below is intentionally simple and documented // so a reviewer can ratify or override the policy. See the PR body for the open // runtime-per-class question (RULE 4). // --------------------------------------------------------------------------- /** * RULE 4 — Runtime assignment policy (THE one open design question). * * Default: EVERY seat → `runtime: claude`. Claude runs every persona, so it is * the safe universal default and guarantees a structurally-valid, launchable * roster regardless of how the policy ultimately lands. We deliberately do NOT * hardcode pi / gpt-5.5 per class here. The live roster today runs coders on * pi + openai-codex/gpt-5.5:high — whether provisioning should encode a * class→runtime/model map (and WHERE: the profile schema vs a separate * runtime-policy file) is flagged in the PR body for ratification. * * Centralized so changing the policy is a ONE-edit change. If a future profile * entry (or persona) declares a runtime/model preference, honor it here; until * then everything defaults to claude. */ export const DEFAULT_RUNTIME = 'claude'; /** Result of applying the runtime policy to one seat. */ interface RuntimeChoice { runtime: string; modelHint?: string; } /** * The single centralized runtime-policy function. Today it returns the universal * `claude` default for every seat. To encode a class→runtime/model map later, * edit ONLY this function (and/or extend the profile schema and read it here). */ export function resolveSeatRuntime( _klass: string, _isFloor: boolean, _isLead: boolean, ): RuntimeChoice { return { runtime: DEFAULT_RUNTIME }; } /** One generated seat, fully resolved for emission + topology display. */ export interface GeneratedSeat { name: string; className: string; runtime: string; modelHint?: string; persistentPersona?: boolean; resetBetweenTasks?: boolean; /** Topology edge from the profile (NOT emitted to roster.yaml — see RULE 6). */ reportsTo?: string; /** Which persona layer the class resolved from (baseline/override). */ personaLayer: PersonaLayer; } export interface GenerateRosterResult { seats: GeneratedSeat[]; /** The roster.yaml text (parser-valid, drop-in). */ yaml: string; } export interface ProvisionOptions { /** Materialize the entire profile roster (multiplicity expanded). */ full?: boolean; mosaicHome?: string; /** Test overrides — mirror fleet-profiles LoadProfilesOptions. */ profilesDir?: string; rolesDir?: string; overrideDir?: string; } function resolveDirs(opts: ProvisionOptions): { mosaicHome: string; profilesDir: string; rolesDir: string; overrideDir: string; } { const mosaicHome = opts.mosaicHome ?? defaultMosaicHome(); return { mosaicHome, profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome), rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome), overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome), }; } /** * RULE 1 — Seat naming. multiplicity 1 → name = class (e.g. `planner`). * multiplicity N>1 → `0`,`1`,… (e.g. `code` ×2 → code0/code1). * Names are deterministic, following profile roster order. */ function seatNames(entry: ProfileRosterEntry): string[] { if (entry.multiplicity <= 1) return [entry.class]; return Array.from({ length: entry.multiplicity }, (_, i) => `${entry.class}${i}`); } /** * Generate the concrete seats + roster.yaml for a validated profile. * * Seat selection: * --full → the ENTIRE profile roster, multiplicity expanded. * default → ONLY the `floor` classes (the always-staffed minimum), matching * the profile note "two-agent floor always staffed; every other seat * added on demand." * * Per-seat flags: * RULE 2 persistent_persona: true for floor classes AND the lead; else omitted. * RULE 3 reset_between_tasks: true for non-floor, non-lead execution seats; * floor/lead omit it (mirrors today's coders resetting while the * orchestrator/enhancer do not). * RULE 4 runtime: via resolveSeatRuntime (defaults claude). * RULE 6 reports_to: tracked on the seat for the topology summary but NOT * emitted to roster.yaml — the fleet.ts parser rejects unknown agent * keys, so writing reports_to would break round-trip. Confirmed against * normalizeAgent's allow-list in fleet.ts. * * Persona resolution: every emitted class is resolved override-aware via * resolvePersona so we can (a) record the layer for the summary and (b) refuse * to emit a roster that references a nonexistent persona. */ export async function generateRoster( profile: FleetProfile, opts: ProvisionOptions, ): Promise { const { rolesDir, overrideDir } = resolveDirs(opts); const floor = new Set(profile.floor); const lead = profile.lead; const selected: ProfileRosterEntry[] = opts.full ? profile.roster : profile.roster.filter((e) => floor.has(e.class)); // Scan the persona directories ONCE, then resolve every roster entry against // the in-memory maps. resolvePersona() would otherwise re-scan both dirs per // entry — O(entries × files) redundant reads that push --full provisioning // past the test timeout on slow/contended filesystems. const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(overrideDir), ]); const seats: GeneratedSeat[] = []; for (const entry of selected) { const isFloor = floor.has(entry.class); const isLead = entry.class === lead; const resolved = await resolvePersonaFrom(entry.class, { rolesDir, overrideDir, base, over }); if (!resolved) { // Defensive: validateProfile already guards this, but a class can resolve // for membership yet have no readable file. Fail loudly rather than emit a // roster pointing at a persona we cannot load. throw new Error( `Cannot provision: roster class "${entry.class}" does not resolve to a readable persona.`, ); } const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead); for (const name of seatNames(entry)) { const seat: GeneratedSeat = { name, className: entry.class, runtime: runtimeChoice.runtime, personaLayer: resolved.layer, }; if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint; if (isFloor || isLead) seat.persistentPersona = true; if (!isFloor && !isLead) seat.resetBetweenTasks = true; if (entry.reportsTo) seat.reportsTo = entry.reportsTo; seats.push(seat); } } if (seats.length === 0) { throw new Error( `Profile "${profile.id}" produced no seats. ` + (opts.full ? 'Its roster is empty.' : 'No floor seats are defined — try --full.'), ); } return { seats, yaml: renderRosterYaml(seats) }; } /** * RULE 5 — Standard roster scaffolding. We emit the same generic, non-personal * scaffold the committed example presets use (socket_name: mosaic-fleet, * holder_session: _holder, working_directory: ~, claude + pi runtimes) so the * output is a drop-in valid roster. No operator-personal data is copied. * * Built via the `yaml` lib (same serializer the parser uses) so the result * round-trips. reports_to is intentionally NOT included on agents (RULE 6). */ function renderRosterYaml(seats: GeneratedSeat[]): string { const agents = seats.map((s) => { const a: Record = { name: s.name, runtime: s.runtime, class: s.className, }; if (s.persistentPersona) a['persistent_persona'] = true; if (s.modelHint) a['model_hint'] = s.modelHint; if (s.resetBetweenTasks) a['reset_between_tasks'] = true; return a; }); const doc = { version: 1, transport: 'tmux', tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' }, defaults: { working_directory: '~' }, runtimes: { claude: { reset_command: '/clear' }, pi: { reset_command: '/new' }, }, agents, }; return YAML.stringify(doc); } // --------------------------------------------------------------------------- // Validation — reuse fleet-profiles.validateProfile (override-aware classes) and // name any unresolved class clearly. Never generate a roster referencing a // nonexistent persona. // --------------------------------------------------------------------------- /** * Validate the profile against the override-aware persona library. Throws with a * clear, class-naming message if any referenced class is unresolved. */ export async function validateProfileForProvision( profile: FleetProfile, opts: ProvisionOptions, ): Promise { const { rolesDir, overrideDir } = resolveDirs(opts); const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir); const problems = validateProfile(profile, validClasses); if (problems.length > 0) { throw new Error( `Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`, ); } } // --------------------------------------------------------------------------- // Topology summary (printed in dry-run and after write). // --------------------------------------------------------------------------- function formatTopologySummary(seats: GeneratedSeat[]): string { const lines: string[] = []; lines.push(`Topology (${seats.length} seat(s)):`); for (const s of seats) { const reports = s.reportsTo ? `reports_to=${s.reportsTo}` : 'reports_to=- (lead)'; lines.push( ` - ${s.name}\tclass=${s.className}\truntime=${s.runtime}\t${reports}\tpersona=${s.personaLayer}`, ); } return lines.join('\n'); } // --------------------------------------------------------------------------- // CLI wiring — mirror registerFleetProfileCommand / registerFleetPersonaCommand. // --------------------------------------------------------------------------- export interface ProvisionRunResult { yaml: string; summary: string; wrote?: string; } /** * Core provision flow shared by the CLI: load + validate the profile, generate * the roster, optionally write it. Returns the artifacts for printing/testing. */ export async function runProvision( profileId: string, opts: ProvisionOptions & { write?: boolean; force?: boolean }, ): Promise { const dirs = resolveDirs(opts); const profile = await loadProfile(profileId, { mosaicHome: dirs.mosaicHome, profilesDir: dirs.profilesDir, rolesDir: dirs.rolesDir, overrideDir: dirs.overrideDir, }); // loadProfile already validates, but re-run with our explicit error wording so // an unresolved class is named clearly even if invoked directly. await validateProfileForProvision(profile, opts); const { seats, yaml } = await generateRoster(profile, opts); const summary = formatTopologySummary(seats); if (!opts.write) { return { yaml, summary }; } const rosterPath = join(dirs.mosaicHome, 'fleet', 'roster.yaml'); if (!opts.force) { let exists = false; try { await access(rosterPath, constants.F_OK); exists = true; } catch { exists = false; } if (exists) { throw new Error( `Refusing to overwrite existing roster: ${rosterPath}. ` + `Pass --force to overwrite, or edit it by hand.`, ); } } await mkdir(dirname(rosterPath), { recursive: true }); await writeFile(rosterPath, yaml, 'utf8'); return { yaml, summary, wrote: rosterPath }; } /** * Register `provision` under an existing `fleet` command. `mosaicHomeFor` * resolves the active --mosaic-home (parent flag) at call time, exactly like the * profile/persona/backlog subcommands. */ export function registerFleetProvisionCommand( fleetCmd: Command, mosaicHomeFor: () => string, ): Command { const provisionCmd = fleetCmd .command('provision') .description('Materialize a roster.yaml from a system-type profile (H3). DRY-RUN by default.') .requiredOption('--profile ', 'System-type profile id to provision') .option('--full', 'Materialize the entire profile roster (default: floor seats only)') .option('--write', 'Write the generated roster to /fleet/roster.yaml') .option('--force', 'Overwrite an existing roster.yaml (requires --write)') .action(async (opts: { profile: string; full?: boolean; write?: boolean; force?: boolean }) => { try { const result = await runProvision(opts.profile, { mosaicHome: mosaicHomeFor(), full: opts.full, write: opts.write, force: opts.force, }); if (result.wrote) { console.log(`Wrote roster: ${result.wrote}`); console.log(''); console.log(result.summary); } else { // DRY RUN: print the roster it WOULD generate + topology, write nothing. console.log('# DRY RUN — no files written. Re-run with --write to persist.'); console.log(result.yaml.trimEnd()); console.log(''); console.log(result.summary); } } catch (err) { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); process.exitCode = 1; } }); return provisionCmd; }