import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { authorityForCanonicalClass, canonicalizeRoleClass, extractClassesFromDir, extractClassesFromDirSync, listPersonaClasses, personaStatus, resolvePersona, resolvePersonaFrom, resolvePersonaSync, } from './fleet-personas.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; // The real, committed library: packages/mosaic/src/commands -> framework/fleet. const frameworkFleet = resolve( dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework', 'fleet', ); const realRolesDir = join(frameworkFleet, 'roles'); let tmp: string; let rolesDir: string; let overrideDir: string; // A minimal baseline persona file with an inline `class:` + `domain:` marker. function baselinePersona(klass: string, domain: string, marker = 'BASELINE'): string { return `# ${klass} — fleet role definition The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`). `; } function overridePersona(klass: string, domain: string, marker = 'OVERRIDE'): string { return `# ${klass} — fleet role definition (override) The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`). `; } beforeEach(async () => { tmp = await mkdtemp(join(tmpdir(), 'h4-personas-')); rolesDir = join(tmp, 'roles'); overrideDir = join(tmp, 'roles.local'); await mkdir(rolesDir, { recursive: true }); // Seed two baseline personas. (No override dir yet — created per test.) await writeFile(join(rolesDir, 'ceo.md'), baselinePersona('ceo', 'executive'), 'utf8'); await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8'); }); afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); describe('FCM class canonicalization and authority', () => { it.each([ ['implementer', 'code'], ['reviewer', 'review'], ['operator-interaction', 'interaction'], ])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => { expect(canonicalizeRoleClass(requested)).toEqual({ requestedClass: requested, canonicalClass: canonical, }); }); it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])( 'does not infer an alias for %s', (requested: string) => { expect(canonicalizeRoleClass(requested)).toEqual({ requestedClass: requested, canonicalClass: requested, }); }, ); it('resolves inherited-property class names without granting protected authority', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'constructor.md'), overridePersona('constructor', 'custom'), 'utf8', ); const resolved = await resolvePersona('constructor', { rolesDir, overrideDir }); expect(resolved).toMatchObject({ requestedClass: 'constructor', canonicalClass: 'constructor', klass: 'constructor', layer: 'override', }); expect(authorityForCanonicalClass('constructor')).toMatchObject({ mayMerge: false, mayIssueValidationCertificate: false, mayOrchestrate: false, }); }); it('canonicalizes before override lookup and lets the canonical override win', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'implementer.md'), overridePersona('implementer', 'legacy'), 'utf8', ); await writeFile( join(overrideDir, 'code.md'), overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'), 'utf8', ); const resolved = await resolvePersona('implementer', { rolesDir, overrideDir }); expect(resolved).toMatchObject({ requestedClass: 'implementer', canonicalClass: 'code', klass: 'code', layer: 'override', }); expect(resolved?.content).toContain('CANONICAL-OVERRIDE'); expect(resolved?.content).not.toContain('legacy'); }); it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'code.md'), overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'), 'utf8', ); const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir }); const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir }); expect(syncResolution).toEqual(asyncResolution); }); it('derives immutable protected authority only from the canonical class', () => { expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true }); for (const klass of [ 'validator', 'orchestrator', 'team-leader', 'interaction', 'code', 'custom-role', ]) { expect(authorityForCanonicalClass(klass).mayMerge).toBe(false); } expect(authorityForCanonicalClass('validator')).toMatchObject({ mayIssueValidationCertificate: true, mayMerge: false, }); expect(authorityForCanonicalClass('orchestrator')).toMatchObject({ mayOrchestrate: true, mayIssueLeases: true, mayMerge: false, }); expect(authorityForCanonicalClass('team-leader')).toMatchObject({ leasedCapacityOnly: true, mayMutateRoster: false, mayAccessCredentials: false, mayMerge: false, }); expect(authorityForCanonicalClass('interaction')).toMatchObject({ requestStatusOnly: true, mayOrchestrate: false, mayMerge: false, }); expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true); }); }); describe('required FCM role library', () => { it.each([ 'code', 'review', 'validator', 'orchestrator', 'team-leader', 'enhancer', 'interaction', 'merge-gate', ])('resolves %s through the real framework role library', async (klass: string) => { const resolved = await resolvePersona(klass, { rolesDir: realRolesDir, overrideDir: join(tmp, 'none'), }); expect(resolved).not.toBeNull(); expect(resolved?.canonicalClass).toBe(klass); expect(resolved?.content.trim()).not.toBe(''); }); }); describe('extractClassesFromDir (shared extraction)', () => { it('records class + domain and distinguishes scanned from missing directories', async () => { const base = await extractClassesFromDir(rolesDir); expect(base.scanState).toBe('scanned'); expect(base.classes.has('ceo')).toBe(true); expect(base.byClass.get('ceo')?.domain).toBe('executive'); const missingDir = join(tmp, 'nope'); const missing = await extractClassesFromDir(missingDir); expect(missing.scanState).toBe('missing'); expect(missing.classes.size).toBe(0); expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing'); }); }); describe('resolvePersona — override wins', () => { it('resolves to the override when a class exists in BOTH layers', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); const resolved = await resolvePersona('ceo', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('override'); expect(resolved?.content).toContain('OVERRIDE'); expect(resolved?.file).toBe(join(overrideDir, 'ceo.md')); }); it('resolves to the baseline when no override exists', async () => { const resolved = await resolvePersona('code', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('baseline'); expect(resolved?.content).toContain('BASELINE'); }); it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'LIBRARY.md'), '| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n', 'utf8', ); const resolved = await resolvePersona('code', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('baseline'); expect(resolved?.content).toContain('BASELINE'); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); expect( (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') ?.status, ).toBe('baseline'); }); it('does not let an incidental later class marker shadow a readable baseline persona', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'mascot.md'), '# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n', 'utf8', ); const resolved = await resolvePersona('code', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('baseline'); expect(resolved?.content).toContain('BASELINE'); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true); expect( (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') ?.status, ).toBe('baseline'); }); it('does not advertise an incidental-only class through listing or status APIs', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'mascot.md'), '# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n', 'utf8', ); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false); expect( (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'), ).toBe(false); }); it('rejects a canonical filename whose explicit marker names another class', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8'); await writeFile( join(rolesDir, 'merge-gate.md'), baselinePersona('merge-gate', 'governance'), 'utf8', ); expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull(); expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull(); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false); expect( (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'), ).toBe(false); }); it('fails closed if a marker-defined override becomes unreadable after extraction', async () => { await mkdir(overrideDir, { recursive: true }); const overrideFile = join(overrideDir, 'engineering.md'); await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(overrideDir), ]); await rm(overrideFile); await mkdir(overrideFile); expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); }); it('fails closed if a marker-defined override changes identity after extraction', async () => { await mkdir(overrideDir, { recursive: true }); const overrideFile = join(overrideDir, 'engineering.md'); await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(overrideDir), ]); await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); const classes = await listPersonaClasses({ rolesDir, overrideDir }); expect(classes.has('code')).toBe(true); expect(classes.has('mascot')).toBe(true); const status = new Map( (await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]), ); expect(status.get('code')?.status).toBe('baseline'); expect(status.get('mascot')?.status).toBe('custom'); }); it('fails closed if a markerless cached override gains a conflicting marker', async () => { await mkdir(overrideDir, { recursive: true }); const overrideFile = join(overrideDir, 'merge-gate.md'); await writeFile(overrideFile, '# markerless merge gate\n', 'utf8'); await writeFile( join(rolesDir, 'merge-gate.md'), baselinePersona('merge-gate', 'governance'), 'utf8', ); const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(overrideDir), ]); await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); expect( await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }), ).toBeNull(); }); it('fails closed asynchronously when the override directory cannot be scanned', async () => { await writeFile(overrideDir, 'not a directory', 'utf8'); expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); }); it('fails closed synchronously when the override directory cannot be scanned', async () => { await writeFile(overrideDir, 'not a directory', 'utf8'); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); }); it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => { await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); }); it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => { const danglingAncestor = join(tmp, 'dangling-ancestor'); await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir'); overrideDir = join(danglingAncestor, 'nested', 'roles.local'); expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); }); it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => { const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor'); await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir'); overrideDir = `${danglingAncestor}/../roles.local`; expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); }); it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => { const danglingAncestor = join(tmp, 'relative-dangling-ancestor'); await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir'); overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`; expect(overrideDir.startsWith('/')).toBe(false); expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); }); it('revalidates a cached missing override before baseline fallback', async () => { const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local'); const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(cachedOverrideDir), ]); expect(over.scanState).toBe('missing'); await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir'); expect( await resolvePersonaFrom('code', { rolesDir, overrideDir: cachedOverrideDir, base, over, }), ).toBeNull(); }); it('revalidates a cached scanned override before baseline fallback', async () => { await mkdir(overrideDir, { recursive: true }); const [base, over] = await Promise.all([ extractClassesFromDir(rolesDir), extractClassesFromDir(overrideDir), ]); expect(over.scanState).toBe('scanned'); expect(over.classes.size).toBe(0); await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering')); const resolved = await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over, }); expect(resolved?.layer).toBe('override'); expect(resolved?.file).toBe(join(overrideDir, 'engineering.md')); }); it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => { const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir }); const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir }); expect(asyncResolution?.layer).toBe('baseline'); expect(syncResolution?.layer).toBe('baseline'); }); it('fails closed asynchronously when the canonical override exists but is unreadable', async () => { await mkdir(overrideDir, { recursive: true }); await mkdir(join(overrideDir, 'code.md')); expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); }); it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => { await mkdir(overrideDir, { recursive: true }); await mkdir(join(overrideDir, 'code.md')); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false); expect( (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'), ).toBe(false); }); it('does not advertise baseline personas when the override directory is unscannable', async () => { await writeFile(overrideDir, 'not a directory', 'utf8'); expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); }); it('does not advertise baseline personas through a dangling override-directory symlink', async () => { await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); }); it('preserves baseline listings when the override directory is missing', async () => { expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); expect( (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') ?.status, ).toBe('baseline'); }); it('does not advertise an unreadable custom override as a valid persona class or status', async () => { await mkdir(overrideDir, { recursive: true }); await mkdir(join(overrideDir, 'ghost.md')); const extracted = await extractClassesFromDir(overrideDir); expect(extracted.fileStems.has('ghost')).toBe(true); expect(extracted.classes.has('ghost')).toBe(false); expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false); expect( (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'), ).toBe(false); }); it('fails closed synchronously when the canonical override exists but is unreadable', async () => { await mkdir(overrideDir, { recursive: true }); await mkdir(join(overrideDir, 'code.md')); expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); }); it('returns null for an unknown class', async () => { expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); }); }); describe('custom add — override-only class', () => { it('a class present only in roles.local/ appears in listPersonaClasses and resolves', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun', 'CUSTOM'), 'utf8', ); const classes = await listPersonaClasses({ rolesDir, overrideDir }); expect(classes.has('mascot')).toBe(true); // Baseline classes are still present (union). expect(classes.has('ceo')).toBe(true); const resolved = await resolvePersona('mascot', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('override'); expect(resolved?.content).toContain('CUSTOM'); }); }); describe('personaStatus classification', () => { it('classifies baseline / overridden / custom correctly', async () => { await mkdir(overrideDir, { recursive: true }); // ceo: overridden (both). code: baseline (only base). mascot: custom (only override). await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); await writeFile(join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8'); const status = await personaStatus({ rolesDir, overrideDir }); const byClass = new Map(status.map((s) => [s.klass, s])); expect(byClass.get('ceo')?.status).toBe('overridden'); expect(byClass.get('code')?.status).toBe('baseline'); expect(byClass.get('mascot')?.status).toBe('custom'); // Domain surfaced. expect(byClass.get('ceo')?.domain).toBe('executive'); }); }); describe('AC-NS-7 — update-survival simulation', () => { it('override and custom-added class survive a baseline reseed', async () => { // 1. User customizes ceo and adds a brand-new persona in the override layer. await mkdir(overrideDir, { recursive: true }); await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); await writeFile( join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun', 'CUSTOM'), 'utf8', ); // 2. Simulate `mosaic update`: REPLACE the baseline roles/ entirely (as the // framework reseed/rsync does), leaving roles.local/ untouched. The reseed // even ships a NEW baseline ceo and adds a brand-new baseline persona. await rm(rolesDir, { recursive: true, force: true }); await mkdir(rolesDir, { recursive: true }); await writeFile( join(rolesDir, 'ceo.md'), baselinePersona('ceo', 'executive', 'RESEEDED-BASELINE'), 'utf8', ); await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8'); await writeFile(join(rolesDir, 'new-role.md'), baselinePersona('new-role', 'ops'), 'utf8'); // 3. The override STILL wins (was not clobbered by the reseed). const ceo = await resolvePersona('ceo', { rolesDir, overrideDir }); expect(ceo?.layer).toBe('override'); expect(ceo?.content).toContain('OVERRIDE'); expect(ceo?.content).not.toContain('RESEEDED-BASELINE'); // 4. The custom-added class still exists and resolves. const mascot = await resolvePersona('mascot', { rolesDir, overrideDir }); expect(mascot?.layer).toBe('override'); expect(mascot?.content).toContain('CUSTOM'); // 5. New baseline personas from the reseed are now visible too. const classes = await listPersonaClasses({ rolesDir, overrideDir }); expect(classes.has('new-role')).toBe(true); expect(classes.has('mascot')).toBe(true); }); }); describe('fleet-profiles validation accepts a custom (override-only) persona', () => { it('a profile referencing an override-only class validates', async () => { // Build a profiles dir + roles using the REAL library plus a custom persona. const profilesDir = join(tmp, 'profiles'); const customRolesDir = join(tmp, 'real-roles'); const customOverrideDir = join(tmp, 'real-roles.local'); await mkdir(profilesDir, { recursive: true }); await cp(realRolesDir, customRolesDir, { recursive: true }); await mkdir(customOverrideDir, { recursive: true }); await writeFile(join(customOverrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8'); // A profile whose roster references the custom (override-only) persona. const profileYaml = [ 'id: custom-team', 'title: Custom Team', 'description: A team that uses a user-added persona.', 'lead: ceo', 'floor:', ' - ceo', 'roster:', ' - class: ceo', ' - class: mascot', ' reports_to: ceo', ].join('\n'); await writeFile(join(profilesDir, 'custom-team.yaml'), profileYaml, 'utf8'); // Override-aware loadProfiles must accept it (would throw if mascot unknown). const profiles = await loadProfiles({ profilesDir, rolesDir: customRolesDir, overrideDir: customOverrideDir, }); const team = profiles.find((p: FleetProfile) => p.id === 'custom-team'); expect(team).toBeDefined(); // And direct validation against the union confirms zero problems. const validClasses = await listPersonaClasses({ rolesDir: customRolesDir, overrideDir: customOverrideDir, }); expect(validateProfile(team as FleetProfile, validClasses)).toEqual([]); }); });