Files
bootstrap/oc-plugins/mosaic-framework/index.ts
Jason Woltje 24496cea01 feat: add mosaic-framework OpenClaw plugin + install-oc-plugins command
- 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/<repo>
2026-03-09 19:32:01 -05:00

395 lines
15 KiB
TypeScript

/**
* 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<string, unknown> | 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<string, unknown>).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) : "<repo>";
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/<task-slug> -b <branch-name> origin/main
cd ~/src/${projectName}-worktrees/<task-slug>
# ... all work happens here ...
git push origin <branch-name>
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
\`\`\`
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — 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/<repo>-worktrees/<task-slug>`. 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<string, string> = {};
// 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" };
});
}