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>
This commit is contained in:
394
oc-plugins/mosaic-framework/index.ts
Normal file
394
oc-plugins/mosaic-framework/index.ts
Normal file
@@ -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<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" };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user