From 23343bb7f0ad34ca460827a21146fa44663b5000 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 22 Jun 2026 02:16:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(mosaic):=20P5=20=E2=80=94=20overlay=20comp?= =?UTF-8?q?oser=20(compose-contract=20+=20*.local=20overlays)=20(#605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- docs/TASKS.md | 6 + docs/fleet/PRD-fleet-suite.md | 70 ++++++----- 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 ++++++++++-- 6 files changed, 274 insertions(+), 45 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/fleet/PRD-fleet-suite.md b/docs/fleet/PRD-fleet-suite.md index 4797fd2..bba8ee9 100644 --- a/docs/fleet/PRD-fleet-suite.md +++ b/docs/fleet/PRD-fleet-suite.md @@ -20,39 +20,43 @@ functional, we use the fleet itself to continue the work. ## Requirements ### A. Configure-without-AI CLI -| ID | Requirement | -|---|---| -| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). | -| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. | -| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). | -| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). | -| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. | -| R10 | A set of **recommended configurations (presets)** ships for easy duplication. | -| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). | -| R17 | Fleet controls are **simple and intuitive**. | + +| ID | Requirement | +| --- | ------------------------------------------------------------------------------------------------------------- | +| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). | +| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. | +| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). | +| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). | +| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. | +| R10 | A set of **recommended configurations (presets)** ships for easy duplication. | +| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). | +| R17 | Fleet controls are **simple and intuitive**. | ### B. Comms & orchestrator chat-ops -| ID | Requirement | -|---|---| -| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. | -| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. | -| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. | + +| ID | Requirement | +| --- | --------------------------------------------------------------------------------------------------------------------------------- | +| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. | +| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. | +| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. | | R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. | ### C. Runtime, health, lifecycle -| ID | Requirement | -|---|---| -| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. | + +| ID | Requirement | +| --- | ---------------------------------------------------------------------------------- | +| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. | | R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. | -| R14 | The **Pi harness is customized** for proper tool usage, etc. | -| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. | +| R14 | The **Pi harness is customized** for proper tool usage, etc. | +| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. | ### D. Surfaces, testing, docs -| ID | Requirement | -|---|---| + +| ID | Requirement | +| --- | ----------------------------------------------------------------------------------- | | R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. | -| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. | -| R12 | **Documentation**: how to install, configure, and use the fleet. | +| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. | +| R12 | **Documentation**: how to install, configure, and use the fleet. | ## Architecture / approach @@ -65,15 +69,15 @@ functional, we use the fleet itself to continue the work. ## Phases (incremental, each shippable) -| Phase | Deliverable | Notes | -|---|---|---| -| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 | -| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 | -| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 | -| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 | -| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 | -| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 | -| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) | +| Phase | Deliverable | Notes | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 | +| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 | +| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 | +| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 | +| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 | +| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 | +| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) | ## Work division (proposed — confirm with dragon-lin) 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')