- 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>
395 lines
15 KiB
TypeScript
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" };
|
|
});
|
|
}
|