From bca7fc4cf2bcbbe19e9fe28b241be49fcf8097b7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 20:54:30 -0500 Subject: [PATCH] =?UTF-8?q?feat(mosaic):=20P5=20overlay=20composer=20?= =?UTF-8?q?=E2=80=94=20compose-contract=20+=20*.local=20overlays=20(#604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements R7 + R8 of the Constitution alpha: launcher-composed operator overlays so a user's *.local deltas reach the model as one pre-merged blob (DESIGN §3.2), without re-injecting full base prose. - composeContract(runtime, mosaicHome=MOSAIC_HOME): testable pure fn extracted from the launcher prompt assembly; buildRuntimePrompt delegates to it. - Overlay logic (deltas by value, base files keep residency): - USER.local.md -> appended under the # User Profile block (USER is injected) - SOUL.local.md + STANDARDS.local.md -> trailing # Operator Overlays section (their bases are load-on-demand; only the small delta is injected, so the P3 byte-budget tiering is preserved). Whitespace-only overlays ignored. - Absent *.local -> base-only, automatically. - New command: mosaic compose-contract -> prints the composed blob to stdout (inspection / mosaic doctor / diffing / the composer test). - defaults/AGENTS.md: bare-launch self-load fallback now nudges to relaunch via mosaic (or mosaic doctor) when *.local overlays exist (R7 known-limit). - compose-contract.spec.ts (7 tests): per-tier anchors, Tier-3 byte-equality (injected L0 == CONSTITUTION.md on disk), overlay present/absent, per-harness. ASSUMPTION: overlays injected as deltas-by-value under labeled sections; bases keep existing residency. Rationale in docs/scratchpads/p5-overlay-composer.md. Verified: 7 composer + 26 launch tests pass; tsc-clean on touched files; prettier clean. Defers to P6: CONTRIBUTING.md + compliance matrix, line-count CI ceiling, aiguide reconcile, alpha tag. Refs #604, #542 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83 --- docs/TASKS.md | 6 + docs/scratchpads/p5-overlay-composer.md | 43 +++++++ packages/mosaic/framework/defaults/AGENTS.md | 5 +- .../src/commands/compose-contract.spec.ts | 118 ++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 77 ++++++++++-- 5 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 docs/scratchpads/p5-overlay-composer.md create mode 100644 packages/mosaic/src/commands/compose-contract.spec.ts diff --git a/docs/TASKS.md b/docs/TASKS.md index 464e5a0..238b838 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -45,3 +45,9 @@ Active workstream is **W1 — Federation v1**. Workers should: - Status: PR open, awaiting maintainer merge ratification (fleet-governing change). - Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact. - Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md. + +## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer + +- Status: in progress. R7 (compose-contract) + R8 (cross-harness) + R9 (composer test). +- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract ` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md. +- Defer to P6: CONTRIBUTING.md + compliance matrix; line-count CI ceiling; aiguide; alpha tag. diff --git a/docs/scratchpads/p5-overlay-composer.md b/docs/scratchpads/p5-overlay-composer.md new file mode 100644 index 0000000..c2fcb88 --- /dev/null +++ b/docs/scratchpads/p5-overlay-composer.md @@ -0,0 +1,43 @@ +# P5 — Overlay composer + cross-harness (compose-contract) + +- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha +- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test) +- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`) + +## Locked design (sequential-thinking) + +Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate + +CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are +read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support. + +**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under +labeled sections; base files keep their existing residency. + +- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected). +- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are + load-on-demand, so only the small delta is injected — not the full base prose). +- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving + the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base + layers first, operator overlays at recency. +- Base-only is automatic when a `.local` file is absent (`readOptional`). + +## Plan + +| # | Task | File | +| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` | +| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` | +| 3 | `mosaic compose-contract ` command → prints blob to stdout | `src/commands/launch.ts` | +| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` | +| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` | + +## Deferred to P6 + +CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile; +alpha tag `mosaic-vX.Y.Z-alpha`. + +## Status + +- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS) +- [ ] Implementation (tasks 1–5) +- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut) diff --git a/packages/mosaic/framework/defaults/AGENTS.md b/packages/mosaic/framework/defaults/AGENTS.md index beb3285..eb488c3 100755 --- a/packages/mosaic/framework/defaults/AGENTS.md +++ b/packages/mosaic/framework/defaults/AGENTS.md @@ -9,7 +9,10 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.) 1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime contract (injected by `mosaic` launch) — do not re-read those. **If you were launched bare** (a harness started without `mosaic`, so the law is NOT in your context), read - `~/.config/mosaic/CONSTITUTION.md` now, before your first action. + `~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets + **base contracts only** — operator overlays (`*.local.md`) are composed by the launcher, so if + `SOUL.local.md`/`USER.local.md`/`STANDARDS.local.md` exist, relaunch via `mosaic ` (or run + `mosaic doctor`) to pick them up. 2. Read `SOUL.md` (agent persona — small, once). 3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter). 4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front. diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts new file mode 100644 index 0000000..f60f5a8 --- /dev/null +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { composeContract } from './launch.js'; + +/** + * Composer unit test (R7/R8/R9): asserts the launcher-composed runtime contract + * + * - includes the per-tier anchors (CONSTITUTION / AGENTS / USER / runtime), + * - keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3 + * byte-equality — the bare-launch fallback read must match what is injected), + * - merges `*.local.md` operator overlays as deltas-by-value, and omits them + * entirely when absent (base-only), + * - selects the correct per-harness RUNTIME.md. + * + * `composeContract` takes `mosaicHome` as a param, so each test runs against an + * isolated fixture home. We also chdir to an empty temp cwd so the cwd-relative + * mission/PRD blocks contribute nothing (deterministic output). + */ + +const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n'; +const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n'; +const USER = '# operator\n\nName: Test Operator\n'; +const TOOLS = '# tools index\n'; + +function makeHome(): { home: string; root: string } { + const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-')); + const home = join(root, 'mosaic-home'); + for (const h of ['claude', 'codex', 'opencode', 'pi']) { + mkdirSync(join(home, 'runtime', h), { recursive: true }); + writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`); + } + writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION); + writeFileSync(join(home, 'AGENTS.md'), AGENTS); + writeFileSync(join(home, 'USER.md'), USER); + writeFileSync(join(home, 'TOOLS.md'), TOOLS); + return { home, root }; +} + +describe('composeContract — overlay composer', () => { + let fixture: ReturnType; + let prevCwd: string; + let cwdDir: string; + + beforeEach(() => { + fixture = makeHome(); + prevCwd = process.cwd(); + cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-')); + process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks + }); + + afterEach(() => { + process.chdir(prevCwd); + rmSync(fixture.root, { recursive: true, force: true }); + rmSync(cwdDir, { recursive: true, force: true }); + }); + + it('includes the per-tier anchors and the selected harness runtime', () => { + const out = composeContract('claude', fixture.home); + expect(out).toContain('GATE-1: the non-negotiable law.'); // L0 + expect(out).toContain('Mosaic Agent Dispatcher'); // AGENTS + expect(out).toContain('# User Profile'); // USER header + expect(out).toContain('Name: Test Operator'); // USER body + expect(out).toContain('# Runtime-Specific Contract'); + expect(out).toContain('# claude runtime contract'); + }); + + it('keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3)', () => { + const out = composeContract('pi', fixture.home); + const onDisk = readFileSync(join(fixture.home, 'CONSTITUTION.md'), 'utf-8'); + // The injected L0 must be a byte-equal substring of the composed blob, so a + // bare-launch fallback read of CONSTITUTION.md matches what was injected. + expect(out.includes(onDisk)).toBe(true); + }); + + it('is base-only when no *.local overlays exist', () => { + const out = composeContract('claude', fixture.home); + expect(out).not.toContain('# Operator Overlays'); + expect(out).not.toContain('Operator Overlay (USER.local.md)'); + expect(out).not.toContain('Persona Overlay'); + expect(out).not.toContain('Standards Overlay'); + }); + + it('merges USER.local.md directly under the operator profile', () => { + writeFileSync(join(fixture.home, 'USER.local.md'), 'Prefer terse status updates.\n'); + const out = composeContract('claude', fixture.home); + expect(out).toContain('## Operator Overlay (USER.local.md)'); + expect(out).toContain('Prefer terse status updates.'); + // Overlay appears AFTER its base profile. + expect(out.indexOf('# User Profile')).toBeLessThan( + out.indexOf('## Operator Overlay (USER.local.md)'), + ); + }); + + it('merges SOUL.local.md + STANDARDS.local.md as deltas in the Operator Overlays block', () => { + writeFileSync(join(fixture.home, 'SOUL.local.md'), 'Tone: dry and direct.\n'); + writeFileSync(join(fixture.home, 'STANDARDS.local.md'), 'Require 90% coverage on auth code.\n'); + const out = composeContract('claude', fixture.home); + expect(out).toContain('# Operator Overlays'); + expect(out).toContain('## Persona Overlay (SOUL.local.md)'); + expect(out).toContain('Tone: dry and direct.'); + expect(out).toContain('## Standards Overlay (STANDARDS.local.md)'); + expect(out).toContain('Require 90% coverage on auth code.'); + }); + + it('ignores whitespace-only *.local overlays (no empty overlay section)', () => { + writeFileSync(join(fixture.home, 'SOUL.local.md'), ' \n\n'); + const out = composeContract('claude', fixture.home); + expect(out).not.toContain('# Operator Overlays'); + }); + + it('selects a different RUNTIME.md per harness', () => { + expect(composeContract('codex', fixture.home)).toContain('# codex runtime contract'); + expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract'); + expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract'); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index cf3a05c..f47cb13 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -291,12 +291,23 @@ function buildPrdBlock(): string { // ─── Runtime prompt builder ────────────────────────────────────────────────── -function buildRuntimePrompt(runtime: RuntimeName): string { +/** + * Compose the full runtime contract for a harness: the resident-by-value core + * (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays + * (`*.local.md` deltas), merged in precedence order so the model gets one + * pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value; + * base files keep their existing residency (USER injected; SOUL/STANDARDS are + * load-on-demand, so only their small `.local` deltas are injected here). + * + * `mosaicHome` is parameterized for testability; production callers use the + * module-level default. + */ +export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string { const runtimeContractPaths: Record = { - claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'), - codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'), - opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'), - pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'), + claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'), + codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'), + opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'), + pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'), }; const runtimeFile = runtimeContractPaths[runtime]; @@ -331,27 +342,55 @@ For required push/merge/issue-close/release actions, execute without routine con `); // CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of - // pre-constitution installs that have not been re-seeded yet. - const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md')); + // pre-constitution installs that have not been re-seeded yet. Injected by + // value verbatim so the bare-launch fallback read is byte-equal (R8). + const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md')); if (constitution) parts.push(constitution); // AGENTS.md - parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8')); + parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8')); - // USER.md - const user = readOptional(join(MOSAIC_HOME, 'USER.md')); + // USER.md (+ USER.local.md operator overlay, appended directly under the + // profile its base owns). + const user = readOptional(join(mosaicHome, 'USER.md')); if (user) parts.push('\n\n# User Profile\n\n' + user); + const userLocal = readOptional(join(mosaicHome, 'USER.local.md')); + if (userLocal.trim()) { + parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal); + } // TOOLS.md - const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md')); + const tools = readOptional(join(mosaicHome, 'TOOLS.md')); if (tools) parts.push('\n\n# Machine Tools\n\n' + tools); + // Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS): + // inject only the small `.local` delta by value so the customization reaches + // the model without re-injecting the full base prose (preserves the byte + // budget). Absent `.local` files → base-only, automatically (R7 §3.2). + const overlayBlocks: string[] = []; + const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md')); + if (soulLocal.trim()) { + overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim()); + } + const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md')); + if (standardsLocal.trim()) { + overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim()); + } + if (overlayBlocks.length > 0) { + parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n')); + } + // Runtime-specific contract parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); return parts.join('\n'); } +/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */ +function buildRuntimePrompt(runtime: RuntimeName): string { + return composeContract(runtime); +} + // ─── Session lock ──────────────────────────────────────────────────────────── function writeSessionLock(runtime: string): void { @@ -976,6 +1015,22 @@ export function registerLaunchCommands(program: Command): void { launchRuntime(runtime, extraArgs, yolo); }); + // compose-contract — emit the composed runtime contract (base + operator + // overlays) for a harness to stdout, without launching. For inspection, + // `mosaic doctor`, diffing, and the composer test (R7). + program + .command('compose-contract ') + .description('Print the composed runtime contract (base + *.local overlays) for a harness') + .action((harness: string) => { + const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi']; + if (!valid.includes(harness as RuntimeName)) { + console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`); + process.exitCode = 64; + return; + } + process.stdout.write(composeContract(harness as RuntimeName)); + }); + // Coord (mission orchestrator) program .command('coord')