feat(fleet): add shared role semantics (#768)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #768.
This commit is contained in:
2026-07-15 00:53:47 +00:00
parent eb4e14ae5c
commit a5e8e55401
19 changed files with 1650 additions and 177 deletions

View File

@@ -1,4 +1,15 @@
import YAML from 'yaml';
import {
authorityForCanonicalClass,
canonicalizeRoleClass,
defaultOverrideDir,
defaultRolesDir,
extractClassesFromDir,
resolvePersonaFrom,
type PersonaDirs,
type PersonaResolution,
type RoleAuthority,
} from '../commands/fleet-personas.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -58,6 +69,80 @@ export interface FleetRosterV2 {
readonly agents: readonly FleetRosterV2Agent[];
}
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
readonly requestedClass: string;
readonly canonicalClass: string;
readonly canonicalToolPolicy: string;
readonly persona: PersonaResolution;
readonly authority: RoleAuthority;
}
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
}
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
'merge-gate',
'validator',
'orchestrator',
'team-leader',
'interaction',
]);
/**
* Validate filesystem-backed roster semantics after synchronous structural parsing.
* Directory scans are batched once and every class must resolve to readable content.
*/
export async function validateRosterV2Semantics(
roster: FleetRosterV2,
opts: PersonaDirs = {},
): Promise<SemanticallyValidatedRosterV2> {
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const agents: SemanticallyValidatedRosterV2Agent[] = [];
for (const agent of roster.agents) {
const requestedClass = agent.className;
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
const persona = await resolvePersonaFrom(requestedClass, {
rolesDir,
overrideDir,
base,
over,
});
if (!persona || persona.content.trim() === '') {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
);
}
if (
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
canonicalToolPolicy !== canonicalClass
) {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
);
}
agents.push(
Object.freeze({
...agent,
requestedClass,
canonicalClass,
canonicalToolPolicy,
persona,
authority: authorityForCanonicalClass(canonicalClass),
}),
);
}
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
}
export class RosterV2ValidationError extends Error {
constructor(message: string) {
super(message);