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
15 changed files with 61 additions and 550 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.
- **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)
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaicstack/db",
"version": "0.0.4",
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

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

View File

@@ -19,7 +19,6 @@ their intro so tooling can group them.
| 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 |
| 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 |

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
functional requirements (FRs) wired into a `depends_on` DAG.
> **Reports to the orchestrator.** The planner is the goal-decomposition seat that
> the **orchestrator** dispatches planning work to; it carries the heavy
> goal-decomposition context, while the orchestrator holds only the lean
> coordination state. The two-agent floor is **orchestrator + enhancer** — the
> planner is added on demand, not part of the floor.
> **Alias:** the planner role IS the existing **orchestrator** class. The
> orchestrator _plays_ planner; this file documents the planning contract, it does
> **not** introduce a competing class. The two-agent floor (orchestrator +
> enhancer) is preserved — do not split planner into a separate persistent agent
> that would break it.
It is a **front-office** role.
@@ -19,8 +19,8 @@ It is a **front-office** role.
between FRs so downstream decomposition can parallelize safely.
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.
4. **Re-plan on failure** — when execution diverges, the planner re-sequences the
DAG rather than letting agents improvise.
4. **Re-plan on failure** — when execution diverges, the planner (orchestrator)
re-sequences the DAG rather than letting agents improvise.
## Boundaries
@@ -35,7 +35,6 @@ merge path.
## Persona
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
dispatched it.
a clean DAG to decomposition, and keeps the orchestrator/enhancer floor intact.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaicstack/mosaic",
"version": "0.0.47",
"version": "0.0.45",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -164,89 +164,4 @@ describe('composeContract — overlay composer', () => {
expect(composeContract('pi', fixture.home)).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.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
@@ -89,12 +88,13 @@ export interface DirClasses {
* 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>() };
const classes = new Set<string>();
const byClass = new Map<string, PersonaFile>();
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return acc;
return { classes, byClass };
}
for (const entry of entries) {
@@ -105,75 +105,36 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
} 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 {
if (entry === 'LIBRARY.md') {
for (const m of text.matchAll(LIBRARY_ROW)) {
const name = m[1];
if (name && name !== 'persona') classes.add(name);
}
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);
// The filename stem is itself a valid class (covers marker-less alias docs).
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
let markedClassForFile: string | undefined;
for (const m of text.matchAll(CLASS_MARKER)) {
const klass = m[1];
if (!klass) continue;
classes.add(klass);
// Record the FIRST marker as the file's defining class (the prose names
// the persona's own class up top; later mentions reference siblings).
if (!markedClassForFile) {
markedClassForFile = klass;
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
}
}
return;
}
// 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) });
}
}
// A marker-less file still maps its stem to itself (no domain known).
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
}
return { classes, byClass };
}
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 {
klass: string;
status: PersonaStatus;

View File

@@ -46,12 +46,10 @@ describe('listPersonaClasses (real role library)', () => {
it('covers marker-less engineering personas via filename + LIBRARY index', async () => {
const classes = await listPersonaClasses(rolesDir);
// planner/decomposition have a role file but no inline marker — they resolve
// from the filename + LIBRARY.md row.
// planner/decomposition have a role file but no inline marker (planner aliases
// the orchestrator class) — they resolve from the filename + LIBRARY.md row.
expect(classes.has('planner')).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 () => {
@@ -77,17 +75,11 @@ describe('baseline profiles (real library)', () => {
it('software-delivery has the expected lead, floor, and roster shape', async () => {
const profile = await loadProfile('software-delivery', realLib);
expect(profile.lead).toBe('orchestrator');
expect(profile.floor).toEqual(['orchestrator', 'enhancer']);
expect(profile.lead).toBe('planner');
expect(profile.floor).toEqual(['planner', 'enhancer']);
const code = profile.roster.find((r) => r.class === 'code');
expect(code?.multiplicity).toBe(2);
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 () => {

View File

@@ -246,8 +246,6 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=',
'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', () => {
const generated = [
'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 () => {
cleanup = await tempDir();
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;
return [
`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)}`,
// 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

View File

@@ -20,7 +20,6 @@ import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
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');
@@ -385,16 +384,6 @@ For required push/merge/issue-close/release actions, execute without routine con
// Runtime-specific contract
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
// 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.

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