Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
92e4ee189a feat(fleet): update-surviving persona overrides (roles.local layer, resolver, persona CLI) (H4)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add a PRESERVE-protected persona override layer at <mosaicHome>/fleet/roles.local/
that survives `mosaic update` while baseline fleet/roles/ keeps reseeding.

- fleet-personas.ts: shared class-extraction (single source of truth, DRY with
  fleet-profiles.ts), resolvePersona (override wins, baseline fallback),
  listPersonaClasses (baseline ⊕ override union), personaStatus
  (baseline/overridden/custom), and the `fleet persona list|show|customize` CLI.
- fleet-profiles.ts: roster validation now uses the override-aware union so a
  profile can reference a user-customized or user-ADDED persona; the old
  listPersonaClasses(rolesDir) is kept as a thin delegate to the shared helper.
- install.sh: add fleet/roles.local to PRESERVE_PATHS (AC-NS-7 guarantee).
- specs: override-wins, custom-add, status classification, AC-NS-7
  update-survival simulation, and profile-validation-accepts-custom-persona.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:11:00 -05:00
13 changed files with 59 additions and 548 deletions

View File

@@ -353,25 +353,6 @@ re-evaluate if isolation or write-volume demands it.
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained. - **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it. - **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
## Decisions of record (2026-06-24, with Jason)
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends `--model <hint>` to that
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
downgrade ladder (opus → sonnet → haiku, then Claude → Codex): the operator sets the per-agent model
_intent/ceiling_; budget pacing may downgrade within policy. Tracked as a Fleet `TASKS.md` entry under
the Phase-5 webUI surface.
- **Orchestrator runtime (confirmed live):** the **orchestrator and enhancer run Claude Opus 4.8 in the
Claude Code harness**; only workers (coder/reviewer) run pi/gpt-5.5. Consistent with the 2026-06-20
"Claude reserved for Claude Code only" decision (the orchestrator runs _in_ Claude Code, not an
alternate Claude harness). Pi/gpt-5.5 as the orchestrator is permitted **only if proven** at least as
satisfactory; absent that proof, the orchestrator stays on Claude Opus 4.8.
## Future enhancements (north-star, post-MVP — not on the MVP track) ## Future enhancements (north-star, post-MVP — not on the MVP track)
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly - **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly

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

@@ -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

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

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

@@ -246,8 +246,6 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=', 'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
@@ -257,40 +255,6 @@ describe('fleet roster parsing', () => {
); );
}); });
it('emits MOSAIC_AGENT_CLASS=worker for an agent that declares no class', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
'MOSAIC_AGENT_CLASS=worker\n',
);
});
it('shell-escapes MOSAIC_AGENT_CLASS so a launcher reads it verbatim', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex', class: 'orchestrator' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
'MOSAIC_AGENT_CLASS=orchestrator\n',
);
});
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => { it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
const generated = [ const generated = [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
@@ -322,28 +286,6 @@ describe('fleet roster parsing', () => {
); );
}); });
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
const generated = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_CLASS=orchestrator',
'MOSAIC_AGENT_RUNTIME=codex',
'',
].join('\n');
const existing = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_CLASS=worker',
'MOSAIC_AGENT_RUNTIME=codex',
'',
].join('\n');
const merged = mergeAgentEnv(generated, existing);
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
});
it('rejects unknown roster fields instead of silently defaulting', async () => { it('rejects unknown roster fields instead of silently defaulting', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');

View File

@@ -490,9 +490,6 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return [ return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`, `MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
// matching persona contract for the agent's class (default `worker`).
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`, `MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to // Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on // the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on

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()}`;
}