- plugins/macp/src/index.ts: use createRequire + dynamic import() for OC SDK - plugins/macp/src/acp-runtime-types.ts: local ACP runtime type definitions - plugins/macp/src/macp-runtime.ts: DEFAULT_REPO_ROOT and PI_RUNNER_PATH use os.homedir() instead of hardcoded /home/user/ - plugins/mosaic-framework/src/index.ts: removed hardcoded SDK import - No hardcoded /home/ paths remain in any plugin source file - Plugin works on any machine with openclaw installed globally
487 lines
18 KiB
TypeScript
487 lines
18 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 os from 'node:os';
|
|
import path from 'node:path';
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
// OpenClawPluginApi type — the OC plugin loader provides the actual api object at runtime
|
|
type OpenClawPluginApi = any;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeReadNdjson(filePath: string, limit = 10): Record<string, unknown>[] {
|
|
const raw = safeRead(filePath);
|
|
if (!raw) return [];
|
|
|
|
const parsed: Record<string, unknown>[] = [];
|
|
for (const line of raw.split('\n')) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const item = JSON.parse(line) as unknown;
|
|
if (typeof item === 'object' && item !== null) {
|
|
parsed.push(item as Record<string, unknown>);
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return parsed.slice(-limit);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
function findMacpProjectRoot(projectRoots: string[]): string | null {
|
|
for (const root of projectRoots) {
|
|
const expanded = expandHome(root);
|
|
const configPath = path.join(expanded, '.mosaic/orchestrator/config.json');
|
|
const config = safeReadJson(configPath);
|
|
if (config?.enabled) {
|
|
return expanded;
|
|
}
|
|
}
|
|
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.`;
|
|
}
|
|
|
|
function buildMacpContext(projectRoot: string): string | null {
|
|
const orchDir = path.join(projectRoot, '.mosaic/orchestrator');
|
|
const configPath = path.join(orchDir, 'config.json');
|
|
if (!existsSync(configPath)) return null;
|
|
|
|
const config = safeReadJson(configPath);
|
|
if (!config?.enabled) return null;
|
|
|
|
const tasksPath = path.join(orchDir, 'tasks.json');
|
|
const tasksPayload = safeReadJson(tasksPath);
|
|
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
|
|
const counts = {
|
|
pending: 0,
|
|
running: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
escalated: 0,
|
|
};
|
|
|
|
for (const task of tasks) {
|
|
if (typeof task !== 'object' || task === null) continue;
|
|
const status = String((task as Record<string, unknown>).status ?? 'pending');
|
|
if (status in counts) {
|
|
counts[status as keyof typeof counts] += 1;
|
|
}
|
|
}
|
|
|
|
const lines = [
|
|
'[MACP Queue]',
|
|
`Queue: pending=${counts.pending} running=${counts.running} completed=${counts.completed} failed=${counts.failed} escalated=${counts.escalated}`,
|
|
];
|
|
|
|
const events = safeReadNdjson(path.join(orchDir, 'events.ndjson'));
|
|
if (events.length > 0) {
|
|
lines.push('Recent activity:');
|
|
for (const event of events) {
|
|
const timestamp = String(event.timestamp ?? '?');
|
|
const eventType = String(event.event_type ?? 'event');
|
|
const taskId = String(event.task_id ?? '-');
|
|
const message = String(event.message ?? '').trim();
|
|
lines.push(`- ${timestamp} | ${eventType} | ${taskId}${message ? ` | ${message}` : ''}`);
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
api.on('before_agent_start', async (_event: any, ctx: any) => {
|
|
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/MACP state → prependContext (fresh each turn)
|
|
const sections: string[] = [];
|
|
if (mission) {
|
|
sections.push(buildMissionContext(mission));
|
|
}
|
|
const macpProjectRoot = mission?.projectRoot ?? findMacpProjectRoot(projectRoots);
|
|
if (macpProjectRoot) {
|
|
const macpContext = buildMacpContext(macpProjectRoot);
|
|
if (macpContext) {
|
|
sections.push(macpContext);
|
|
}
|
|
}
|
|
if (sections.length > 0) {
|
|
result.prependContext = sections.join('\n\n');
|
|
}
|
|
|
|
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.
|
|
// ---------------------------------------------------------------------------
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
api.on('subagent_spawning', async (event: any, _ctx: any) => {
|
|
const childAgentId = (event as Record<string, unknown>).agentId as string | undefined;
|
|
if (!childAgentId) return { status: 'ok' };
|
|
|
|
// 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' };
|
|
});
|
|
}
|