407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
/**
|
||
* `mosaic fleet provision --profile <id>` — turn a declared SYSTEM TYPE (a
|
||
* profile) into a concrete fleet roster (North Star H3).
|
||
*
|
||
* A profile (fleet/profiles/<id>.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 → `<class>0`,`<class>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<GenerateRosterResult> {
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
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<ProvisionRunResult> {
|
||
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 <id>', 'System-type profile id to provision')
|
||
.option('--full', 'Materialize the entire profile roster (default: floor seats only)')
|
||
.option('--write', 'Write the generated roster to <mosaicHome>/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;
|
||
}
|