feat(fleet): update-surviving persona customization (H4) (#661)
This commit was merged in pull request #661.
This commit is contained in:
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user