feat(fleet): inject persona contract at launch (A3b) (#664)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed

This commit was merged in pull request #664.
This commit is contained in:
2026-06-24 17:06:51 +00:00
parent 6c84ccd0b1
commit 28cfecda94
5 changed files with 377 additions and 28 deletions

View File

@@ -25,6 +25,7 @@
* can reference a customized or user-added persona.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
@@ -88,13 +89,12 @@ export interface DirClasses {
* classes still appear in `classes` for membership checks.
*/
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const classes = new Set<string>();
const byClass = new Map<string, PersonaFile>();
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return { classes, byClass };
return acc;
}
for (const entry of entries) {
@@ -105,36 +105,75 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
} catch {
continue;
}
if (entry === 'LIBRARY.md') {
for (const m of text.matchAll(LIBRARY_ROW)) {
const name = m[1];
if (name && name !== 'persona') classes.add(name);
}
accumulateEntry(acc, dir, entry, text);
}
return acc;
}
/**
* Synchronous twin of {@link extractClassesFromDir}. Identical extraction
* semantics (same markers, same union of marker/filename/LIBRARY sources) on
* sync fs, for the synchronous launch-time prompt path (composeContract) which
* cannot await. Missing dir / unreadable files degrade gracefully.
*/
export function extractClassesFromDirSync(dir: string): DirClasses {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return acc;
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
let text: string;
try {
text = readFileSync(join(dir, entry), 'utf8');
} catch {
continue;
}
// The filename stem is itself a valid class (covers marker-less alias docs).
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
let markedClassForFile: string | undefined;
for (const m of text.matchAll(CLASS_MARKER)) {
const klass = m[1];
if (!klass) continue;
classes.add(klass);
// Record the FIRST marker as the file's defining class (the prose names
// the persona's own class up top; later mentions reference siblings).
if (!markedClassForFile) {
markedClassForFile = klass;
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
}
accumulateEntry(acc, dir, entry, text);
}
return acc;
}
/**
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure
* over already-read content, so the async and sync directory scanners share a
* single definition of "what classes a file contributes" (DRY — no semantic
* drift between the launch-time and command-time paths).
*/
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
const { classes, byClass } = acc;
if (entry === 'LIBRARY.md') {
for (const m of text.matchAll(LIBRARY_ROW)) {
const name = m[1];
if (name && name !== 'persona') classes.add(name);
}
// A marker-less file still maps its stem to itself (no domain known).
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
return;
}
// The filename stem is itself a valid class (covers marker-less alias docs).
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
let markedClassForFile: string | undefined;
for (const m of text.matchAll(CLASS_MARKER)) {
const klass = m[1];
if (!klass) continue;
classes.add(klass);
// Record the FIRST marker as the file's defining class (the prose names
// the persona's own class up top; later mentions reference siblings).
if (!markedClassForFile) {
markedClassForFile = klass;
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
}
}
return { classes, byClass };
// A marker-less file still maps its stem to itself (no domain known).
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
}
}
export interface PersonaDirs {
@@ -229,6 +268,51 @@ export async function resolvePersona(
);
}
/**
* Synchronous twin of {@link resolvePersona} — same override-wins precedence
* (roles.local/ beats roles/, by marker first then filename stem), returning
* null if neither layer defines the class. Exists for the synchronous launch
* prompt path (composeContract → readPersonaContractBlock) which cannot await.
* Keeping it here, beside the async resolver, keeps the resolution semantics in
* one module so the launch-time and command-time resolutions never diverge.
*/
export function resolvePersonaSync(
klass: string,
opts: PersonaDirs = {},
): PersonaResolution | null {
const { rolesDir, overrideDir } = resolveDirs(opts);
const base = extractClassesFromDirSync(rolesDir);
const over = extractClassesFromDirSync(overrideDir);
const fromLayer = (
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): PersonaResolution | null => {
// Prefer the marker-defined file; fall back to the filename stem.
const pf = extracted.byClass.get(klass);
if (!pf) {
if (!extracted.classes.has(klass)) return null;
const byName = join(dir, `${klass}.md`);
try {
const content = readFileSync(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
} catch {
return null;
}
}
try {
const content = readFileSync(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
} catch {
return null;
}
};
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
}
export interface PersonaStatusEntry {
klass: string;
status: PersonaStatus;