/** * Native runtime launcher — replaces the bash mosaic-launch script. * * Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md + * TOOLS.md + mission context + PRD status, then exec's into the target CLI. */ import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import type { Command } from 'commander'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; const RUNTIME_LABELS: Record = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', pi: 'Pi', }; // ─── Pre-flight checks ────────────────────────────────────────────────────── function checkMosaicHome(): void { if (!existsSync(MOSAIC_HOME)) { console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`); console.error( '[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)', ); process.exit(1); } } function checkFile(path: string, label: string): void { if (!existsSync(path)) { console.error(`[mosaic] ERROR: ${label} not found: ${path}`); process.exit(1); } } function checkRuntime(cmd: string): void { try { execSync(`which ${cmd}`, { stdio: 'ignore' }); } catch { console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`); console.error(`[mosaic] Install ${cmd} before launching.`); process.exit(1); } } function checkSoul(): void { const soulPath = join(MOSAIC_HOME, 'SOUL.md'); if (!existsSync(soulPath)) { console.log('[mosaic] SOUL.md not found. Running setup wizard...'); // Prefer the TypeScript wizard (idempotent, detects existing files) try { const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], { stdio: 'inherit', }); if (result.status === 0 && existsSync(soulPath)) return; } catch { // Fall through to legacy init } // Fallback: legacy bash mosaic-init const initBin = fwScript('mosaic-init'); if (existsSync(initBin)) { spawnSync(initBin, [], { stdio: 'inherit' }); } else { console.error('[mosaic] Setup failed. Run: mosaic wizard'); process.exit(1); } } } function checkSequentialThinking(runtime: string): void { const checker = fwScript('mosaic-ensure-sequential-thinking'); if (!existsSync(checker)) return; // Skip if checker doesn't exist const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' }); if (result.status !== 0) { console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`); process.exit(1); } } // ─── File helpers ──────────────────────────────────────────────────────────── function readOptional(path: string): string { try { return readFileSync(path, 'utf-8'); } catch { return ''; } } function readJson(path: string): Record | null { try { return JSON.parse(readFileSync(path, 'utf-8')) as Record; } catch { return null; } } // ─── Mission context ───────────────────────────────────────────────────────── interface MissionInfo { name: string; id: string; status: string; milestoneCount: number; completedCount: number; } function detectMission(): MissionInfo | null { const missionFile = '.mosaic/orchestrator/mission.json'; const data = readJson(missionFile); if (!data) return null; const status = String(data['status'] ?? 'inactive'); if (status !== 'active' && status !== 'paused') return null; const milestones = Array.isArray(data['milestones']) ? data['milestones'] : []; const completed = milestones.filter( (m) => typeof m === 'object' && m !== null && (m as Record)['status'] === 'completed', ); return { name: String(data['name'] ?? 'unnamed'), id: String(data['mission_id'] ?? ''), status, milestoneCount: milestones.length, completedCount: completed.length, }; } function buildMissionBlock(mission: MissionInfo): string { return `# 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} **Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed ## MANDATORY — Before ANY Response to the User You MUST complete these steps before responding to any user message, 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, decisions, and corrections 4. Read \`docs/TASKS.md\` for current task state (what is done, what is next) 5. After reading all four, acknowledge the mission state to the user before proceeding If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed. `; } // ─── PRD status ────────────────────────────────────────────────────────────── function buildPrdBlock(): string { const prdFile = 'docs/PRD.md'; if (!existsSync(prdFile)) return ''; const content = readFileSync(prdFile, 'utf-8'); const patterns = [ /^#{2,3} .*(problem statement|objective)/im, /^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im, /^#{2,3} .*(user stor|stakeholder|user.*requirement)/im, /^#{2,3} .*functional requirement/im, /^#{2,3} .*non.functional/im, /^#{2,3} .*acceptance criteria/im, /^#{2,3} .*(technical consideration|constraint|dependenc)/im, /^#{2,3} .*(risk|open question)/im, /^#{2,3} .*(success metric|test|verification)/im, /^#{2,3} .*(milestone|delivery|scope version)/im, ]; let sections = 0; for (const pattern of patterns) { if (pattern.test(content)) sections++; } const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length; const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready'; return ` # PRD Status - **File:** docs/PRD.md - **Status:** ${status} - **Assumptions:** ${assumptions} `; } // ─── Runtime prompt builder ────────────────────────────────────────────────── function buildRuntimePrompt(runtime: RuntimeName): string { const runtimeContractPaths: Record = { claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'), codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'), opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'), pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'), }; const runtimeFile = runtimeContractPaths[runtime]; checkFile(runtimeFile, `Runtime contract for ${runtime}`); const parts: string[] = []; // Mission context (injected first) const mission = detectMission(); if (mission) { parts.push(buildMissionBlock(mission)); } // PRD status const prdBlock = buildPrdBlock(); if (prdBlock) parts.push(prdBlock); // Hard gate parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate) This contract is injected by \`mosaic\` launch and is mandatory. First assistant response MUST start with exactly one mode declaration line: 1. Orchestration mission: \`Now initiating Orchestrator mode...\` 2. Implementation mission: \`Now initiating Delivery mode...\` 3. Review-only mission: \`Now initiating Review mode...\` No tool call or implementation step may occur before that first line. Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations. For required push/merge/issue-close/release actions, execute without routine confirmation prompts. `); // AGENTS.md parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8')); // USER.md const user = readOptional(join(MOSAIC_HOME, 'USER.md')); if (user) parts.push('\n\n# User Profile\n\n' + user); // TOOLS.md const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md')); if (tools) parts.push('\n\n# Machine Tools\n\n' + tools); // Runtime-specific contract parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); return parts.join('\n'); } // ─── Session lock ──────────────────────────────────────────────────────────── function writeSessionLock(runtime: string): void { const missionFile = '.mosaic/orchestrator/mission.json'; const lockFile = '.mosaic/orchestrator/session.lock'; const data = readJson(missionFile); if (!data) return; const status = String(data['status'] ?? 'inactive'); if (status !== 'active' && status !== 'paused') return; const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`; const lock = { session_id: sessionId, runtime, pid: process.pid, started_at: new Date().toISOString(), project_path: process.cwd(), milestone_id: '', }; try { mkdirSync(dirname(lockFile), { recursive: true }); writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n'); // Clean up on exit const cleanup = () => { try { rmSync(lockFile, { force: true }); } catch { // best-effort } }; process.on('exit', cleanup); process.on('SIGINT', () => { cleanup(); process.exit(130); }); process.on('SIGTERM', () => { cleanup(); process.exit(143); }); } catch { // Non-fatal } } // ─── Resumable session advisory ────────────────────────────────────────────── function checkResumableSession(): void { const lockFile = '.mosaic/orchestrator/session.lock'; const missionFile = '.mosaic/orchestrator/mission.json'; if (existsSync(lockFile)) { const lock = readJson(lockFile); if (lock) { const pid = Number(lock['pid'] ?? 0); if (pid > 0) { try { process.kill(pid, 0); // Check if alive } catch { // Process is dead — stale lock rmSync(lockFile, { force: true }); console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`); } } } } else if (existsSync(missionFile)) { const data = readJson(missionFile); if (data && data['status'] === 'active') { console.log('[mosaic] Active mission detected. Generate continuation prompt with:'); console.log('[mosaic] mosaic coord continue\n'); } } } // ─── Write config for runtimes that read from fixed paths ──────────────────── function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void { const prompt = buildRuntimePrompt(runtime); mkdirSync(dirname(destPath), { recursive: true }); const existing = readOptional(destPath); if (existing !== prompt) { writeFileSync(destPath, prompt); } } // ─── Pi skill/extension discovery ──────────────────────────────────────────── function discoverPiSkills(): string[] { const args: string[] = []; for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) { if (!existsSync(skillsRoot)) continue; try { for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillDir = join(skillsRoot, entry.name); if (existsSync(join(skillDir, 'SKILL.md'))) { args.push('--skill', skillDir); } } } catch { // skip } } return args; } function discoverPiExtension(): string[] { const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts'); return existsSync(ext) ? ['--extension', ext] : []; } // ─── Launch functions ──────────────────────────────────────────────────────── function getMissionPrompt(): string { const mission = detectMission(); if (!mission) return ''; return `Active mission detected: ${mission.name}. Read the mission state files and report status.`; } function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never { checkMosaicHome(); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); checkSoul(); checkRuntime(runtime); // Pi doesn't need sequential-thinking (has native thinking levels) if (runtime !== 'pi') { checkSequentialThinking(runtime); } checkResumableSession(); const missionPrompt = getMissionPrompt(); const hasMissionNoArgs = missionPrompt && args.length === 0; const label = RUNTIME_LABELS[runtime]; const modeStr = yolo ? ' in YOLO mode' : ''; const missionStr = hasMissionNoArgs ? ' (active mission detected)' : ''; writeSessionLock(runtime); switch (runtime) { case 'claude': { const prompt = buildRuntimePrompt('claude'); const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; cliArgs.push('--append-system-prompt', prompt); if (hasMissionNoArgs) { cliArgs.push(missionPrompt); } else { cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); execRuntime('claude', cliArgs); break; } case 'codex': { ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md')); const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : []; if (hasMissionNoArgs) { cliArgs.push(missionPrompt); } else { cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); execRuntime('codex', cliArgs); break; } case 'opencode': { ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md')); console.log(`[mosaic] Launching ${label}${modeStr}...`); execRuntime('opencode', args); break; } case 'pi': { const prompt = buildRuntimePrompt('pi'); const cliArgs = ['--append-system-prompt', prompt]; cliArgs.push(...discoverPiSkills()); cliArgs.push(...discoverPiExtension()); if (hasMissionNoArgs) { cliArgs.push(missionPrompt); } else { cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); execRuntime('pi', cliArgs); break; } } process.exit(0); // Unreachable but satisfies never } /** exec into the runtime, replacing the current process. */ function execRuntime(cmd: string, args: string[]): void { try { // Use execFileSync with inherited stdio to replace the process const result = spawnSync(cmd, args, { stdio: 'inherit', env: process.env, }); process.exit(result.status ?? 0); } catch (err) { console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err); process.exit(1); } } // ─── Framework script/tool delegation ─────────────────────────────────────── function delegateToScript(scriptPath: string, args: string[], env?: Record): never { if (!existsSync(scriptPath)) { console.error(`[mosaic] Script not found: ${scriptPath}`); process.exit(1); } try { execFileSync('bash', [scriptPath, ...args], { stdio: 'inherit', env: { ...process.env, ...env }, }); process.exit(0); } catch (err) { process.exit((err as { status?: number }).status ?? 1); } } /** * Resolve a path under the framework tools directory. Prefers the version * bundled in the @mosaicstack/mosaic npm package (always matches the installed * CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale). */ function resolveTool(...segments: string[]): string { try { const req = createRequire(import.meta.url); const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json')); const bundled = join(mosaicPkg, 'framework', 'tools', ...segments); if (existsSync(bundled)) return bundled; } catch { // Fall through to deployed copy } return join(MOSAIC_HOME, 'tools', ...segments); } function fwScript(name: string): string { return resolveTool('_scripts', name); } function toolScript(toolDir: string, name: string): string { return resolveTool(toolDir, name); } // ─── Coord (mission orchestrator) ─────────────────────────────────────────── const COORD_SUBCMDS: Record = { status: 'session-status.sh', session: 'session-status.sh', init: 'mission-init.sh', mission: 'mission-status.sh', progress: 'mission-status.sh', continue: 'continue-prompt.sh', next: 'continue-prompt.sh', run: 'session-run.sh', start: 'session-run.sh', smoke: 'smoke-test.sh', test: 'smoke-test.sh', resume: 'session-resume.sh', recover: 'session-resume.sh', }; function runCoord(args: string[]): never { checkMosaicHome(); let runtime = 'claude'; let yoloFlag = ''; const coordArgs: string[] = []; for (const arg of args) { if (arg === '--claude' || arg === '--codex' || arg === '--pi') { runtime = arg.slice(2); } else if (arg === '--yolo') { yoloFlag = '--yolo'; } else { coordArgs.push(arg); } } const subcmd = coordArgs[0] ?? 'help'; const subArgs = coordArgs.slice(1); const script = COORD_SUBCMDS[subcmd]; if (!script) { console.log(`mosaic coord — mission coordinator tools Commands: init --name [opts] Initialize a new mission mission [--project ] Show mission progress dashboard status [--project ] Check agent session health continue [--project ] Generate continuation prompt run [--project ] Launch runtime with mission context smoke Run orchestration smoke checks resume [--project ] Crash recovery Runtime: --claude (default) | --codex | --pi | --yolo`); process.exit(subcmd === 'help' ? 0 : 1); } if (yoloFlag) subArgs.unshift(yoloFlag); delegateToScript(toolScript('orchestrator', script), subArgs, { MOSAIC_COORD_RUNTIME: runtime, }); } // ─── Prdy (PRD tools via framework scripts) ───────────────────────────────── const PRDY_SUBCMDS: Record = { init: 'prdy-init.sh', update: 'prdy-update.sh', validate: 'prdy-validate.sh', check: 'prdy-validate.sh', status: 'prdy-status.sh', }; function runPrdyLocal(args: string[]): never { checkMosaicHome(); let runtime = 'claude'; const prdyArgs: string[] = []; for (const arg of args) { if (arg === '--claude' || arg === '--codex' || arg === '--pi') { runtime = arg.slice(2); } else { prdyArgs.push(arg); } } const subcmd = prdyArgs[0] ?? 'help'; const subArgs = prdyArgs.slice(1); const script = PRDY_SUBCMDS[subcmd]; if (!script) { console.log(`mosaic prdy — PRD creation and validation Commands: init [--project ] [--name ] Create docs/PRD.md update [--project ] Update existing PRD validate [--project ] Check PRD completeness status [--project ] Quick PRD health check Runtime: --claude (default) | --codex | --pi`); process.exit(subcmd === 'help' ? 0 : 1); } delegateToScript(toolScript('prdy', script), subArgs, { MOSAIC_PRDY_RUNTIME: runtime, }); } // ─── Seq (sequential-thinking MCP) ────────────────────────────────────────── function runSeq(args: string[]): never { checkMosaicHome(); const action = args[0] ?? 'check'; const rest = args.slice(1); const checker = fwScript('mosaic-ensure-sequential-thinking'); switch (action) { case 'check': delegateToScript(checker, ['--check', ...rest]); break; // unreachable case 'fix': case 'apply': delegateToScript(checker, rest); break; case 'start': { console.log('[mosaic] Starting sequential-thinking MCP server...'); try { execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], { stdio: 'inherit', }); process.exit(0); } catch (err) { process.exit((err as { status?: number }).status ?? 1); } break; } default: console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`); process.exit(1); } } // ─── Upgrade ──────────────────────────────────────────────────────────────── function runUpgrade(args: string[]): never { checkMosaicHome(); const subcmd = args[0]; if (!subcmd || subcmd === 'release') { delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0)); } else if (subcmd === 'check') { delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]); } else if (subcmd === 'project') { delegateToScript(fwScript('mosaic-upgrade'), args.slice(1)); } else if (subcmd.startsWith('-')) { delegateToScript(fwScript('mosaic-release-upgrade'), args); } else { delegateToScript(fwScript('mosaic-upgrade'), args); } } // ─── Commander registration ───────────────────────────────────────────────── export function registerLaunchCommands(program: Command): void { // Runtime launchers for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) { program .command(runtime) .description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`) .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { launchRuntime(runtime, cmd.args, false); }); } // Yolo mode program .command('yolo ') .description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)') .allowUnknownOption(true) .allowExcessArguments(true) .action((runtime: string, _opts: unknown, cmd: Command) => { const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi']; if (!valid.includes(runtime as RuntimeName)) { console.error( `[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`, ); process.exit(1); } launchRuntime(runtime as RuntimeName, cmd.args, true); }); // Coord (mission orchestrator) program .command('coord') .description('Mission coordinator tools (init, status, run, continue, resume)') .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { runCoord(cmd.args); }); // Prdy (PRD tools via local framework scripts) program .command('prdy') .description('PRD creation and validation (init, update, validate, status)') .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { runPrdyLocal(cmd.args); }); // Seq (sequential-thinking MCP management) program .command('seq') .description('sequential-thinking MCP management (check/fix/start)') .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { runSeq(cmd.args); }); // Upgrade (release + project) program .command('upgrade') .description('Upgrade Mosaic release or project files') .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { runUpgrade(cmd.args); }); // Direct framework script delegates const directCommands: Record = { init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' }, doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' }, sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' }, bootstrap: { desc: 'Bootstrap a repo with Mosaic standards', script: 'mosaic-bootstrap-repo', }, }; for (const [name, { desc, script }] of Object.entries(directCommands)) { program .command(name) .description(desc) .allowUnknownOption(true) .allowExcessArguments(true) .action((_opts: unknown, cmd: Command) => { checkMosaicHome(); delegateToScript(fwScript(script), cmd.args); }); } }