import YAML from 'yaml'; export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const; export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number]; export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number]; export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number]; export type RosterV2InputFormat = 'json' | 'yaml'; export interface FleetRosterV2Tmux { readonly socketName: string; readonly holderSession: string; } export interface FleetRosterV2Defaults { readonly workingDirectory: string; readonly runtime: RosterV2RuntimeName; } export interface FleetRosterV2Runtime { readonly resetCommand: string; } export interface FleetRosterV2Lifecycle { readonly enabled: boolean; readonly desiredState: RosterV2DesiredState; } export interface FleetRosterV2Launch { readonly yolo: boolean; } export interface FleetRosterV2Agent { readonly name: string; readonly alias: string; readonly className: string; readonly runtime: RosterV2RuntimeName; readonly provider: string; readonly model: string; readonly reasoning: RosterV2ReasoningLevel; readonly toolPolicy: string; readonly workingDirectory: string; readonly persistentPersona: boolean; readonly resetBetweenTasks: boolean; readonly lifecycle: FleetRosterV2Lifecycle; readonly launch: FleetRosterV2Launch; } export interface FleetRosterV2 { readonly version: 2; readonly generation: number; readonly transport: 'tmux'; readonly tmux: FleetRosterV2Tmux; readonly defaults: FleetRosterV2Defaults; readonly runtimes: Readonly>; readonly agents: readonly FleetRosterV2Agent[]; } export class RosterV2ValidationError extends Error { constructor(message: string) { super(message); this.name = 'RosterV2ValidationError'; } } type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema }; /** * Executable v2 structural contract. The checked-in documentation schema is * structurally compared to this value in roster-v2.spec.ts. */ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json', title: 'Mosaic local tmux fleet roster v2', type: 'object', additionalProperties: false, required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'], properties: { version: { const: 2 }, generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER }, transport: { const: 'tmux' }, tmux: { type: 'object', additionalProperties: false, required: ['socket_name', 'holder_session'], properties: { socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, }, }, defaults: { type: 'object', additionalProperties: false, required: ['working_directory', 'runtime'], properties: { working_directory: { type: 'string', minLength: 1 }, runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, }, }, runtimes: { type: 'object', minProperties: 1, propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, additionalProperties: { type: 'object', additionalProperties: false, required: ['reset_command'], properties: { reset_command: { type: 'string', minLength: 1 } }, }, }, agents: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: false, required: [ 'name', 'alias', 'class', 'runtime', 'provider', 'model', 'reasoning', 'tool_policy', 'working_directory', 'persistent_persona', 'reset_between_tasks', 'lifecycle', 'launch', ], properties: { name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' }, alias: { type: 'string', minLength: 1 }, class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' }, runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, provider: { type: 'string', minLength: 1 }, model: { type: 'string', minLength: 1 }, reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] }, tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' }, working_directory: { type: 'string', minLength: 1 }, persistent_persona: { type: 'boolean' }, reset_between_tasks: { type: 'boolean' }, lifecycle: { type: 'object', additionalProperties: false, required: ['enabled', 'desired_state'], properties: { enabled: { type: 'boolean' }, desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] }, }, }, launch: { type: 'object', additionalProperties: false, required: ['yolo'], properties: { yolo: { type: 'boolean' } }, }, }, }, }, }, }; const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents']; const TMUX_KEYS = ['socket_name', 'holder_session']; const DEFAULT_KEYS = ['working_directory', 'runtime']; const RUNTIME_KEYS = ['reset_command']; const AGENT_KEYS = [ 'name', 'alias', 'class', 'runtime', 'provider', 'model', 'reasoning', 'tool_policy', 'working_directory', 'persistent_persona', 'reset_between_tasks', 'lifecycle', 'launch', ]; const LIFECYCLE_KEYS = ['enabled', 'desired_state']; const LAUNCH_KEYS = ['yolo']; const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/; const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/; /** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */ export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 { const parsed = parseSource(source, format); return normalizeRosterV2(parsed); } /** Renders canonical snake_case YAML with sorted runtime and agent entries. */ export function renderRosterV2Yaml(roster: FleetRosterV2): string { const normalized = normalizeRosterV2(toSourceShape(roster)); return YAML.stringify(toSourceShape(normalized)); } function parseSource(source: string, format?: RosterV2InputFormat): unknown { const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml'); try { if (resolvedFormat === 'json') return JSON.parse(source) as unknown; return YAML.parse(source) as unknown; } catch (error: unknown) { const detail = error instanceof Error ? error.message : String(error); throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`); } } export function normalizeRosterV2(raw: unknown): FleetRosterV2 { const root = requiredObject(raw, 'Roster v2'); assertKnownKeys(root, 'Roster v2', ROOT_KEYS); if (root.version === 1) { throw new RosterV2ValidationError( 'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.', ); } if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.'); const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation'); const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const); const tmux = normalizeTmux(root.tmux); const defaults = normalizeDefaults(root.defaults); const runtimes = normalizeRuntimes(root.runtimes); const agents = normalizeAgents(root.agents, runtimes); if (!runtimes[defaults.runtime]) { throw new RosterV2ValidationError( `Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`, ); } return { version: 2, generation, transport, tmux, defaults, runtimes, agents }; } function normalizeTmux(value: unknown): FleetRosterV2Tmux { const raw = requiredObject(value, 'Roster v2 tmux'); assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS); return { socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'), holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'), }; } function normalizeDefaults(value: unknown): FleetRosterV2Defaults { const raw = requiredObject(value, 'Roster v2 defaults'); assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS); return { workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'), runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'), }; } function normalizeRuntimes(value: unknown): Readonly> { const raw = requiredObject(value, 'Roster v2 runtimes'); const names = Object.keys(raw); if (names.length === 0) throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.'); const result: Record = {}; for (const name of names.sort()) { const runtime = requiredRuntime(name, 'Roster v2 runtime name'); const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`); assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS); result[runtime] = { resetCommand: requiredString( config.reset_command, `Roster v2 runtime "${runtime}" reset_command`, ), }; } return result; } function normalizeAgents( value: unknown, runtimes: Readonly>, ): readonly FleetRosterV2Agent[] { if (!Array.isArray(value) || value.length === 0) { throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.'); } const seen = new Set(); const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => { const raw = requiredObject(candidate, `Roster v2 agents[${index}]`); assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS); const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`); if (seen.has(name)) throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`); seen.add(name); const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`); if (!runtimes[runtime]) { throw new RosterV2ValidationError( `Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`, ); } return { name, alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`), className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`), runtime, provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`), model: requiredString(raw.model, `Roster v2 agent "${name}" model`), reasoning: requiredEnum( raw.reasoning, `Roster v2 agent "${name}" reasoning`, ROSTER_V2_REASONING_LEVELS, ), toolPolicy: requiredPolicyIdentifier( raw.tool_policy, `Roster v2 agent "${name}" tool_policy`, ), workingDirectory: requiredString( raw.working_directory, `Roster v2 agent "${name}" working_directory`, ), persistentPersona: requiredBoolean( raw.persistent_persona, `Roster v2 agent "${name}" persistent_persona`, ), resetBetweenTasks: requiredBoolean( raw.reset_between_tasks, `Roster v2 agent "${name}" reset_between_tasks`, ), lifecycle: normalizeLifecycle(raw.lifecycle, name), launch: normalizeLaunch(raw.launch, name), }; }); return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => left.name.localeCompare(right.name), ); } function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle { const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`); assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS); return { enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`), desiredState: requiredEnum( raw.desired_state, `Roster v2 agent "${agentName}" lifecycle desired_state`, ROSTER_V2_DESIRED_STATES, ), }; } function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch { const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`); assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS); return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) }; } function requiredObject(value: unknown, label: string): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new RosterV2ValidationError(`${label} must be an object.`); } return value as Record; } function assertKnownKeys( value: Record, label: string, allowedKeys: readonly string[], ): void { const allowed = new Set(allowedKeys); const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key)); if (unknown.length > 0) { throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`); } } function requiredString(value: unknown, label: string): string { if (typeof value !== 'string' || value.trim() === '') { throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`); } return value.trim(); } function requiredIdentifier(value: unknown, label: string): string { const result = requiredString(value, label); if (!IDENTIFIER.test(result)) { throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`); } return result; } function requiredTmuxIdentifier(value: unknown, label: string): string { const result = requiredString(value, label); if (!TMUX_IDENTIFIER.test(result)) throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`); return result; } function requiredPolicyIdentifier(value: unknown, label: string): string { const result = requiredString(value, label); if (!POLICY_IDENTIFIER.test(result)) throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`); return result; } function requiredPositiveInteger(value: unknown, label: string): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { throw new RosterV2ValidationError(`${label} must be a positive integer.`); } return value; } function requiredBoolean(value: unknown, label: string): boolean { if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`); return value; } function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName { return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES); } function requiredEnum(value: unknown, label: string, allowed: readonly T[]): T { if (typeof value !== 'string' || !allowed.includes(value as T)) { throw new RosterV2ValidationError( `${label} must be one of the supported values: ${allowed.join(', ')}.`, ); } return value as T; } function toSourceShape(roster: FleetRosterV2): Record { return { version: roster.version, generation: roster.generation, transport: roster.transport, tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession }, defaults: { working_directory: roster.defaults.workingDirectory, runtime: roster.defaults.runtime, }, runtimes: Object.fromEntries( Object.entries(roster.runtimes) .sort(([left], [right]): number => left.localeCompare(right)) .map(([name, runtime]): [string, unknown] => [ name, { reset_command: runtime.resetCommand }, ]), ), agents: [...roster.agents] .sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => left.name.localeCompare(right.name), ) .map( (agent: FleetRosterV2Agent): Record => ({ name: agent.name, alias: agent.alias, class: agent.className, runtime: agent.runtime, provider: agent.provider, model: agent.model, reasoning: agent.reasoning, tool_policy: agent.toolPolicy, working_directory: agent.workingDirectory, persistent_persona: agent.persistentPersona, reset_between_tasks: agent.resetBetweenTasks, lifecycle: { enabled: agent.lifecycle.enabled, desired_state: agent.lifecycle.desiredState, }, launch: { yolo: agent.launch.yolo }, }), ), }; }