feat(fleet): update-surviving persona customization (H4) (#661)
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 #661.
This commit is contained in:
2026-06-24 16:21:01 +00:00
parent a738ac1410
commit 84d2757817
6 changed files with 688 additions and 51 deletions

View File

@@ -25,6 +25,11 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
defaultOverrideDir,
extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses,
} from './fleet-personas.js';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -57,57 +62,29 @@ export interface FleetProfile {
}
/**
* Extract the set of valid persona classes from the role library.
* Extract the set of valid persona classes from a single baseline role dir.
*
* Sources (unioned — see module doc for why each is needed):
* 1. inline `` `class: X` `` markers in every roles/*.md (the primary signal;
* a marker may wrap across a newline, e.g. `` `class:\n support-agent` ``).
* 2. persona-name cells from the LIBRARY.md index tables.
* 3. the role filename stems (roles/<class>.md), covering personas whose file
* documents an alias instead of carrying its own marker (planner ->
* orchestrator, decomposition).
*
* Returns a Set so membership checks in the validator are O(1). Missing dir or
* unreadable files degrade gracefully to whatever was found (an empty set makes
* the validator reject every class, which surfaces a clear error).
* Thin wrapper over the shared {@link extractClassesFromDir} in fleet-personas.ts
* — the single source of truth for "what classes exist" (DRY). Kept as a
* baseline-only, positional-`rolesDir` helper for backward compatibility; the
* override-aware union (baseline ⊕ roles.local) used by roster validation is
* {@link listPersonaClassesWithOverrides} below.
*/
export async function listPersonaClasses(rolesDir = defaultRolesDir()): Promise<Set<string>> {
const classes = new Set<string>();
let entries: string[];
try {
entries = await readdir(rolesDir);
} catch {
return classes;
}
// Match `class: X` even when the value wrapped onto the next line. Allow
// surrounding backtick(s); the value is a single kebab-case token.
const inlineMarker = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
// LIBRARY.md persona rows: first table cell is the persona name.
const libraryRow = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
return (await extractClassesFromDir(rolesDir)).classes;
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
let text: string;
try {
text = await readFile(join(rolesDir, entry), 'utf8');
} catch {
continue;
}
if (entry === 'LIBRARY.md') {
for (const m of text.matchAll(libraryRow)) {
const name = m[1];
// Skip the markdown table divider / header artifacts.
if (name && name !== 'persona') classes.add(name);
}
continue;
}
// Role contract: the filename stem is itself a valid class (covers alias docs).
classes.add(basename(entry, '.md'));
for (const m of text.matchAll(inlineMarker)) {
if (m[1]) classes.add(m[1]);
}
}
return classes;
/**
* Override-aware valid-class set: baseline roles/ ⊕ override roles.local/. A
* profile may legitimately reference a user-customized OR user-ADDED persona, so
* roster validation resolves against this union (H4). Delegates to the shared
* fleet-personas resolver.
*/
export async function listPersonaClassesWithOverrides(
rolesDir: string,
overrideDir: string,
): Promise<Set<string>> {
return listOverrideAwarePersonaClasses({ rolesDir, overrideDir });
}
function asString(value: unknown, ctx: string): string {
@@ -227,14 +204,21 @@ export interface LoadProfilesOptions {
profilesDir?: string;
/** Override the roles dir (tests). Defaults to <mosaicHome>/fleet/roles. */
rolesDir?: string;
/** Persona override dir (tests). Defaults to <mosaicHome>/fleet/roles.local. */
overrideDir?: string;
mosaicHome?: string;
}
function resolveDirs(opts: LoadProfilesOptions): { profilesDir: string; rolesDir: string } {
function resolveDirs(opts: LoadProfilesOptions): {
profilesDir: string;
rolesDir: string;
overrideDir: string;
} {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return {
profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome),
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
};
}
@@ -244,7 +228,7 @@ function resolveDirs(opts: LoadProfilesOptions): { profilesDir: string; rolesDir
* Profiles are returned sorted by id for deterministic output.
*/
export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<FleetProfile[]> {
const { profilesDir, rolesDir } = resolveDirs(opts);
const { profilesDir, rolesDir, overrideDir } = resolveDirs(opts);
let files: string[];
try {
files = (await readdir(profilesDir)).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
@@ -253,7 +237,10 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
}
files.sort();
const validClasses = await listPersonaClasses(rolesDir);
// Override-aware: a profile may reference a user-customized or user-ADDED
// persona living in the roles.local/ layer (H4), so validate against the
// baseline ⊕ override union, not the baseline alone.
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
const profiles: FleetProfile[] = [];
const seen = new Map<string, string>();