forked from mosaicstack/stack
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
authorityForCanonicalClass,
|
|
canonicalizeRoleClass,
|
|
extractClassesFromDir,
|
|
listPersonaClasses,
|
|
personaStatus,
|
|
resolvePersona,
|
|
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'])(
|
|
'does not infer an alias for %s',
|
|
(requested: string) => {
|
|
expect(canonicalizeRoleClass(requested)).toEqual({
|
|
requestedClass: requested,
|
|
canonicalClass: requested,
|
|
});
|
|
},
|
|
);
|
|
|
|
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 from inline markers and degrades on missing dir', async () => {
|
|
const base = await extractClassesFromDir(rolesDir);
|
|
expect(base.classes.has('ceo')).toBe(true);
|
|
expect(base.byClass.get('ceo')?.domain).toBe('executive');
|
|
const missing = await extractClassesFromDir(join(tmp, 'nope'));
|
|
expect(missing.classes.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
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('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([]);
|
|
});
|
|
});
|