diff --git a/packages/mosaic/src/commands/fleet-provision.spec.ts b/packages/mosaic/src/commands/fleet-provision.spec.ts new file mode 100644 index 0000000..691814e --- /dev/null +++ b/packages/mosaic/src/commands/fleet-provision.spec.ts @@ -0,0 +1,262 @@ +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadFleetRoster } from './fleet.js'; +import { generateRoster, runProvision } from './fleet-provision.js'; +import { loadProfile } from './fleet-profiles.js'; + +// The real, committed library: packages/mosaic/src/commands -> framework/fleet. +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); +const rolesDir = join(frameworkFleet, 'roles'); +const profilesDir = join(frameworkFleet, 'profiles'); + +/** A fresh temp mosaicHome whose fleet/ dir is empty (for write-path tests). */ +async function freshMosaicHome(): Promise { + const home = await mkdtemp(join(tmpdir(), 'mosaic-provision-')); + await mkdir(join(home, 'fleet'), { recursive: true }); + return home; +} + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe('provision software-delivery (floor, default)', () => { + it('materializes only the floor seats with correct flags + valid scaffold', async () => { + const profile = await loadProfile('software-delivery', { profilesDir, rolesDir }); + const { seats, yaml } = await generateRoster(profile, { profilesDir, rolesDir }); + + // Floor is orchestrator + enhancer. + expect(seats.map((s) => s.name).sort()).toEqual(['enhancer', 'orchestrator']); + const orch = seats.find((s) => s.name === 'orchestrator'); + const enh = seats.find((s) => s.name === 'enhancer'); + // RULE 2: floor + lead get persistent_persona. + expect(orch?.persistentPersona).toBe(true); + expect(enh?.persistentPersona).toBe(true); + // RULE 3: floor/lead do NOT reset between tasks. + expect(orch?.resetBetweenTasks).toBeUndefined(); + expect(enh?.resetBetweenTasks).toBeUndefined(); + // RULE 4: default runtime claude. + expect(orch?.runtime).toBe('claude'); + + // Scaffold round-trips through the real parser. + expect(yaml).toContain('version: 1'); + expect(yaml).toContain('transport: tmux'); + expect(yaml).toContain('socket_name: mosaic-fleet'); + }); +}); + +describe('provision --full', () => { + it('expands the entire roster, including multiplicity, deterministically', async () => { + const profile = await loadProfile('software-delivery', { profilesDir, rolesDir }); + const { seats } = await generateRoster(profile, { full: true, profilesDir, rolesDir }); + + const names = seats.map((s) => s.name); + // code multiplicity 2 -> code0/code1 (RULE 1). + expect(names).toContain('code0'); + expect(names).toContain('code1'); + expect(names).not.toContain('code'); + // Singleton seats keep the bare class name. + expect(names).toContain('planner'); + expect(names).toContain('merge-gate'); + + // Deterministic ordering: profile roster order, multiplicity expanded inline. + const codeIdx0 = names.indexOf('code0'); + expect(names[codeIdx0 + 1]).toBe('code1'); + + // RULE 3: a non-floor, non-lead execution seat resets between tasks. + const code0 = seats.find((s) => s.name === 'code0'); + expect(code0?.resetBetweenTasks).toBe(true); + expect(code0?.persistentPersona).toBeUndefined(); + + // Seat count == sum of multiplicities. + const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0); + expect(seats.length).toBe(expected); + }); +}); + +describe('generated roster round-trips through the real parser', () => { + it('feeds generated YAML back through loadFleetRoster (key correctness proof)', async () => { + const home = await freshMosaicHome(); + try { + const result = await runProvision('software-delivery', { + mosaicHome: home, + profilesDir, + rolesDir, + full: true, + write: true, + }); + expect(result.wrote).toBe(join(home, 'fleet', 'roster.yaml')); + + const parsed = await loadFleetRoster(result.wrote!); + // It parses with no error and carries every seat. + const profile = await loadProfile('software-delivery', { profilesDir, rolesDir }); + const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0); + expect(parsed.agents.length).toBe(expected); + // Classes survive the round-trip. + expect(parsed.agents.some((a) => a.className === 'orchestrator')).toBe(true); + expect(parsed.agents.filter((a) => a.className === 'code').length).toBe(2); + // reports_to must NOT have been emitted (parser rejects unknown keys). + expect(result.yaml).not.toContain('reports_to'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); + +describe('override-aware persona validation', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mosaic-provision-ov-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('resolves a user-added (roles.local-only) persona without a false unresolved error', async () => { + const overrideDir = join(dir, 'roles.local'); + const customProfilesDir = join(dir, 'profiles'); + await mkdir(overrideDir, { recursive: true }); + await mkdir(customProfilesDir, { recursive: true }); + // A brand-new class that exists ONLY in roles.local. + await writeFile( + join(overrideDir, 'widget-maker.md'), + '# widget-maker\n\nThe widget-maker persona (`class: widget-maker`).\n', + ); + await writeFile( + join(customProfilesDir, 'custom.yaml'), + [ + 'id: custom', + 'title: Custom', + 'description: a custom system', + 'lead: widget-maker', + 'floor: [widget-maker]', + 'roster:', + ' - class: widget-maker', + '', + ].join('\n'), + ); + + const result = await runProvision('custom', { + mosaicHome: dir, + profilesDir: customProfilesDir, + // Point baseline rolesDir at a missing dir so resolution depends on override. + rolesDir: join(dir, 'no-baseline'), + overrideDir, + }); + expect(result.yaml).toContain('class: widget-maker'); + // It resolved from the override layer. + // (generateRoster records personaLayer; the seat is present.) + expect(result.summary).toContain('persona=override'); + }); + + it('FAILS with a clear message when a profile references a bogus class', async () => { + const customProfilesDir = join(dir, 'profiles'); + await mkdir(customProfilesDir, { recursive: true }); + await writeFile( + join(customProfilesDir, 'bogus.yaml'), + [ + 'id: bogus', + 'title: Bogus', + 'description: bad system', + 'lead: orchestrator', + 'floor: [orchestrator]', + 'roster:', + ' - class: orchestrator', + ' - class: not-a-real-persona-xyz', + ' reports_to: orchestrator', + '', + ].join('\n'), + ); + await expect( + runProvision('bogus', { + mosaicHome: dir, + profilesDir: customProfilesDir, + rolesDir, + overrideDir: join(dir, 'roles.local'), + }), + ).rejects.toThrow(/not-a-real-persona-xyz|not a known persona class/); + }); +}); + +describe('--write protection', () => { + it('refuses to clobber an existing roster.yaml without --force', async () => { + const home = await freshMosaicHome(); + try { + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await writeFile(rosterPath, 'version: 1\n# operator customizations\n'); + await expect( + runProvision('software-delivery', { mosaicHome: home, profilesDir, rolesDir, write: true }), + ).rejects.toThrow(/Refusing to overwrite/); + // The original file is untouched. + const { readFile } = await import('node:fs/promises'); + expect(await readFile(rosterPath, 'utf8')).toContain('operator customizations'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('--write --force overwrites an existing roster', async () => { + const home = await freshMosaicHome(); + try { + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await writeFile(rosterPath, 'version: 1\n# old\n'); + const result = await runProvision('software-delivery', { + mosaicHome: home, + profilesDir, + rolesDir, + write: true, + force: true, + }); + expect(result.wrote).toBe(rosterPath); + const parsed = await loadFleetRoster(rosterPath); + expect(parsed.agents.length).toBeGreaterThan(0); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('--write to a fresh mosaicHome creates the roster file', async () => { + const home = await freshMosaicHome(); + try { + const result = await runProvision('software-delivery', { + mosaicHome: home, + profilesDir, + rolesDir, + write: true, + }); + expect(await fileExists(result.wrote!)).toBe(true); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('dry run (no --write) writes nothing', async () => { + const home = await freshMosaicHome(); + try { + const result = await runProvision('software-delivery', { + mosaicHome: home, + profilesDir, + rolesDir, + }); + expect(result.wrote).toBeUndefined(); + expect(await fileExists(join(home, 'fleet', 'roster.yaml'))).toBe(false); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/mosaic/src/commands/fleet-provision.ts b/packages/mosaic/src/commands/fleet-provision.ts new file mode 100644 index 0000000..4c058e0 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-provision.ts @@ -0,0 +1,392 @@ +/** + * `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, resolvePersona, 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)); + + const seats: GeneratedSeat[] = []; + for (const entry of selected) { + const isFloor = floor.has(entry.class); + const isLead = entry.class === lead; + + const resolved = await resolvePersona(entry.class, { rolesDir, overrideDir }); + 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; +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index e6d6fdf..b1bf5ea 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -84,6 +84,7 @@ describe('registerFleetCommand', () => { 'install-systemd', 'persona', 'profile', + 'provision', 'ps', 'remove', 'restart', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 8aeb492..3149114 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -11,6 +11,7 @@ import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; import { registerFleetBacklogCommand } from './fleet-backlog.js'; import { registerFleetPersonaCommand } from './fleet-personas.js'; import { registerFleetProfileCommand } from './fleet-profiles.js'; +import { registerFleetProvisionCommand } from './fleet-provision.js'; /** * A function that spawns a command with inherited stdio (TTY passthrough). @@ -1720,6 +1721,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // --mosaic-home flag. registerFleetPersonaCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + // Provisioning (H3): materialize a concrete roster.yaml from a system-type + // profile. DRY-RUN by default; --write persists under the same --mosaic-home. + registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + return cmd; }