From 1bb9fbd83aa09ff85cb10fe45490e6d6f6487c85 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 24 Jun 2026 11:49:50 -0500 Subject: [PATCH] feat(fleet): inject resolved persona contract at launch by class (A3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At agent launch, composeContract reads MOSAIC_AGENT_CLASS (exported into the pane env by the companion A3a goal) and injects the agent's resolved persona role contract into the system prompt, so its identity (mandate + boundaries) is resident from the first turn. Override-aware: resolution goes through the persona resolver, so a customized persona in fleet/roles.local/ wins over the baseline fleet/roles/ of the same class — the launch-time proof of AC-NS-7. Tolerant: any miss (unset/empty/ unknown class, missing file) no-ops silently and never throws during launch, mirroring readFleetCommsBlock. Persona is injected BEFORE the fleet comms block (identity first, then how-to-reach-peers). Adds a synchronous resolvePersonaSync / extractClassesFromDirSync twin in fleet-personas.ts (composeContract is sync and cannot await), sharing the same extraction semantics via a pure accumulateEntry helper (no drift). Co-Authored-By: Claude Opus 4.8 --- .../src/commands/compose-contract.spec.ts | 85 +++++++++++ .../mosaic/src/commands/fleet-personas.ts | 140 ++++++++++++++---- packages/mosaic/src/commands/launch.ts | 11 ++ .../mosaic/src/fleet/persona-contract.spec.ts | 106 +++++++++++++ packages/mosaic/src/fleet/persona-contract.ts | 63 ++++++++ 5 files changed, 377 insertions(+), 28 deletions(-) create mode 100644 packages/mosaic/src/fleet/persona-contract.spec.ts create mode 100644 packages/mosaic/src/fleet/persona-contract.ts diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index 6abecce..bc021e4 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -164,4 +164,89 @@ 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; + } + }); + }); }); diff --git a/packages/mosaic/src/commands/fleet-personas.ts b/packages/mosaic/src/commands/fleet-personas.ts index 6d9bc46..9bb807c 100644 --- a/packages/mosaic/src/commands/fleet-personas.ts +++ b/packages/mosaic/src/commands/fleet-personas.ts @@ -25,6 +25,7 @@ * 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'; @@ -88,13 +89,12 @@ export interface DirClasses { * classes still appear in `classes` for membership checks. */ export async function extractClassesFromDir(dir: string): Promise { - const classes = new Set(); - const byClass = new Map(); + const acc: DirClasses = { classes: new Set(), byClass: new Map() }; let entries: string[]; try { entries = await readdir(dir); } catch { - return { classes, byClass }; + return acc; } for (const entry of entries) { @@ -105,36 +105,75 @@ export async function extractClassesFromDir(dir: string): Promise { } catch { continue; } - if (entry === 'LIBRARY.md') { - for (const m of text.matchAll(LIBRARY_ROW)) { - const name = m[1]; - if (name && name !== 'persona') classes.add(name); - } + 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(), byClass: new Map() }; + 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; } - // 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 } : {}) }); - } + 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); } - // 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; + } + // 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 { classes, byClass }; + // 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 { @@ -229,6 +268,51 @@ 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; diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 471fcd3..fabff2a 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -20,6 +20,7 @@ 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'); @@ -384,6 +385,16 @@ 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. diff --git a/packages/mosaic/src/fleet/persona-contract.spec.ts b/packages/mosaic/src/fleet/persona-contract.spec.ts new file mode 100644 index 0000000..c4c1421 --- /dev/null +++ b/packages/mosaic/src/fleet/persona-contract.spec.ts @@ -0,0 +1,106 @@ +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/.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/.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(''); + }); +}); diff --git a/packages/mosaic/src/fleet/persona-contract.ts b/packages/mosaic/src/fleet/persona-contract.ts new file mode 100644 index 0000000..fab6fd7 --- /dev/null +++ b/packages/mosaic/src/fleet/persona-contract.ts @@ -0,0 +1,63 @@ +/** + * 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; + 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()}`; +}