feat!: unify mosaic CLI — native launcher, no bin/ directory
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

BREAKING CHANGE: ~/.config/mosaic/bin/ is removed entirely.
The mosaic npm CLI is now the only executable.

## What changed

- **bin/ → deleted**: All scripts moved to tools/_scripts/ (internal)
- **mosaic-launch → deleted**: Launcher logic is native TypeScript
  in packages/cli/src/commands/launch.ts
- **mosaic.ps1 → deleted**: PowerShell launcher removed
- **Framework install.sh**: Complete rewrite with migration system
- **Version tracking**: .framework-version file (schema v2)
- **Migration v1→v2**: Auto-removes bin/, cleans old PATH entries
  from shell profiles

## Native TypeScript launcher (commands/launch.ts)

All runtime launch logic ported from bash:
- Runtime prompt builder (AGENTS.md + RUNTIME.md + USER.md + TOOLS.md)
- Mission context injection (reads .mosaic/orchestrator/mission.json)
- PRD status injection (scans docs/PRD.md)
- Pre-flight checks (MOSAIC_HOME, AGENTS.md, SOUL.md, runtime binary)
- Session lock management with signal cleanup
- Per-runtime launch: Claude, Codex, OpenCode, Pi
- Yolo mode flags per runtime
- Pi skill discovery + extension loading
- Framework management (init, doctor, sync, bootstrap) delegates
  to tools/_scripts/ bash implementations

## Installer

- tools/install.sh: detects framework by .framework-version or AGENTS.md
- Framework install.sh: migration system with schema versioning
- Forward-compatible: add migrations as numbered blocks
- No PATH manipulation for framework (npm bin is the only PATH entry)
This commit is contained in:
Jarvis
2026-04-02 19:23:44 -05:00
parent 04db8591af
commit 15830e2f2a
43 changed files with 724 additions and 1475 deletions

View File

@@ -0,0 +1,531 @@
/**
* 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 { 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<RuntimeName, string> = {
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 mosaic init...');
const initBin = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-init');
if (existsSync(initBin)) {
spawnSync(initBin, [], { stdio: 'inherit' });
} else {
console.error('[mosaic] mosaic-init not found. Run: mosaic wizard');
process.exit(1);
}
}
}
function checkSequentialThinking(runtime: string): void {
const checker = join(MOSAIC_HOME, 'tools', '_scripts', '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<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
} 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<string, unknown>)['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<RuntimeName, string> = {
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 delegation (for tools that remain in bash) ─────────────
function delegateToFrameworkScript(script: string, args: string[]): never {
const scriptPath = join(MOSAIC_HOME, 'tools', '_scripts', script);
if (!existsSync(scriptPath)) {
console.error(`[mosaic] Script not found: ${scriptPath}`);
process.exit(1);
}
try {
execFileSync(scriptPath, args, { stdio: 'inherit' });
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
}
// ─── 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 <runtime>')
.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);
});
// Framework management commands (delegate to bash scripts)
const frameworkCommands: Record<string, { desc: string; script: string }> = {
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(frameworkCommands)) {
program
.command(name)
.description(desc)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
checkMosaicHome();
delegateToFrameworkScript(script, cmd.args);
});
}
}