/**
 * 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,
  realpathSync,
  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';
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';

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 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);
    }
  }
}

// ─── Claude settings validation ─────────────────────────────────────────────

interface SettingsAudit {
  warnings: string[];
}

function auditClaudeSettings(): SettingsAudit {
  const warnings: string[] = [];
  const settingsPath = join(homedir(), '.claude', 'settings.json');
  const settings = readJson(settingsPath);

  if (!settings) {
    warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
    return { warnings };
  }

  // Check required hooks
  const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;

  const requiredPreToolUse = ['prevent-memory-write.sh'];
  const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];

  const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
  const postHooks = (hooks?.['PostToolUse'] ?? []) as Array<Record<string, unknown>>;

  const preCommands = preHooks.flatMap((h) => {
    const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
    return inner.map((ih) => String(ih['command'] ?? ''));
  });
  const postCommands = postHooks.flatMap((h) => {
    const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
    return inner.map((ih) => String(ih['command'] ?? ''));
  });

  for (const script of requiredPreToolUse) {
    if (!preCommands.some((c) => c.includes(script))) {
      warnings.push(`Missing PreToolUse hook: ${script}`);
    }
  }
  for (const script of requiredPostToolUse) {
    if (!postCommands.some((c) => c.includes(script))) {
      warnings.push(`Missing PostToolUse hook: ${script}`);
    }
  }

  // Check required plugins
  const plugins = (settings['enabledPlugins'] ?? {}) as Record<string, boolean>;
  const requiredPlugins = ['feature-dev', 'pr-review-toolkit', 'code-review'];

  for (const plugin of requiredPlugins) {
    const found = Object.keys(plugins).some((k) => k.startsWith(plugin) && plugins[k]);
    if (!found) {
      warnings.push(`Missing plugin: ${plugin}`);
    }
  }

  // Check enableAllMcpTools
  if (!settings['enableAllMcpTools']) {
    warnings.push('enableAllMcpTools is not true — MCP tools may require per-tool approval');
  }

  return { warnings };
}

function printSettingsWarnings(audit: SettingsAudit): void {
  if (audit.warnings.length === 0) return;

  console.log('\n[mosaic] Claude Code settings audit:');
  for (const w of audit.warnings) {
    console.log(`  ⚠ ${w}`);
  }
  console.log(
    '[mosaic] Run: mosaic doctor  — or see ~/.config/mosaic/runtime/claude/RUNTIME.md for required settings.\n',
  );
}

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<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 ──────────────────────────────────────────────────

/**
 * Compose the full runtime contract for a harness: the resident-by-value core
 * (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays
 * (`*.local.md` deltas), merged in precedence order so the model gets one
 * pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value;
 * base files keep their existing residency (USER injected; SOUL/STANDARDS are
 * load-on-demand, so only their small `.local` deltas are injected here).
 *
 * `mosaicHome` is parameterized for testability; production callers use the
 * module-level default.
 */
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
  const runtimeContractPaths: Record<RuntimeName, string> = {
    claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
    codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
    opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
    pi: join(mosaicHome, '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.
`);

  // CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
  // pre-constitution installs that have not been re-seeded yet. Injected by
  // value verbatim so the bare-launch fallback read is byte-equal (R8).
  const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
  if (constitution) parts.push(constitution);

  // AGENTS.md
  parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));

  // USER.md (+ USER.local.md operator overlay, appended directly under the
  // profile its base owns).
  const user = readOptional(join(mosaicHome, 'USER.md'));
  if (user) parts.push('\n\n# User Profile\n\n' + user);
  const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
  if (userLocal.trim()) {
    parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
  }

  // TOOLS.md
  const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
  if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);

  // Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
  // inject only the small `.local` delta by value so the customization reaches
  // the model without re-injecting the full base prose (preserves the byte
  // budget). Absent `.local` files → base-only, automatically (R7 §3.2).
  const overlayBlocks: string[] = [];
  const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md'));
  if (soulLocal.trim()) {
    overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
  }
  const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
  if (standardsLocal.trim()) {
    overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
  }
  if (overlayBlocks.length > 0) {
    parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n'));
  }

  // Runtime-specific contract
  parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));

  // Persona contract (A3b): when this agent was spawned with a class
  // (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
  // role contract so its identity (mandate + boundaries) is resident from the
  // first turn. Override-aware via the persona resolver: a user-customized
  // persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
  // Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
  // silently when the class is unset/unknown (mirrors the comms block).
  const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
  if (persona) parts.push('\n\n' + persona);

  const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
  if (toolPolicy) parts.push('\n\n' + toolPolicy);

  // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
  // and present in the roster), inject a comms cheat-sheet + peer roster so it
  // knows how to reach the orchestrator and its peers from its first turn.
  const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
  if (fleetComms) parts.push('\n\n' + fleetComms);

  return parts.join('\n');
}

function readFleetToolPolicyBlock(policy: string | undefined): string {
  if (policy !== 'operator-interaction') return '';
  return [
    '# Fleet Tool Policy (operator-interaction)',
    '',
    'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
    'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
    'Delegate orchestrator-owned work through the authorized handoff boundary.',
  ].join('\n');
}

/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
function buildRuntimePrompt(runtime: RuntimeName): string {
  return composeContract(runtime);
}

// ─── 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 ────────────────────────────────────────────

/** Resolve a skill dir to its canonical real path so symlinked duplicates
 * (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
 * Falls back to the literal path if it can't be resolved (e.g. broken link). */
function skillRealPath(dir: string): string {
  try {
    return realpathSync(dir);
  } catch {
    return dir;
  }
}

/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
 * skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
  return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
}

/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
 * counts as a skill when it (or its symlink target) contains a SKILL.md.
 * Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
export function enumerateSkillDirs(roots: string[]): string[] {
  const seen = new Set<string>();
  const args: string[] = [];
  for (const skillsRoot of roots) {
    if (!existsSync(skillsRoot)) continue;
    try {
      for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
        // Synced fleet skills land as symlinks, so accept both dirs and links.
        if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
        const skillDir = join(skillsRoot, entry.name);
        if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
        const key = skillRealPath(skillDir);
        if (seen.has(key)) continue;
        seen.add(key);
        args.push('--skill', skillDir);
      }
    } catch {
      // skip unreadable roots
    }
  }
  return args;
}

/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
 * global/local catalog plus Pi's own native roots. `--no-skills` suppresses
 * native auto-discovery, so 'all' must re-add the native roots explicitly or
 * they would be silently dropped. Deduped by real path. */
function discoverPiSkills(cwd: string = process.cwd()): string[] {
  return enumerateSkillDirs([
    join(MOSAIC_HOME, 'skills'),
    join(MOSAIC_HOME, 'skills-local'),
    ...piNativeSkillRoots(cwd),
  ]);
}

/** Real paths of skills Pi will auto-discover from its native roots. Used to
 * drop redundant force-loads in 'discover' mode (which keeps native discovery
 * on) so the same skill is not registered twice. */
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
  const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
  const set = new Set<string>();
  for (let i = 1; i < args.length; i += 2) {
    const dir = args[i];
    if (dir !== undefined) set.add(skillRealPath(dir));
  }
  return set;
}

type PiSkillMode = 'none' | 'all' | 'discover';

function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
  const value = env['MOSAIC_PI_SKILL_MODE']?.trim().toLowerCase();
  if (value === 'all' || value === 'discover') return value;
  return 'none';
}

/**
 * Fleet-critical Pi skills that are force-loaded on every Pi launch regardless
 * of MOSAIC_PI_SKILL_MODE. They cover the highest-frequency cross-agent and
 * git-provider operations where Pi workers historically improvised raw CLIs
 * (raw `tmux send-keys`, raw `tea`/`gh`/`glab`) instead of the maintained
 * `~/.config/mosaic/tools/` wrappers.
 *
 * An explicit `--skill <dir>` overrides `--no-skills` for that path, so forcing
 * a single targeted skill surfaces the must-use toolkit without loading the full
 * ~100-skill catalog (context bloat). Missing skills are skipped silently, so
 * this is a no-op until the named skill is synced into ~/.config/mosaic/skills/.
 *
 * Override with MOSAIC_PI_FORCE_SKILLS (colon-separated skill dir names; set to
 * an empty string to disable force-loading entirely).
 */
const DEFAULT_PI_FORCE_SKILLS = ['mosaic-tools'];

export function piForceSkillNames(env: NodeJS.ProcessEnv): string[] {
  const override = env['MOSAIC_PI_FORCE_SKILLS'];
  if (override === undefined) return DEFAULT_PI_FORCE_SKILLS;
  return override
    .split(':')
    .map((name) => name.trim())
    .filter(Boolean);
}

function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
  const args: string[] = [];
  for (const name of piForceSkillNames(env)) {
    const skillDir = join(MOSAIC_HOME, 'skills', name);
    if (existsSync(join(skillDir, 'SKILL.md'))) {
      args.push('--skill', skillDir);
    }
  }
  return args;
}

/** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
 * Dedup is by real path, so a forced skill and the same skill reached via a
 * different (e.g. symlinked) directory collapse to a single `--skill`. */
function mergeSkillArgs(...groups: string[][]): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (const group of groups) {
    for (let i = 0; i < group.length; i += 2) {
      const dir = group[i + 1];
      if (group[i] !== '--skill' || dir === undefined) continue;
      const key = skillRealPath(dir);
      if (seen.has(key)) continue;
      seen.add(key);
      out.push('--skill', dir);
    }
  }
  return out;
}

export function buildPiSkillArgs(
  _runtimeArgs: string[],
  env: NodeJS.ProcessEnv = process.env,
  discoveredSkillArgs: string[] = discoverPiSkills(),
  forcedSkillArgs: string[] = forcedPiSkillArgs(env),
  nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
): string[] {
  const mode = normalizePiSkillMode(env);

  if (mode === 'discover') {
    // Native Pi discovery stays on, so only force-load fleet skills it will NOT
    // already find under its native roots — otherwise the same skill is
    // registered twice (once natively, once via --skill). mergeSkillArgs first
    // collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
    const deduped = mergeSkillArgs(forcedSkillArgs);
    const out: string[] = [];
    for (let i = 0; i < deduped.length; i += 2) {
      const dir = deduped[i + 1];
      if (deduped[i] !== '--skill' || dir === undefined) continue;
      if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
      out.push('--skill', dir);
    }
    return out;
  }

  if (mode === 'all') {
    // 'all' links the full catalog; merge in the forced set so fleet-critical
    // skills are guaranteed present even if they live only under skills-local/.
    // discoverPiSkills already covers Pi's native roots, which `--no-skills`
    // would otherwise suppress.
    return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
  }

  return ['--no-skills', ...forcedSkillArgs];
}

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': {
      // Audit Claude Code settings and warn about missing hooks/plugins
      const settingsAudit = auditClaudeSettings();
      printSettingsWarnings(settingsAudit);

      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(...buildPiSkillArgs(args));
      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<string, string>): 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<string, string> = {
  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 <name> [opts]   Initialize a new mission
  mission [--project <path>]  Show mission progress dashboard
  status [--project <path>]   Check agent session health
  continue [--project <path>] Generate continuation prompt
  run [--project <path>]      Launch runtime with mission context
  smoke                       Run orchestration smoke checks
  resume [--project <path>]   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<string, string> = {
  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 <path>] [--name <feature>]   Create docs/PRD.md
  update [--project <path>]                    Update existing PRD
  validate [--project <path>]                  Check PRD completeness
  status [--project <path>]                    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 ─────────────────────────────────────────────────

/**
 * Handler invoked when a runtime subcommand (`<runtime>` or `yolo <runtime>`)
 * is parsed. Exposed so tests can exercise the commander wiring without
 * spawning subprocesses.
 */
export type RuntimeLaunchHandler = (
  runtime: RuntimeName,
  extraArgs: string[],
  yolo: boolean,
) => void;

/**
 * Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
 * pluggable launch handler. Separated from `registerLaunchCommands` so tests
 * can inject a spy and verify argument forwarding.
 */
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
  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) => {
        handler(runtime, cmd.args, false);
      });
  }

  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);
      }
      // Commander includes declared positional arguments (`<runtime>`) in
      // `cmd.args` alongside any trailing excess args. Slice off the first
      // element so we forward only true excess args — otherwise the runtime
      // name leaks into the underlying CLI as an initial positional arg,
      // which Claude Code interprets as the first user message.
      // Regression test: launch.spec.ts, issue mosaicstack/stack#454.
      handler(runtime as RuntimeName, cmd.args.slice(1), true);
    });
}

export function registerLaunchCommands(program: Command): void {
  // Runtime launchers + yolo mode wired to the real process-replacing launcher.
  registerRuntimeLaunchers(program, (runtime, extraArgs, yolo) => {
    launchRuntime(runtime, extraArgs, yolo);
  });

  // compose-contract — emit the composed runtime contract (base + operator
  // overlays) for a harness to stdout, without launching. For inspection,
  // `mosaic doctor`, diffing, and the composer test (R7).
  program
    .command('compose-contract <harness>')
    .description('Print the composed runtime contract (base + *.local overlays) for a harness')
    .action((harness: string) => {
      const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
      if (!valid.includes(harness as RuntimeName)) {
        console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`);
        process.exitCode = 64;
        return;
      }
      process.stdout.write(composeContract(harness as RuntimeName));
    });

  // 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<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(directCommands)) {
    program
      .command(name)
      .description(desc)
      .allowUnknownOption(true)
      .allowExcessArguments(true)
      .action((_opts: unknown, cmd: Command) => {
        checkMosaicHome();
        delegateToScript(fwScript(script), cmd.args);
      });
  }
}
