From 24496cea016171541debda8693b3e8103df30a39 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Mar 2026 19:31:54 -0500 Subject: [PATCH] feat: add mosaic-framework OpenClaw plugin + install-oc-plugins command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oc-plugins/mosaic-framework/ — plugin that mechanically injects Mosaic framework rails into every OpenClaw agent session and ACP worker spawn - before_agent_start: appendSystemContext (static rules, cached) + prependContext (dynamic mission state per agent project root) - subagent_spawning: writes ~/.codex/instructions.md before Codex binary starts; no mission context (workers detect own CWD mission) - Optional gate: requireMission blocks ACP spawns with no active mission Install: mosaic install-oc-plugins --project-root ~/src/ --- oc-plugins/mosaic-framework/README.md | 106 +++++ oc-plugins/mosaic-framework/index.ts | 394 ++++++++++++++++++ .../mosaic-framework/openclaw.plugin.json | 34 ++ oc-plugins/mosaic-framework/package.json | 15 + 4 files changed, 549 insertions(+) create mode 100644 oc-plugins/mosaic-framework/README.md create mode 100644 oc-plugins/mosaic-framework/index.ts create mode 100644 oc-plugins/mosaic-framework/openclaw.plugin.json create mode 100644 oc-plugins/mosaic-framework/package.json diff --git a/oc-plugins/mosaic-framework/README.md b/oc-plugins/mosaic-framework/README.md new file mode 100644 index 0000000..40b6b3a --- /dev/null +++ b/oc-plugins/mosaic-framework/README.md @@ -0,0 +1,106 @@ +# mosaic-framework — OpenClaw Plugin + +Mechanically injects the Mosaic framework contract into every OpenClaw agent session and ACP coding worker spawn. Ensures no worker starts without the mandatory load order, hard gates, worktree rules, and completion gates. + +## What It Does + +### For OC native agents (main, mosaic, dyor, sage, pixels) +Hooks `before_agent_start` and injects via `appendSystemContext`: +- Mosaic global hard rules (compaction-resistant) +- Completion gates (code review ✓ | security review ✓ | tests GREEN ✓ | CI green ✓ | issue closed ✓ | docs updated ✓) +- Worker completion protocol (open PR → fire system event → EXIT — never merge) +- Worktree requirement (`~/src/-worktrees/`, never `/tmp`) + +Also injects dynamic mission state via `prependContext` (re-read each turn from the agent's configured project root). + +### For ACP coding workers (Codex, Claude Code) +Hooks `subagent_spawning` and writes `~/.codex/instructions.md` (or `~/.claude/CLAUDE.md`) **before the process starts**: +- Full runtime contract (mandatory load order, hard gates, mode declaration requirement) +- Global framework rules +- Worktree and completion gate requirements +- Worker reads its own `.mosaic/orchestrator/mission.json` from CWD — no cross-project contamination + +## Installation + +### Automatic (via mosaic install script) +```bash +mosaic install-oc-plugins +``` + +### Manual +```bash +# 1. Copy plugin to extensions directory (already done if cloned from mosaic-bootstrap) +cp -r ~/.config/mosaic/oc-plugins/mosaic-framework ~/.openclaw/extensions/ + +# 2. Register in OpenClaw config +openclaw config patch '{ + "plugins": { + "allow": [...existing..., "mosaic-framework"], + "load": { "paths": [...existing..., "~/.openclaw/extensions/mosaic-framework"] }, + "entries": { + "mosaic-framework": { + "enabled": true, + "config": { + "mosaicHome": "~/.config/mosaic", + "projectRoots": ["~/src/"], + "requireMission": false, + "acpAgentIds": ["codex", "claude"] + } + } + } + } +}' + +# 3. Restart gateway +openclaw gateway restart +``` + +## Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `mosaicHome` | string | `~/.config/mosaic` | Path to Mosaic config home | +| `projectRoots` | string[] | `[]` | Project directories to scan for active missions (used in `before_agent_start` for native agents) | +| `requireMission` | boolean | `false` | If `true`, blocks ACP coding worker spawns when no active mission exists in any project root | +| `injectAgentIds` | string[] | all agents | Limit `before_agent_start` injection to specific agent IDs | +| `acpAgentIds` | string[] | `["codex", "claude"]` | ACP agent IDs that trigger runtime contract injection on spawn | + +## Adding a New Project + +When starting work on a new project, add its root to `projectRoots` in the plugin config: + +```bash +openclaw config patch '{ + "plugins": { + "entries": { + "mosaic-framework": { + "config": { + "projectRoots": ["~/src/mosaic-stack", "~/src/sage-phr-ng", "~/src/new-project"] + } + } + } + } +}' +openclaw gateway restart +``` + +## Packaging + +This plugin lives at `~/.config/mosaic/oc-plugins/mosaic-framework/` in the mosaic-bootstrap distribution. The `mosaic install-oc-plugins` command symlinks it into `~/.openclaw/extensions/` and registers it in `openclaw.json`. + +## Architecture Notes + +- `appendSystemContext` is cached by Anthropic's API (cheaper than per-turn injection) — used for static framework rules +- `prependContext` is fresh per-turn — used for dynamic mission state +- `subagent_spawning` fires synchronously before the external process starts — `~/.codex/instructions.md` is written before the Codex binary reads it +- Mission context is NOT injected in `subagent_spawning` — workers detect their own CWD mission (avoids cross-project contamination when multiple missions are active simultaneously) + +## Files + +``` +mosaic-framework/ +├── index.ts # Plugin implementation +├── openclaw.plugin.json # Plugin manifest +├── package.json # Node package metadata +└── README.md # This file +``` diff --git a/oc-plugins/mosaic-framework/index.ts b/oc-plugins/mosaic-framework/index.ts new file mode 100644 index 0000000..0b249ac --- /dev/null +++ b/oc-plugins/mosaic-framework/index.ts @@ -0,0 +1,394 @@ +/** + * mosaic-framework — OpenClaw Plugin + * + * Mechanically injects the Mosaic framework contract into every agent session + * and ACP coding worker spawn. Two injection paths: + * + * 1. before_agent_start (OC native sessions): + * Returns appendSystemContext with the Mosaic global contract excerpt + * + prependContext with active mission state (dynamic, re-read each turn). + * + * 2. subagent_spawning (ACP worker spawns — Codex, Claude, etc.): + * Writes the full runtime contract to ~/.codex/instructions.md + * (or Claude equivalent) BEFORE the external process starts. + * Optionally blocks spawns when no active mission exists. + */ + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Config types +// --------------------------------------------------------------------------- +interface MosaicFrameworkConfig { + mosaicHome?: string; + projectRoots?: string[]; + requireMission?: boolean; + injectAgentIds?: string[]; + acpAgentIds?: string[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function expandHome(p: string): string { + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + if (p === "~") return os.homedir(); + return p; +} + +function safeRead(filePath: string): string | null { + try { + return readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function safeReadJson(filePath: string): Record | null { + const raw = safeRead(filePath); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Mission detection +// --------------------------------------------------------------------------- + +interface ActiveMission { + name: string; + id: string; + status: string; + projectRoot: string; + milestonesTotal: number; + milestonesCompleted: number; +} + +function findActiveMission(projectRoots: string[]): ActiveMission | null { + for (const root of projectRoots) { + const expanded = expandHome(root); + const missionFile = path.join(expanded, ".mosaic/orchestrator/mission.json"); + if (!existsSync(missionFile)) continue; + + const data = safeReadJson(missionFile); + if (!data) continue; + + const status = String(data.status ?? "inactive"); + if (status !== "active" && status !== "paused") continue; + + const milestones = Array.isArray(data.milestones) ? data.milestones : []; + const completed = milestones.filter( + (m: unknown) => typeof m === "object" && m !== null && (m as Record).status === "completed" + ).length; + + return { + name: String(data.name ?? "unnamed"), + id: String(data.mission_id ?? ""), + status, + projectRoot: expanded, + milestonesTotal: milestones.length, + milestonesCompleted: completed, + }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Build runtime contract (mirrors mosaic's build_runtime_prompt codex) +// --------------------------------------------------------------------------- + +function buildRuntimeContract( + mosaicHome: string, + mission: ActiveMission | null, + projectRoot?: string +): string { + const sections: string[] = []; + + // 1. Active mission block (FIRST — hard gate) + if (mission) { + sections.push(`# ACTIVE MISSION — HARD GATE (Read Before Anything Else) + +An active orchestration mission exists in this project. This is a BLOCKING requirement. + +**Mission:** ${mission.name} +**ID:** ${mission.id} +**Status:** ${mission.status} +**Project Root:** ${mission.projectRoot} +**Milestones:** ${mission.milestonesCompleted} / ${mission.milestonesTotal} completed + +## MANDATORY — Before ANY Response + +You MUST complete these steps before ANY response, including simple greetings: + +1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol) +2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria +3. Read the latest scratchpad in \`docs/scratchpads/\` for session history and decisions +4. Read \`docs/TASKS.md\` for current task state (what is done, what is next) +5. Acknowledge mission state to the user before proceeding + +No tool call or implementation step may occur before the mode declaration line.`); + } + + // 2. Mosaic Runtime Contract (from ~/.config/mosaic/runtime/codex/RUNTIME.md) + const runtimeFile = path.join(mosaicHome, "runtime/codex/RUNTIME.md"); + const runtimeContent = safeRead(runtimeFile); + if (runtimeContent) { + sections.push(runtimeContent.trim()); + } + + // 3. Global AGENTS.md hard rules + const agentsFile = path.join(mosaicHome, "AGENTS.md"); + const agentsContent = safeRead(agentsFile); + if (agentsContent) { + // Extract just the hard rules section to keep the contract focused + const hardRulesMatch = agentsContent.match( + /## ⛔ HARD RULES[\s\S]*?(?=^## (?!⛔)|\Z)/m + ); + if (hardRulesMatch) { + sections.push(`# Mosaic Global Agent Contract — Hard Rules\n\n${hardRulesMatch[0].trim()}`); + } else { + // Fallback: include first 200 lines + const lines = agentsContent.split("\n").slice(0, 200).join("\n"); + sections.push(`# Mosaic Global Agent Contract\n\n${lines}`); + } + } + + // 4. Mode declaration requirement + sections.push(`# Required Mode Declaration + +First assistant response MUST start with exactly one mode declaration line: +- Orchestration mission: \`Now initiating Orchestrator mode...\` +- Implementation mission: \`Now initiating Delivery mode...\` +- Review-only mission: \`Now initiating Review mode...\` + +Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations. +For required push/merge/issue-close/release actions, execute without routine confirmation prompts.`); + + // 5. Worktree requirement (critical — has been violated repeatedly) + const projectName = projectRoot ? path.basename(projectRoot) : ""; + sections.push(`# Git Worktree Requirement — MANDATORY + +Every agent that touches a git repo MUST use a worktree. NO EXCEPTIONS. + +\`\`\`bash +cd ~/src/${projectName} +git fetch origin +mkdir -p ~/src/${projectName}-worktrees +git worktree add ~/src/${projectName}-worktrees/ -b origin/main +cd ~/src/${projectName}-worktrees/ +# ... all work happens here ... +git push origin +cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/ +\`\`\` + +Worktrees path: \`~/src/-worktrees/\` — NEVER use /tmp.`); + + // 6. Completion gates + sections.push(`# Completion Gates — ENFORCED + +A task is NOT done until ALL of these pass: +1. Code review — independent review of every changed file +2. Security review — auth, input validation, error leakage +3. QA/tests — lint + typecheck + unit tests GREEN +4. CI green — pipeline passes after merge +5. Issue closed — linked issue closed in Gitea +6. Docs updated — API/auth/schema changes require doc update + +Workers NEVER merge PRs. Ever. Open PR → fire system event → EXIT.`); + + return sections.join("\n\n---\n\n"); +} + +// --------------------------------------------------------------------------- +// Build mission context block (dynamic — injected as prependContext) +// --------------------------------------------------------------------------- + +function buildMissionContext(mission: ActiveMission): string { + const tasksFile = path.join(mission.projectRoot, "docs/TASKS.md"); + const tasksContent = safeRead(tasksFile); + + // Extract just the next not-started task to keep context compact + let nextTask = ""; + if (tasksContent) { + const notStartedMatch = tasksContent.match( + /\|[^|]*\|\s*not[-\s]?started[^|]*\|[^|]*\|[^|]*\|/i + ); + if (notStartedMatch) { + nextTask = `\n**Next task:** ${notStartedMatch[0].replace(/\|/g, " ").trim()}`; + } + } + + return `[Mosaic Framework] Active mission: **${mission.name}** (${mission.id}) +Status: ${mission.status} | Milestones: ${mission.milestonesCompleted}/${mission.milestonesTotal} +Project: ${mission.projectRoot}${nextTask} + +Read ORCHESTRATOR-PROTOCOL.md + TASKS.md before proceeding.`; +} + +// --------------------------------------------------------------------------- +// Write runtime contract to ACP worker config files +// --------------------------------------------------------------------------- + +function writeCodexInstructions(mosaicHome: string, mission: ActiveMission | null): void { + const contract = buildRuntimeContract(mosaicHome, mission, mission?.projectRoot); + const dest = path.join(os.homedir(), ".codex/instructions.md"); + mkdirSync(path.dirname(dest), { recursive: true }); + writeFileSync(dest, contract, "utf8"); +} + +function writeClaudeInstructions(mosaicHome: string, mission: ActiveMission | null): void { + // Claude Code reads from ~/.claude/CLAUDE.md + const contract = buildRuntimeContract(mosaicHome, mission, mission?.projectRoot); + const dest = path.join(os.homedir(), ".claude/CLAUDE.md"); + mkdirSync(path.dirname(dest), { recursive: true }); + // Only write if different to avoid unnecessary disk writes + const existing = safeRead(dest); + if (existing !== contract) { + writeFileSync(dest, contract, "utf8"); + } +} + +// --------------------------------------------------------------------------- +// Build static framework preamble for OC native agents (appendSystemContext) +// --------------------------------------------------------------------------- + +function buildFrameworkPreamble(mosaicHome: string): string { + const agentsFile = path.join(mosaicHome, "AGENTS.md"); + const agentsContent = safeRead(agentsFile); + + const lines: string[] = [ + "# Mosaic Framework Contract (Auto-injected)", + "", + "You are operating under the Mosaic multi-agent framework.", + "The following rules are MANDATORY and OVERRIDE any conflicting defaults.", + "", + ]; + + if (agentsContent) { + // Extract hard rules section + const hardRulesMatch = agentsContent.match( + /## ⛔ HARD RULES[\s\S]*?(?=^## [^⛔]|\z)/m + ); + if (hardRulesMatch) { + lines.push("## Hard Rules (Compaction-Resistant)\n"); + lines.push(hardRulesMatch[0].trim()); + } + } + + lines.push( + "", + "## Completion Gates", + "A task is NOT done until: code review ✓ | security review ✓ | tests GREEN ✓ | CI green ✓ | issue closed ✓ | docs updated ✓", + "", + "## Worker Completion Protocol", + "Workers NEVER merge PRs. Implement → lint/typecheck → push branch → open PR → fire system event → EXIT.", + "", + "## Worktree Requirement", + "All code work MUST use a git worktree at `~/src/-worktrees/`. Never use /tmp." + ); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Plugin registration +// --------------------------------------------------------------------------- + +export default function register(api: OpenClawPluginApi) { + const cfg = (api.config ?? {}) as MosaicFrameworkConfig; + + const mosaicHome = expandHome(cfg.mosaicHome ?? "~/.config/mosaic"); + const projectRoots = (cfg.projectRoots ?? []).map(expandHome); + const requireMission = cfg.requireMission ?? false; + const injectAgentIds = cfg.injectAgentIds ?? null; // null = all agents + const acpAgentIds = new Set(cfg.acpAgentIds ?? ["codex", "claude"]); + + // Pre-build the static framework preamble (injected once per session start) + const frameworkPreamble = buildFrameworkPreamble(mosaicHome); + + // --------------------------------------------------------------------------- + // Hook 1: before_agent_start — inject into OC native agent sessions + // --------------------------------------------------------------------------- + api.on("before_agent_start", async (event, ctx) => { + const agentId = ctx.agentId ?? "unknown"; + + // Skip if this agent is not in the inject list (when configured) + if (injectAgentIds !== null && !injectAgentIds.includes(agentId)) { + return {}; + } + + // Skip ACP worker sessions — they get injected via subagent_spawning instead + if (acpAgentIds.has(agentId)) { + return {}; + } + + // Read active mission for this turn (dynamic) + const mission = projectRoots.length > 0 ? findActiveMission(projectRoots) : null; + + const result: Record = {}; + + // Static framework preamble → appendSystemContext (cached by provider) + result.appendSystemContext = frameworkPreamble; + + // Dynamic mission state → prependContext (fresh each turn) + if (mission) { + result.prependContext = buildMissionContext(mission); + } + + return result; + }); + + // --------------------------------------------------------------------------- + // Hook 2: subagent_spawning — inject runtime contract into ACP workers + // + // Mission context is intentionally NOT injected here. The runtime contract + // includes instructions to read .mosaic/orchestrator/mission.json from the + // worker's own CWD — so the worker picks up the correct project mission + // itself. Injecting a mission here would risk cross-contamination when + // multiple projects have active missions simultaneously. + // --------------------------------------------------------------------------- + api.on("subagent_spawning", async (event, ctx) => { + const childAgentId = event.agentId ?? ""; + + // Only act on ACP coding worker spawns + if (!acpAgentIds.has(childAgentId)) { + return { status: "ok" }; + } + + // Gate: block spawn if requireMission is true and no active mission found in any root + if (requireMission) { + const mission = projectRoots.length > 0 ? findActiveMission(projectRoots) : null; + if (!mission) { + return { + status: "error", + error: `[mosaic-framework] No active Mosaic mission found. Run 'mosaic coord init' in your project directory first. Scanned: ${projectRoots.join(", ")}`, + }; + } + } + + // Write runtime contract (global framework rules + load order, no mission context) + // The worker will detect its own mission from .mosaic/orchestrator/mission.json in its CWD. + try { + if (childAgentId === "codex") { + writeCodexInstructions(mosaicHome, null); + } else if (childAgentId === "claude") { + writeClaudeInstructions(mosaicHome, null); + } + } catch (err) { + // Log but don't block — better to have a worker without full rails than no worker + api.logger?.warn( + `[mosaic-framework] Failed to write runtime contract for ${childAgentId}: ${String(err)}` + ); + } + + return { status: "ok" }; + }); +} diff --git a/oc-plugins/mosaic-framework/openclaw.plugin.json b/oc-plugins/mosaic-framework/openclaw.plugin.json new file mode 100644 index 0000000..f3ff502 --- /dev/null +++ b/oc-plugins/mosaic-framework/openclaw.plugin.json @@ -0,0 +1,34 @@ +{ + "id": "mosaic-framework", + "name": "Mosaic Framework", + "description": "Mechanically injects Mosaic rails and mission context into all agent sessions and ACP worker spawns. Ensures no worker starts without the framework contract.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "mosaicHome": { + "type": "string", + "description": "Path to the Mosaic config home (default: ~/.config/mosaic)" + }, + "projectRoots": { + "type": "array", + "items": { "type": "string" }, + "description": "List of project root paths to scan for active missions. Plugin checks each for .mosaic/orchestrator/mission.json." + }, + "requireMission": { + "type": "boolean", + "description": "If true, ACP coding worker spawns are BLOCKED when no active Mosaic mission exists in any configured project root. Default: false." + }, + "injectAgentIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Agent IDs that receive framework context via before_agent_start (appendSystemContext). Default: all agents." + }, + "acpAgentIds": { + "type": "array", + "items": { "type": "string" }, + "description": "ACP agent IDs that trigger runtime contract injection (subagent_spawning). Default: ['codex', 'claude']." + } + } + } +} diff --git a/oc-plugins/mosaic-framework/package.json b/oc-plugins/mosaic-framework/package.json new file mode 100644 index 0000000..1f60650 --- /dev/null +++ b/oc-plugins/mosaic-framework/package.json @@ -0,0 +1,15 @@ +{ + "name": "mosaic-framework", + "version": "0.1.0", + "type": "module", + "main": "index.ts", + "description": "Injects Mosaic framework rails, runtime contract, and active mission context into all OpenClaw agent sessions and ACP subagent spawns.", + "openclaw": { + "extensions": [ + "./index.ts" + ] + }, + "devDependencies": { + "openclaw": "*" + } +}