Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
fb61b26818 feat(fleet): system-type profiles — declarative roster+topology mapping (H2)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add declarative system-type profiles: framework/fleet/profiles/*.yaml map a
system type to a persona roster + org topology (reports_to, multiplicity).
Profiles are DATA, seeded like roles, so an operator declares a system type and
gets the matching roster from the baseline library with no code change
(NS-9 / AC-NS-6).

- 5 baseline profiles: software-delivery, personal-assistant, research,
  business (company-in-a-box), marketing.
- fleet-profiles.ts: loadProfiles/loadProfile/parseProfile/validateProfile +
  listPersonaClasses (extracts valid classes from the role library by unioning
  inline `class:` markers, LIBRARY.md rows, and role filenames so marker-less
  personas like planner/decomposition resolve).
- CLI: `mosaic fleet profile list|show [--json]`; invalid profiles exit non-zero.
- Spec covers parse/validate, the library-drift guard (every referenced class
  resolves against the real role library), and unknown-class/reports_to rejection.
- install.sh: profiles seed via the existing rsync (comment clarified; the
  preserved top-level `fleet/*.yaml` glob does not shadow fleet/profiles/*.yaml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:41:37 -05:00
15 changed files with 82 additions and 1128 deletions

View File

@@ -27,49 +27,45 @@ id: software-delivery
title: Software Delivery title: Software Delivery
description: >- description: >-
The engineering fleet that turns ratified objectives into shipped, reviewed, The engineering fleet that turns ratified objectives into shipped, reviewed,
merged code. The lead (orchestrator) runs the supervisor loop and dispatches merged code. The lead (planner — the orchestrator seat) plans phased FRs into a
ready work; it hands goal-decomposition to the planner, which plans phased FRs depends_on DAG, decomposition splits them into one-PR-each cards, coders execute
into a depends_on DAG, decomposition splits them into one-PR-each cards, coders to green CI, and review / security-review / site-tester / merge-gate guard the
execute to green CI, and review / security-review / site-tester / merge-gate merge. This mirrors today's coding fleet.
guard the merge. This mirrors today's coding fleet. # NOTE: the canonical lead seat is the "orchestrator". In the persona library the
# NOTE: the lead seat is the dedicated "orchestrator" — the always-on coordinator # orchestrator IS the `planner` class (see roles/planner.md: "the planner role IS
# that runs the supervisor tick, dispatches ready work, and routes PRs to the # the existing orchestrator class") — so the lead/floor reference `planner`, the
# merge-gate while holding only lean coordination state. The planner is now a # only class that actually resolves to a role contract.
# distinct seat (heavy goal-decomposition context) that reports to the lead: planner
# orchestrator. The two-agent floor is orchestrator + enhancer.
lead: orchestrator
floor: floor:
- orchestrator - planner
- enhancer - enhancer
roster: roster:
- class: orchestrator
- class: board - class: board
reports_to: orchestrator reports_to: planner
- class: planner - class: planner
reports_to: orchestrator
- class: decomposition - class: decomposition
reports_to: planner reports_to: planner
- class: code - class: code
reports_to: decomposition reports_to: decomposition
multiplicity: 2 multiplicity: 2
- class: review - class: review
reports_to: orchestrator reports_to: planner
- class: security-review - class: security-review
reports_to: review reports_to: review
- class: site-tester - class: site-tester
reports_to: review reports_to: review
- class: documentation - class: documentation
reports_to: orchestrator reports_to: planner
- class: merge-gate - class: merge-gate
reports_to: orchestrator reports_to: planner
- class: rebase - class: rebase
reports_to: merge-gate reports_to: merge-gate
- class: operator - class: operator
reports_to: orchestrator reports_to: planner
- class: session-review - class: session-review
reports_to: orchestrator reports_to: planner
- class: enhancer - class: enhancer
reports_to: orchestrator reports_to: planner
notes: >- notes: >-
Two-agent floor (orchestrator + enhancer) is always staffed; every other seat is Two-agent floor (orchestrator/planner + enhancer) is always staffed; every other
added on demand. seat is added on demand.

View File

@@ -19,7 +19,6 @@ their intro so tooling can group them.
| Persona | Purpose | | Persona | Purpose |
| --------------- | ------------------------------------------------------------------------------ | | --------------- | ------------------------------------------------------------------------------ |
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution | | board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG | | planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges | | decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |

View File

@@ -1,46 +0,0 @@
# Orchestrator — fleet role definition
The **orchestrator** is one half of the fleet's two-agent floor: every fleet runs,
at minimum, an **orchestrator** and an **enhancer**. The orchestrator is the
fleet's **always-on coordinator and dispatcher** (`class: orchestrator`,
`persistent_persona: true`) — it owns fleet _movement_, not the work itself.
It is a **core, always-on** agent, not an ephemeral per-lane worker.
## Mandate
1. **Run the supervisor tick** — perform the readiness scan each loop and keep the
two-agent floor (orchestrator + enhancer) healthy, restoring it the moment it
drops below the floor.
2. **Dispatch ready work** — pick up cards whose `depends_on` edges are satisfied
and assign them via the backlog/claim, so no idle agent sits while ready work
exists.
3. **Delegate decomposition, don't do it** — hand goal-decomposition work to the
**planner**, which it coordinates; the orchestrator tracks the resulting plan
but does not author the DAG itself.
4. **Route PRs to the merge-gate** — push reviewed, ready-to-land PRs at the
**merge-gate** (the only merge path); it never approves or merges itself.
5. **Interface with the operator/user** — be the fleet's coordination surface,
relaying status and accepting direction, while holding only coordination state.
6. **Keep the loop turning** — re-dispatch on completion or failure so the fleet
keeps moving rather than stalling.
## Boundaries
- **Does NOT decompose goals into the DAG/cards** — that is the **planner**'s lane,
which the orchestrator dispatches to.
- **Does NOT write product/source code** (coders), **review** (review), or
**approve merges itself** (merge-gate).
- **Does NOT carry deep per-task context** — it delegates and tracks, keeping its
own context lean so the coordination loop stays fast.
The orchestrator moves work; it never holds the heavy planning or execution
context that the seats it dispatches to carry.
## Persona
A lean, decisive coordinator. It thinks in readiness and throughput, dispatches the
next ready card the instant a dependency clears, and never lets an idle agent sit
while ready work exists — keeping its own context minimal so the loop never slows.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -3,11 +3,11 @@
The **planner** turns ratified objectives into an executable **plan** — phased The **planner** turns ratified objectives into an executable **plan** — phased
functional requirements (FRs) wired into a `depends_on` DAG. functional requirements (FRs) wired into a `depends_on` DAG.
> **Reports to the orchestrator.** The planner is the goal-decomposition seat that > **Alias:** the planner role IS the existing **orchestrator** class. The
> the **orchestrator** dispatches planning work to; it carries the heavy > orchestrator _plays_ planner; this file documents the planning contract, it does
> goal-decomposition context, while the orchestrator holds only the lean > **not** introduce a competing class. The two-agent floor (orchestrator +
> coordination state. The two-agent floor is **orchestrator + enhancer** — the > enhancer) is preserved — do not split planner into a separate persistent agent
> planner is added on demand, not part of the floor. > that would break it.
It is a **front-office** role. It is a **front-office** role.
@@ -19,8 +19,8 @@ It is a **front-office** role.
between FRs so downstream decomposition can parallelize safely. between FRs so downstream decomposition can parallelize safely.
3. **Emit a plan, not tasks** — the planner's output is the phased FR/DAG 3. **Emit a plan, not tasks** — the planner's output is the phased FR/DAG
document. Splitting FRs into one-PR-each cards is the **decomposition** role's job. document. Splitting FRs into one-PR-each cards is the **decomposition** role's job.
4. **Re-plan on failure** — when execution diverges, the planner re-sequences the 4. **Re-plan on failure** — when execution diverges, the planner (orchestrator)
DAG rather than letting agents improvise. re-sequences the DAG rather than letting agents improvise.
## Boundaries ## Boundaries
@@ -35,7 +35,6 @@ merge path.
## Persona ## Persona
The architect of the mission's shape. It thinks in phases and dependencies, hands The architect of the mission's shape. It thinks in phases and dependencies, hands
a clean DAG to decomposition, and reports its plan back to the orchestrator that a clean DAG to decomposition, and keeps the orchestrator/enhancer floor intact.
dispatched it.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library). > Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -37,14 +37,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update # packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
# wipes the operator's fleet AND their backlog. Glob entries are honored by # wipes the operator's fleet AND their backlog. Glob entries are honored by
# both the rsync path (`--exclude`) and the glob-aware cp fallback below. # both the rsync path (`--exclude`) and the glob-aware cp fallback below.
# PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog")
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
# fleet/roles/ are reseeded normally on every update (delivering new baseline
# personas), so any local edit there would be clobbered. User customizations
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts).
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the # Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite). # user must not edit them; a divergent copy is backed up once before overwrite).

View File

@@ -164,89 +164,4 @@ describe('composeContract — overlay composer', () => {
expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract'); expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract');
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract'); expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
}); });
// ── Persona contract injection (A3b) ──────────────────────────────────────
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
// (override-aware). Save/restore the env so these tests don't leak state.
describe('persona contract (A3b)', () => {
let prevClass: string | undefined;
beforeEach(() => {
prevClass = process.env['MOSAIC_AGENT_CLASS'];
});
afterEach(() => {
if (prevClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
else process.env['MOSAIC_AGENT_CLASS'] = prevClass;
});
const seedBaseline = (klass: string, body: string): void => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(join(fixture.home, 'fleet', 'roles', `${klass}.md`), body);
};
const seedOverride = (klass: string, body: string): void => {
mkdirSync(join(fixture.home, 'fleet', 'roles.local'), { recursive: true });
writeFileSync(join(fixture.home, 'fleet', 'roles.local', `${klass}.md`), body);
};
it('injects the baseline persona when MOSAIC_AGENT_CLASS is set and a role file exists', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE: ship the lane.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'coder';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (coder)');
expect(out).toContain('BASELINE-MANDATE');
});
it('OVERRIDE WINS at launch: roles.local persona is injected over baseline (AC-NS-7)', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
seedOverride('coder', '# Coder (override)\n\n(`class: coder`)\n\nOVERRIDE-MANDATE.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'coder';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (coder)');
expect(out).toContain('OVERRIDE-MANDATE');
expect(out).not.toContain('BASELINE-MANDATE');
});
it('does NOT inject a persona when MOSAIC_AGENT_CLASS is unset', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
delete process.env['MOSAIC_AGENT_CLASS'];
const out = composeContract('claude', fixture.home);
expect(out).not.toContain('# Persona Contract');
});
it('does NOT inject (no throw) when MOSAIC_AGENT_CLASS names an unknown class', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'nonexistent';
expect(() => composeContract('claude', fixture.home)).not.toThrow();
expect(composeContract('claude', fixture.home)).not.toContain('# Persona Contract');
});
it('places the persona contract BEFORE the fleet comms block (identity, then peers)', () => {
seedBaseline('enhancer', '# Enhancer\n\n(`class: enhancer`)\n\nIMPROVE.\n');
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'agents:',
' - name: orchestrator',
' class: orchestrator',
' - name: enhancer',
' class: enhancer',
'',
].join('\n'),
);
const prevName = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_CLASS'] = 'enhancer';
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (enhancer)');
expect(out).toContain('# Fleet Comms');
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
} finally {
if (prevName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prevName;
}
});
});
}); });

View File

@@ -1,210 +0,0 @@
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 {
extractClassesFromDir,
listPersonaClasses,
personaStatus,
resolvePersona,
} 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('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([]);
});
});

View File

@@ -1,497 +0,0 @@
/**
* Persona override layer + resolver (North Star H4).
*
* Baseline personas are markdown role contracts seeded by the framework into
* <mosaicHome>/fleet/roles/*.md
* They are RESEEDED on every `mosaic update` (so new baseline personas ship to
* existing installs). That reseed is exactly what would clobber any local edit,
* so user customizations must NOT live in roles/.
*
* The override layer is a sibling directory:
* <mosaicHome>/fleet/roles.local/*.md
* It is PRESERVE-protected in install.sh (see PRESERVE_PATHS "fleet/roles.local"),
* so `mosaic update` never deletes it while roles/ keeps reseeding. An override
* file WINS over the baseline of the same class, and an override file may ADD an
* entirely new class that has no baseline at all. This delivers AC-NS-7: a
* user-customized persona survives `mosaic update`.
*
* Class identity is encoded INLINE in the role prose, not as YAML frontmatter:
* (`class: ceo`, `domain: executive`)
* The marker value may wrap across a newline. A few engineering personas carry
* no marker at all and are identified by filename (e.g. planner -> orchestrator).
*
* The class-extraction logic here is the SINGLE SOURCE OF TRUTH for "what
* persona classes exist"; fleet-profiles.ts imports it (DRY) so a profile roster
* 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';
import type { Command } from 'commander';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles.local');
}
/**
* Match a `class: X` marker even when the value wrapped onto the next line.
* Allow surrounding backtick(s); the value is a single kebab-case token.
* Shared by every caller so the definition of "a class marker" lives once.
*/
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
/** LIBRARY.md persona rows: the first table cell is the persona name. */
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
/** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override';
/** One discovered persona file (a single role contract on disk). */
export interface PersonaFile {
klass: string;
/** The markdown file the class was found in. */
file: string;
domain?: string;
}
/** The set of persona classes a directory of role contracts defines. */
export interface DirClasses {
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
classes: Set<string>;
/** For classes whose file carries a marker, the file + domain that defined it. */
byClass: Map<string, PersonaFile>;
}
/**
* Scan one directory of role contracts and extract the persona classes it
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles
* rely on. Sources, unioned (each needed — see module doc):
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
* 2. persona-name cells from LIBRARY.md index tables,
* 3. the role filename stem (covers marker-less alias docs like planner).
*
* Missing dir / unreadable files degrade gracefully to whatever was found.
* `byClass` records the defining file+domain for marker-bearing classes so the
* resolver can map a class back to its file; filename-only and LIBRARY-only
* classes still appear in `classes` for membership checks.
*/
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return acc;
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
let text: string;
try {
text = await readFile(join(dir, entry), 'utf8');
} catch {
continue;
}
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;
}
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);
}
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 } : {}) });
}
}
// 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 {
/** Baseline roles dir. Defaults to <mosaicHome>/fleet/roles. */
rolesDir?: string;
/** Override dir. Defaults to <mosaicHome>/fleet/roles.local. */
overrideDir?: string;
mosaicHome?: string;
}
function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return {
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
};
}
/**
* UNION of baseline classes and override classes. Overrides may ADD entirely new
* classes not present in the baseline, so callers (e.g. profile roster
* validation) treat a user-added persona as a real class.
*/
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const union = new Set<string>(base.classes);
for (const c of over.classes) union.add(c);
return union;
}
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
export interface PersonaResolution {
klass: string;
layer: PersonaLayer;
/** The file the resolved persona was read from (override wins). */
file: string;
content: string;
domain?: string;
}
/**
* Resolve a persona class to its winning definition: the override file if
* roles.local/ defines that class, else the baseline. Match by inline `class:`
* marker first, then by filename stem (roles.local/<klass>.md) as a fallback.
* Returns null if neither layer defines the class.
*/
export async function resolvePersona(
klass: string,
opts: PersonaDirs = {},
): Promise<PersonaResolution | null> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const fromLayer = async (
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): Promise<PersonaResolution | null> => {
// Prefer the marker-defined file; fall back to the filename stem.
let pf = extracted.byClass.get(klass);
if (!pf) {
const byName = join(dir, `${klass}.md`);
if (!extracted.classes.has(klass)) return null;
// Class known only via filename/LIBRARY: read the stem file if present.
try {
const content = await readFile(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 = await readFile(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
} catch {
return null;
}
};
return (
(await fromLayer(overrideDir, over, 'override')) ??
(await fromLayer(rolesDir, base, 'baseline'))
);
}
/**
* 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;
domain?: string;
}
/**
* Classify every known class:
* - baseline — present only in roles/
* - overridden — present in BOTH roles/ and roles.local/ (override wins)
* - custom — present only in roles.local/ (user-added)
* Domain is taken from the WINNING layer (override domain wins if present).
*/
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const all = new Set<string>([...base.classes, ...over.classes]);
const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
extracted.byClass.get(klass)?.domain;
const entries: PersonaStatusEntry[] = [];
for (const klass of all) {
const inBase = base.classes.has(klass);
const inOver = over.classes.has(klass);
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
entries.push({ klass, status, ...(domain ? { domain } : {}) });
}
entries.sort((a, b) => a.klass.localeCompare(b.klass));
return entries;
}
// ─── CLI: `mosaic fleet persona <list|show|customize>` ───────────────────────
function printPersonaList(entries: PersonaStatusEntry[]): void {
if (entries.length === 0) {
console.log('(no personas)');
return;
}
for (const e of entries) {
console.log(`${e.klass}\t[${e.status}]\tdomain=${e.domain ?? '-'}`);
}
}
/** Minimal override scaffold for a brand-new (no-baseline) class. */
function scaffoldOverride(klass: string): string {
return `# ${klass} — fleet role definition (override)
The **${klass}** persona (\`class: ${klass}\`) is a user-defined override that
lives in the PRESERVE-protected \`fleet/roles.local/\` layer and survives
\`mosaic update\`. Edit this file to define the persona's mandate and boundaries.
## Mandate
1. (describe what this persona owns)
## Boundaries
- (describe what this persona does NOT do)
`;
}
/**
* Register `persona` under an existing `fleet` command. `mosaicHomeFor` resolves
* the active --mosaic-home (parent flag) at call time, mirroring the backlog and
* profile subcommand wiring.
*/
export function registerFleetPersonaCommand(
fleetCmd: Command,
mosaicHomeFor: () => string,
): Command {
const personaCmd = fleetCmd
.command('persona')
.description('Update-surviving persona overrides: baseline ⊕ roles.local layer (H4)');
personaCmd
.command('list')
.description('List every persona class with its status (baseline/overridden/custom) and domain')
.option('--json', 'Print JSON')
.action(async (opts: { json?: boolean }) => {
try {
const entries = await personaStatus({ mosaicHome: mosaicHomeFor() });
if (opts.json) {
console.log(JSON.stringify(entries));
return;
}
printPersonaList(entries);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
personaCmd
.command('show <class>')
.description('Show the RESOLVED persona (override wins) and which layer it came from')
.option('--json', 'Print JSON')
.action(async (klass: string, opts: { json?: boolean }) => {
try {
const resolved = await resolvePersona(klass, { mosaicHome: mosaicHomeFor() });
if (!resolved) {
process.stderr.write(`Unknown persona class "${klass}"\n`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(resolved));
return;
}
console.log(`# class: ${resolved.klass}`);
console.log(`# layer: ${resolved.layer}`);
console.log(`# domain: ${resolved.domain ?? '-'}`);
console.log(`# file: ${resolved.file}`);
console.log('');
console.log(resolved.content);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
personaCmd
.command('customize <class>')
.description(
'Copy the baseline persona into fleet/roles.local/ to edit (override layer). ' +
'--new scaffolds a brand-new persona with no baseline.',
)
.option('--new', 'Scaffold a minimal override for a brand-new class (no baseline required)')
.action(async (klass: string, opts: { new?: boolean }) => {
try {
const { mkdir, writeFile, copyFile, access } = await import('node:fs/promises');
const { constants } = await import('node:fs');
const mosaicHome = mosaicHomeFor();
const rolesDir = defaultRolesDir(mosaicHome);
const overrideDir = defaultOverrideDir(mosaicHome);
const target = join(overrideDir, `${klass}.md`);
await mkdir(overrideDir, { recursive: true });
// Do not clobber an existing override.
try {
await access(target, constants.F_OK);
console.log(`Override already exists, not clobbering: ${target}`);
return;
} catch {
// not present — proceed
}
if (opts.new) {
await writeFile(target, scaffoldOverride(klass), 'utf8');
console.log(`Scaffolded new persona override: ${target}`);
return;
}
// Copy the baseline. Prefer the marker-defining file; fall back to stem.
const base = await extractClassesFromDir(rolesDir);
const pf = base.byClass.get(klass);
const source = pf?.file ?? join(rolesDir, `${klass}.md`);
try {
await access(source, constants.F_OK);
} catch {
process.stderr.write(
`No baseline persona "${klass}" to copy. Use --new to scaffold one.\n`,
);
process.exitCode = 1;
return;
}
await copyFile(source, target);
console.log(`Copied baseline persona to override layer: ${target}`);
console.log('Edit it there; it wins over the baseline and survives `mosaic update`.');
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
return personaCmd;
}

View File

@@ -46,12 +46,10 @@ describe('listPersonaClasses (real role library)', () => {
it('covers marker-less engineering personas via filename + LIBRARY index', async () => { it('covers marker-less engineering personas via filename + LIBRARY index', async () => {
const classes = await listPersonaClasses(rolesDir); const classes = await listPersonaClasses(rolesDir);
// planner/decomposition have a role file but no inline marker — they resolve // planner/decomposition have a role file but no inline marker (planner aliases
// from the filename + LIBRARY.md row. // the orchestrator class) — they resolve from the filename + LIBRARY.md row.
expect(classes.has('planner')).toBe(true); expect(classes.has('planner')).toBe(true);
expect(classes.has('decomposition')).toBe(true); expect(classes.has('decomposition')).toBe(true);
// The dedicated orchestrator persona resolves (inline marker + filename + row).
expect(classes.has('orchestrator')).toBe(true);
}); });
it('returns an empty set for a missing roles dir (graceful)', async () => { it('returns an empty set for a missing roles dir (graceful)', async () => {
@@ -77,17 +75,11 @@ describe('baseline profiles (real library)', () => {
it('software-delivery has the expected lead, floor, and roster shape', async () => { it('software-delivery has the expected lead, floor, and roster shape', async () => {
const profile = await loadProfile('software-delivery', realLib); const profile = await loadProfile('software-delivery', realLib);
expect(profile.lead).toBe('orchestrator'); expect(profile.lead).toBe('planner');
expect(profile.floor).toEqual(['orchestrator', 'enhancer']); expect(profile.floor).toEqual(['planner', 'enhancer']);
const code = profile.roster.find((r) => r.class === 'code'); const code = profile.roster.find((r) => r.class === 'code');
expect(code?.multiplicity).toBe(2); expect(code?.multiplicity).toBe(2);
expect(code?.reportsTo).toBe('decomposition'); expect(code?.reportsTo).toBe('decomposition');
// The dedicated orchestrator is the lead seat (no reports_to); the planner is
// now a distinct seat that reports to it.
const orchestrator = profile.roster.find((r) => r.class === 'orchestrator');
expect(orchestrator?.reportsTo).toBeUndefined();
const planner = profile.roster.find((r) => r.class === 'planner');
expect(planner?.reportsTo).toBe('orchestrator');
}); });
it('loadProfile throws on an unknown id', async () => { it('loadProfile throws on an unknown id', async () => {

View File

@@ -25,11 +25,6 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import {
defaultOverrideDir,
extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses,
} from './fleet-personas.js';
function defaultMosaicHome(): string { function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -62,29 +57,57 @@ export interface FleetProfile {
} }
/** /**
* Extract the set of valid persona classes from a single baseline role dir. * Extract the set of valid persona classes from the role library.
* *
* Thin wrapper over the shared {@link extractClassesFromDir} in fleet-personas.ts * Sources (unioned — see module doc for why each is needed):
* — the single source of truth for "what classes exist" (DRY). Kept as a * 1. inline `` `class: X` `` markers in every roles/*.md (the primary signal;
* baseline-only, positional-`rolesDir` helper for backward compatibility; the * a marker may wrap across a newline, e.g. `` `class:\n support-agent` ``).
* override-aware union (baseline ⊕ roles.local) used by roster validation is * 2. persona-name cells from the LIBRARY.md index tables.
* {@link listPersonaClassesWithOverrides} below. * 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).
*/ */
export async function listPersonaClasses(rolesDir = defaultRolesDir()): Promise<Set<string>> { export async function listPersonaClasses(rolesDir = defaultRolesDir()): Promise<Set<string>> {
return (await extractClassesFromDir(rolesDir)).classes; 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;
/** for (const entry of entries) {
* Override-aware valid-class set: baseline roles/ ⊕ override roles.local/. A if (!entry.endsWith('.md')) continue;
* profile may legitimately reference a user-customized OR user-ADDED persona, so let text: string;
* roster validation resolves against this union (H4). Delegates to the shared try {
* fleet-personas resolver. text = await readFile(join(rolesDir, entry), 'utf8');
*/ } catch {
export async function listPersonaClassesWithOverrides( continue;
rolesDir: string, }
overrideDir: string, if (entry === 'LIBRARY.md') {
): Promise<Set<string>> { for (const m of text.matchAll(libraryRow)) {
return listOverrideAwarePersonaClasses({ rolesDir, overrideDir }); 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;
} }
function asString(value: unknown, ctx: string): string { function asString(value: unknown, ctx: string): string {
@@ -204,21 +227,14 @@ export interface LoadProfilesOptions {
profilesDir?: string; profilesDir?: string;
/** Override the roles dir (tests). Defaults to <mosaicHome>/fleet/roles. */ /** Override the roles dir (tests). Defaults to <mosaicHome>/fleet/roles. */
rolesDir?: string; rolesDir?: string;
/** Persona override dir (tests). Defaults to <mosaicHome>/fleet/roles.local. */
overrideDir?: string;
mosaicHome?: string; mosaicHome?: string;
} }
function resolveDirs(opts: LoadProfilesOptions): { function resolveDirs(opts: LoadProfilesOptions): { profilesDir: string; rolesDir: string } {
profilesDir: string;
rolesDir: string;
overrideDir: string;
} {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome(); const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return { return {
profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome), profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome),
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome), rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
}; };
} }
@@ -228,7 +244,7 @@ function resolveDirs(opts: LoadProfilesOptions): {
* Profiles are returned sorted by id for deterministic output. * Profiles are returned sorted by id for deterministic output.
*/ */
export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<FleetProfile[]> { export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<FleetProfile[]> {
const { profilesDir, rolesDir, overrideDir } = resolveDirs(opts); const { profilesDir, rolesDir } = resolveDirs(opts);
let files: string[]; let files: string[];
try { try {
files = (await readdir(profilesDir)).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); files = (await readdir(profilesDir)).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
@@ -237,10 +253,7 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
} }
files.sort(); files.sort();
// Override-aware: a profile may reference a user-customized or user-ADDED const validClasses = await listPersonaClasses(rolesDir);
// 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 profiles: FleetProfile[] = [];
const seen = new Map<string, string>(); const seen = new Map<string, string>();

View File

@@ -82,7 +82,6 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'persona',
'profile', 'profile',
'ps', 'ps',
'remove', 'remove',
@@ -103,19 +102,6 @@ describe('registerFleetCommand', () => {
expect(profile!.commands.map((command) => command.name()).sort()).toEqual(['list', 'show']); expect(profile!.commands.map((command) => command.name()).sort()).toEqual(['list', 'show']);
}); });
it('registers the persona subcommand with list, show, and customize', () => {
const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet');
const persona = fleet!.commands.find((command) => command.name() === 'persona');
expect(persona).toBeDefined();
expect(persona!.commands.map((command) => command.name()).sort()).toEqual([
'customize',
'list',
'show',
]);
});
it('registers the backlog subcommand with its operations', () => { it('registers the backlog subcommand with its operations', () => {
const program = buildProgram(); const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet'); const fleet = program.commands.find((command) => command.name() === 'fleet');

View File

@@ -9,7 +9,6 @@ import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import { registerFleetBacklogCommand } from './fleet-backlog.js'; import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.js'; import { registerFleetProfileCommand } from './fleet-profiles.js';
/** /**
@@ -1712,11 +1711,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag. // from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// Update-surviving persona overrides (H4): baseline fleet/roles/ ⊕ the
// PRESERVE-protected fleet/roles.local/ override layer, resolved via the same
// --mosaic-home flag.
registerFleetPersonaCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
return cmd; return cmd;
} }

View File

@@ -20,7 +20,6 @@ import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js'; import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -385,16 +384,6 @@ For required push/merge/issue-close/release actions, execute without routine con
// Runtime-specific contract // Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
// Persona contract (A3b): when this agent was spawned with a class
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
// role contract so its identity (mandate + boundaries) is resident from the
// first turn. Override-aware via the persona resolver: a user-customized
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
// silently when the class is unset/unknown (mirrors the comms block).
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
if (persona) parts.push('\n\n' + persona);
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
// and present in the roster), inject a comms cheat-sheet + peer roster so it // and present in the roster), inject a comms cheat-sheet + peer roster so it
// knows how to reach the orchestrator and its peers from its first turn. // knows how to reach the orchestrator and its peers from its first turn.

View File

@@ -1,106 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readPersonaContractBlock } from './persona-contract.js';
/**
* Persona-contract launch injection (A3b). Asserts the override-aware resolver
* is wired so a customized persona in roles.local/ wins at launch (AC-NS-7), and
* that any miss (unset/empty/unknown class, missing file) no-ops silently —
* never throws — mirroring readFleetCommsBlock's tolerant contract.
*/
const BASELINE_CODER = `# Coder — fleet role definition
The **coder** persona (\`class: coder\`, \`domain: engineering\`).
## Mandate
BASELINE-MANDATE: implement the assigned lane.
`;
const OVERRIDE_CODER = `# Coder — fleet role definition (override)
The **coder** persona (\`class: coder\`).
## Mandate
OVERRIDE-MANDATE: implement the assigned lane, the user's way.
`;
function makeHome(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-persona-'));
return join(root, 'mosaic-home');
}
function seedBaseline(home: string, klass: string, body: string): void {
const dir = join(home, 'fleet', 'roles');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${klass}.md`), body);
}
function seedOverride(home: string, klass: string, body: string): void {
const dir = join(home, 'fleet', 'roles.local');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${klass}.md`), body);
}
describe('readPersonaContractBlock — launch-time persona injection (A3b)', () => {
let home: string;
beforeEach(() => {
home = makeHome();
});
afterEach(() => {
// root is the parent of mosaic-home
rmSync(join(home, '..'), { recursive: true, force: true });
});
it('injects the baseline persona when the class has a fleet/roles/<class>.md', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
const block = readPersonaContractBlock(home, 'coder');
expect(block).toContain('# Persona Contract (coder)');
expect(block).toContain('BASELINE-MANDATE');
expect(block).toContain('baseline `fleet/roles/` layer');
});
it('OVERRIDE WINS: roles.local/<class>.md content is injected over the baseline (AC-NS-7)', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
seedOverride(home, 'coder', OVERRIDE_CODER);
const block = readPersonaContractBlock(home, 'coder');
expect(block).toContain('# Persona Contract (coder)');
expect(block).toContain('OVERRIDE-MANDATE'); // override body present
expect(block).not.toContain('BASELINE-MANDATE'); // baseline NOT used
expect(block).toContain('roles.local'); // layer note names the override layer
});
it('injects an override-only (user-added) persona with no baseline at all', () => {
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'reviewer');
expect(block).toContain('# Persona Contract (reviewer)');
expect(block).toContain('CUSTOM-ROLE');
});
it('no-ops (empty string) when the class is undefined', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, undefined)).toBe('');
});
it('no-ops (empty string) when the class is empty/whitespace', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, '')).toBe('');
expect(readPersonaContractBlock(home, ' ')).toBe('');
});
it('no-ops (empty string) for an unknown class with no role file', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, 'nonexistent')).toBe('');
});
it('no-ops (empty string, no throw) when no roles directories exist at all', () => {
expect(() => readPersonaContractBlock(home, 'coder')).not.toThrow();
expect(readPersonaContractBlock(home, 'coder')).toBe('');
});
});

View File

@@ -1,63 +0,0 @@
/**
* Persona-contract injection at launch (North Star A3b).
*
* A spawned fleet agent should boot already knowing WHO it is: its class's role
* contract (mandate + boundaries). The companion goal A3a exports the agent's
* resolved class into the pane env as `MOSAIC_AGENT_CLASS`; here we read that
* class at launch (composeContract → system prompt) and inject the resolved
* persona contract so the identity is resident from the agent's first turn.
*
* OVERRIDE-AWARE: resolution goes through fleet-personas' resolver, so a
* user-customized persona in the PRESERVE-protected `fleet/roles.local/` layer
* WINS over the baseline `fleet/roles/` of the same class. That is the
* launch-time proof of AC-NS-7 — a customized persona actually reaches the model
* when the agent boots, not just in `mosaic fleet persona show`.
*
* Tolerant by contract (mirrors readFleetCommsBlock): an empty/missing class, an
* unknown class, or a missing role file all yield '' so the launcher no-ops
* silently. This MUST never throw during launch.
*
* Standalone module (no fleet.ts import) to keep launch.ts's prompt path free of
* the heavy fleet command module; it depends only on the lightweight persona
* resolver.
*/
import {
resolvePersonaSync,
defaultRolesDir,
defaultOverrideDir,
} from '../commands/fleet-personas.js';
/**
* Resolve `klass`'s persona contract (override-aware) and render it as a
* clearly-delimited launch block. Returns '' on any miss (falsy class, unknown
* class, missing/unreadable file) so composeContract can push it unconditionally
* and have it no-op silently. Never throws.
*/
export function readPersonaContractBlock(mosaicHome: string, klass: string | undefined): string {
if (!klass || !klass.trim()) return '';
let resolved: ReturnType<typeof resolvePersonaSync>;
try {
resolved = resolvePersonaSync(klass.trim(), {
rolesDir: defaultRolesDir(mosaicHome),
overrideDir: defaultOverrideDir(mosaicHome),
});
} catch {
// Best-effort onboarding: a resolver hiccup must not abort the launch.
return '';
}
if (!resolved) return '';
const layerNote =
resolved.layer === 'override'
? '_(resolved from the `fleet/roles.local/` override layer — wins over baseline)_'
: '_(resolved from the baseline `fleet/roles/` layer)_';
return `# Persona Contract (${resolved.klass})
${layerNote}
You are operating as the **${resolved.klass}** persona. The role contract below is your identity — its mandate and boundaries govern what you own and what you must not do for this assignment.
${resolved.content.trim()}`;
}