/**
 * ShieldCortex Doctor — Installation health checker.
 * Runs diagnostics and reports issues with actionable fixes.
 */
import fs from 'fs';
import path from 'path';
import os from 'os';
import semver from 'semver';
import { createRequire } from 'module';
import { REQUIRED_HOOK_NAMES } from '../setup/settings-hooks.js';
const require = createRequire(import.meta.url);
const pkg = require('../../package.json');
// ANSI colour codes
const bold = '\x1b[1m';
const reset = '\x1b[0m';
const green = '\x1b[32m';
const yellow = '\x1b[33m';
const red = '\x1b[31m';
const cyan = '\x1b[36m';
const dim = '\x1b[2m';
function icon(status) {
    switch (status) {
        case 'pass': return `${green}\u2705${reset}`;
        case 'warn': return `${yellow}\u26A0\uFE0F ${reset}`;
        case 'fail': return `${red}\u274C${reset}`;
        case 'info': return `${cyan}\u2139\uFE0F ${reset}`;
    }
}
function formatBytes(bytes) {
    if (bytes < 1024)
        return `${bytes} B`;
    if (bytes < 1024 * 1024)
        return `${(bytes / 1024).toFixed(1)} KB`;
    if (bytes < 1024 * 1024 * 1024)
        return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function getShieldCortexDir() {
    return path.join(os.homedir(), '.shieldcortex');
}
function detectEnvironment() {
    const home = os.homedir();
    const hasClaude = fs.existsSync(path.join(home, '.claude')) || fs.existsSync(path.join(home, '.claude.json'));
    const hasOpenClaw = fs.existsSync(path.join(home, '.openclaw'));
    const hasCodex = fs.existsSync(path.join(home, '.codex'));
    const platform = process.platform;
    const vscodeDirs = platform === 'darwin'
        ? [path.join(home, 'Library', 'Application Support', 'Code', 'User')]
        : platform === 'win32'
            ? [path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Code', 'User')]
            : [path.join(home, '.config', 'Code', 'User')];
    const hasVSCode = vscodeDirs.some(d => fs.existsSync(d));
    // Headless = no display environment (SSH server, container, etc.)
    const isHeadless = !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && platform !== 'darwin' && platform !== 'win32';
    return { hasClaude, hasOpenClaw, hasVSCode, hasCodex, isHeadless };
}
function getDbPath() {
    const newPath = path.join(getShieldCortexDir(), 'memories.db');
    const legacyPath = path.join(os.homedir(), '.claude-memory', 'memories.db');
    if (fs.existsSync(newPath))
        return newPath;
    if (fs.existsSync(legacyPath))
        return legacyPath;
    return newPath; // default expected path
}
// ── Check 1: Database health ──────────────────────────────
async function checkDatabase() {
    const dbPath = getDbPath();
    if (!fs.existsSync(dbPath)) {
        // The database is created lazily on the first memory operation.
        // `quickstart` only configures hooks/MCP — it does NOT touch the DB.
        // The reliable one-shot init paths are: `shieldcortex scan "..."`
        // (works on every install shape) or starting an MCP-bound session
        // (Claude Code) which lazy-inits via the MCP server.
        const env = detectEnvironment();
        const fix = env.hasClaude
            ? 'Run `shieldcortex scan "init"` to create the database now, or start a Claude Code session — the MCP server will lazy-init on first memory call'
            : 'Run `shieldcortex scan "init"` to create the database now (OpenClaw-only / headless install)';
        return {
            label: 'Database',
            status: 'fail',
            message: 'not found',
            fix,
        };
    }
    try {
        const Database = require('better-sqlite3');
        const db = new Database(dbPath, { readonly: true });
        try {
            const result = db.pragma('integrity_check');
            const integrity = Array.isArray(result) ? result[0]?.integrity_check : result;
            const stat = fs.statSync(dbPath);
            const size = formatBytes(stat.size);
            // Check WAL file
            const walPath = dbPath + '-wal';
            let walInfo = '';
            if (fs.existsSync(walPath)) {
                const walStat = fs.statSync(walPath);
                walInfo = `, WAL ${formatBytes(walStat.size)}`;
            }
            if (integrity === 'ok') {
                return { label: 'Database', status: 'pass', message: `healthy (${size}${walInfo})` };
            }
            else {
                return {
                    label: 'Database',
                    status: 'fail',
                    message: `corrupt — integrity check returned: ${integrity}`,
                    fix: 'Back up and delete `~/.shieldcortex/memories.db`, then restart the MCP server',
                };
            }
        }
        finally {
            db.close();
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return {
            label: 'Database',
            status: 'fail',
            message: `cannot open — ${msg}`,
            fix: 'Run `npm rebuild better-sqlite3` if you changed Node versions',
        };
    }
}
// ── Check 2: Schema version ──────────────────────────────
async function checkSchema() {
    const dbPath = getDbPath();
    if (!fs.existsSync(dbPath)) {
        return { label: 'Schema', status: 'warn', message: 'skipped (no database)' };
    }
    try {
        const Database = require('better-sqlite3');
        const db = new Database(dbPath, { readonly: true });
        try {
            const columns = db.pragma('table_info(memories)');
            const columnNames = new Set(columns.map((c) => c.name));
            const required = ['decayed_score', 'graph_extraction_version', 'sensitivity_level'];
            const missing = required.filter(col => !columnNames.has(col));
            if (missing.length === 0) {
                return { label: 'Schema', status: 'pass', message: 'up to date' };
            }
            else {
                return {
                    label: 'Schema',
                    status: 'warn',
                    message: `missing columns: ${missing.join(', ')}`,
                    fix: 'Restart the MCP server to auto-migrate',
                };
            }
        }
        finally {
            db.close();
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Schema', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 3: Memory stats ─────────────────────────────────
async function checkMemoryStats() {
    const dbPath = getDbPath();
    if (!fs.existsSync(dbPath)) {
        return { label: 'Memories', status: 'warn', message: 'skipped (no database)' };
    }
    try {
        const Database = require('better-sqlite3');
        const db = new Database(dbPath, { readonly: true });
        try {
            const total = db.prepare('SELECT COUNT(*) as count FROM memories').get().count;
            const stm = db.prepare("SELECT COUNT(*) as count FROM memories WHERE type = 'short_term'").get().count;
            const ltm = db.prepare("SELECT COUNT(*) as count FROM memories WHERE type = 'long_term'").get().count;
            const STM_LIMIT = 100;
            const LTM_LIMIT = 1000;
            let status = 'pass';
            let warnings = [];
            if (stm >= STM_LIMIT * 0.9) {
                status = 'warn';
                warnings.push(`${stm}/${STM_LIMIT} STM — consolidation needed`);
            }
            if (ltm >= LTM_LIMIT * 0.9) {
                status = 'warn';
                warnings.push(`${ltm}/${LTM_LIMIT} LTM — approaching limit`);
            }
            const message = warnings.length > 0
                ? `${total} total (${stm} STM, ${ltm} LTM) — ${warnings.join('; ')}`
                : `${total} total (${stm} STM, ${ltm} LTM)`;
            return { label: 'Memories', status, message };
        }
        finally {
            db.close();
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Memories', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 3b: Write-path smoke test ───────────────────────
/**
 * The honest "is it working?" check.
 *
 * Doctor checks have historically gone green while writes were silently
 * failing (v4.12.4 path-encoding bug, v4.12.5 NOT NULL UUID schema gap).
 * The pattern: schema introspection passed (columns existed) but actual
 * INSERTs threw constraint violations during real workloads.
 *
 * This check does a real round-trip — INSERT a tagged probe memory,
 * SELECT it back, DELETE it. If any step fails, doctor reports the
 * actual error string instead of "all green". The probe is tagged with
 * a unique source identifier so it can never be confused with real data
 * and gets deleted at the end of the check.
 */
/**
 * Pure helper for the write-path round-trip. Exported so tests can
 * exercise it against any database path (in-memory or temp file)
 * without going through doctor's homedir-derived getDbPath().
 */
export function runWritePathProbe(dbPath) {
    if (!fs.existsSync(dbPath)) {
        return { label: 'Write path', status: 'warn', message: 'skipped (no database)' };
    }
    let db = null;
    const probeUuid = `doctor-probe-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
    const probeTitle = '__shieldcortex_doctor_probe__';
    try {
        const Database = require('better-sqlite3');
        db = new Database(dbPath);
        // INSERT — exercises NOT NULL columns + CHECK constraints. The schema
        // adds these silently across versions; an INSERT against a stale schema
        // is the exact failure mode v4.12.5 had.
        db.prepare(`
      INSERT INTO memories (uuid, type, category, title, content, salience, source, capture_method)
      VALUES (?, 'short_term', 'note', ?, 'doctor probe — safe to delete', 0.01, 'cli:doctor', 'doctor-probe')
    `).run(probeUuid, probeTitle);
        // SELECT — exercises the FTS5 + index path
        const row = db.prepare('SELECT id, title FROM memories WHERE uuid = ?').get(probeUuid);
        if (!row || row.title !== probeTitle) {
            return {
                label: 'Write path',
                status: 'fail',
                message: 'wrote a probe row but could not read it back',
                fix: 'Database may be corrupted. Run `shieldcortex consolidate` then re-run doctor.',
            };
        }
        // DELETE — exercises the cascade triggers (FTS5 cleanup)
        const deleteResult = db.prepare('DELETE FROM memories WHERE uuid = ?').run(probeUuid);
        if (deleteResult.changes !== 1) {
            return {
                label: 'Write path',
                status: 'warn',
                message: `probe row written + read OK but delete affected ${deleteResult.changes} rows (expected 1)`,
                fix: 'Manual cleanup may be needed. Check ~/.shieldcortex/memories.db for orphaned rows.',
            };
        }
        return { label: 'Write path', status: 'pass', message: 'INSERT/SELECT/DELETE round-trip OK' };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        // Best-effort cleanup so we don't leave probe rows behind on a partial failure
        if (db) {
            try {
                db.prepare('DELETE FROM memories WHERE uuid = ?').run(probeUuid);
            }
            catch { /* ignore */ }
        }
        return {
            label: 'Write path',
            status: 'fail',
            message: `round-trip failed — ${msg}`,
            fix: 'This is the smoking gun for a stale schema or migration drift. Try restarting the MCP server (auto-migrates), or re-install: shieldcortex install',
        };
    }
    finally {
        if (db) {
            try {
                db.close();
            }
            catch { /* ignore */ }
        }
    }
}
async function checkWritePath() {
    return runWritePathProbe(getDbPath());
}
// ── Check 4: Hook installation ────────────────────────────
async function checkHooks() {
    const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
    if (!fs.existsSync(settingsPath)) {
        return {
            label: 'Hooks',
            status: 'warn',
            message: 'settings.json not found',
            fix: 'Run `shieldcortex install` to configure hooks',
        };
    }
    try {
        const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
        const hooks = settings.hooks || {};
        // Single source of truth: whatever `shieldcortex install` actually installs.
        // Previously hardcoded a different list which produced false "missing" warnings
        // after SessionEnd was removed from defaults (was crashing OpenClaw agents). [#23]
        const hookNames = [...REQUIRED_HOOK_NAMES];
        let installed = 0;
        const missing = [];
        for (const name of hookNames) {
            const hookConfig = hooks[name];
            if (hookConfig) {
                // Check if any hook command references shieldcortex.
                // Settings format: [{ hooks: [{ type, command, timeout }] }]
                const hasShieldCortex = Array.isArray(hookConfig) && hookConfig.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('shieldcortex')));
                if (hasShieldCortex) {
                    installed++;
                }
                else {
                    missing.push(name);
                }
            }
            else {
                missing.push(name);
            }
        }
        if (installed === hookNames.length) {
            return { label: 'Hooks', status: 'pass', message: `${installed}/${hookNames.length} installed` };
        }
        else {
            return {
                label: 'Hooks',
                status: 'warn',
                message: `${installed}/${hookNames.length} installed — missing: ${missing.join(', ')}`,
                fix: 'Run `shieldcortex install` to configure hooks',
            };
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Hooks', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 4b: Auto-memory hook gates ──────────────────────
/**
 * Surfaces the resolved on/off state of the opt-in Stop and SessionEnd
 * auto-memory hooks. Pre-v4.13.1 these were triple-gated (install flag,
 * runtime config, sampling) with the runtime gate failing silently —
 * users who wired the hook saw zero captures and zero feedback (#41).
 *
 * The check looks at both layers:
 *   - settings.json: is the hook wired so Claude Code will fire it?
 *   - autoMemory.enableStop / enableSessionEnd: will the hook actually run?
 *
 * Both layers must agree for the hook to do work. v4.13.1 onwards, the
 * install flag flips both — if they disagree here, the user edited one
 * side by hand and should re-run setup.
 */
export async function checkAutoMemoryHooks() {
    const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
    let wiredStop = false;
    let wiredSessionEnd = false;
    try {
        if (fs.existsSync(settingsPath)) {
            const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
            const hooks = settings?.hooks || {};
            const isCortexWired = (entries) => Array.isArray(entries) && entries.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('shieldcortex')));
            wiredStop = isCortexWired(hooks.Stop);
            wiredSessionEnd = isCortexWired(hooks.SessionEnd);
        }
    }
    catch { /* fall through — both stay false, user gets a clean info row */ }
    // Lazy-import to avoid pulling cloud/config into doctor's static graph
    // when the user hasn't configured anything yet.
    let gateStop = false;
    let gateSessionEnd = false;
    // Default lowered 10 → 5 in v4.14.0 — keep this fallback in sync with
    // scripts/lib/auto-memory-config.mjs so doctor reports the same value
    // the runtime gate would resolve to when no override is present.
    let samplingTurns = 5;
    try {
        const cfg = await import('../cloud/config.js');
        const enable = cfg.getAutoMemoryEnableConfig();
        gateStop = enable.enableStop;
        gateSessionEnd = enable.enableSessionEnd;
        const raw = cfg.readRawConfig();
        const am = raw.autoMemory && typeof raw.autoMemory === 'object'
            ? raw.autoMemory
            : {};
        if (typeof am.stopHookSamplingTurns === 'number' && am.stopHookSamplingTurns > 0) {
            samplingTurns = Math.floor(am.stopHookSamplingTurns);
        }
    }
    catch { /* defaults already set */ }
    const rowFor = (label, wired, gate, flag, extra) => {
        if (!wired && !gate) {
            return {
                label,
                status: 'info',
                message: 'opt-in (not installed)',
            };
        }
        if (wired && gate) {
            return {
                label,
                status: 'pass',
                message: extra ? `enabled (${extra})` : 'enabled',
            };
        }
        if (wired && !gate) {
            return {
                label,
                status: 'warn',
                message: 'wired in settings.json but runtime gate is off — hook will exit silently every turn',
                fix: `Run \`shieldcortex setup ${flag}\` to flip the gate, or set autoMemory.enableStop/enableSessionEnd=true in ~/.shieldcortex/config.json`,
            };
        }
        // gate && !wired
        return {
            label,
            status: 'warn',
            message: 'runtime gate is on but hook is not wired in settings.json — hook will never fire',
            fix: `Run \`shieldcortex setup ${flag}\` to wire the hook`,
        };
    };
    return [
        rowFor('Auto-memory: Stop hook', wiredStop, gateStop, '--with-stop-hook', `samples turn % ${samplingTurns} == 0`),
        rowFor('Auto-memory: SessionEnd hook', wiredSessionEnd, gateSessionEnd, '--with-session-end'),
    ];
}
// ── Check 5: Process check ────────────────────────────────
async function checkProcesses() {
    const results = [];
    const env = detectEnvironment();
    // On headless/OpenClaw-only setups, API/Dashboard are optional
    const isOptional = env.isHeadless || (env.hasOpenClaw && !env.hasClaude && !env.hasVSCode);
    // Check API server on port 3001
    try {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), 2000);
        const response = await fetch('http://localhost:3001/api/health', { signal: controller.signal });
        clearTimeout(timeout);
        if (response.ok) {
            results.push({ label: 'API server', status: 'pass', message: 'running (port 3001)' });
        }
        else {
            results.push({
                label: 'API server',
                status: 'warn',
                message: `responding but unhealthy (status ${response.status})`,
                fix: 'Restart with `shieldcortex dashboard`',
            });
        }
    }
    catch {
        if (isOptional) {
            results.push({
                label: 'API server',
                status: 'info',
                message: 'not running (optional on headless/OpenClaw-only setups)',
            });
        }
        else {
            results.push({
                label: 'API server',
                status: 'warn',
                message: 'not running',
                fix: 'Run `shieldcortex dashboard` to start the API server',
            });
        }
    }
    // Check dashboard on port 3030
    try {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), 2000);
        const response = await fetch('http://localhost:3030/', { signal: controller.signal });
        clearTimeout(timeout);
        if (response.ok || response.status === 304) {
            results.push({ label: 'Dashboard', status: 'pass', message: 'running (port 3030)' });
        }
        else {
            results.push({
                label: 'Dashboard',
                status: 'warn',
                message: `responding but returned status ${response.status}`,
                fix: 'Restart with `shieldcortex dashboard`',
            });
        }
    }
    catch {
        if (isOptional) {
            results.push({
                label: 'Dashboard',
                status: 'info',
                message: 'not running (optional on headless/OpenClaw-only setups)',
            });
        }
        else {
            results.push({
                label: 'Dashboard',
                status: 'warn',
                message: 'not running',
                fix: 'Run `shieldcortex dashboard` to start the dashboard',
            });
        }
    }
    return results;
}
// ── Check 6: Disk usage ───────────────────────────────────
/**
 * The 100 MB safety limit applies to the data portion of `~/.shieldcortex/`
 * — DB, state, audit logs, telemetry. The `models/` subtree (local Review
 * Copilot weights, embedding caches) can legitimately reach hundreds of MB
 * for users who opted into local AI inference, and should not trip the same
 * warning as runaway memory growth. v4.14.3 and earlier counted everything
 * under `~/.shieldcortex/`, producing a false `Disk: at limit!` for any
 * user with a local AI model cached.
 */
export async function checkDiskUsage(scDir = getShieldCortexDir()) {
    if (!fs.existsSync(scDir)) {
        return { label: 'Disk', status: 'pass', message: '0 B / 100 MB limit (directory not yet created)' };
    }
    try {
        let dataSize = 0;
        let modelsSize = 0;
        function walkInto(dir, bucket) {
            const entries = fs.readdirSync(dir, { withFileTypes: true });
            for (const entry of entries) {
                const fullPath = path.join(dir, entry.name);
                try {
                    if (entry.isDirectory()) {
                        walkInto(fullPath, bucket);
                    }
                    else if (entry.isFile()) {
                        const size = fs.statSync(fullPath).size;
                        if (bucket === 'models')
                            modelsSize += size;
                        else
                            dataSize += size;
                    }
                }
                catch {
                    // Skip inaccessible files
                }
            }
        }
        const topLevel = fs.readdirSync(scDir, { withFileTypes: true });
        for (const entry of topLevel) {
            const fullPath = path.join(scDir, entry.name);
            try {
                if (entry.isDirectory()) {
                    walkInto(fullPath, entry.name === 'models' ? 'models' : 'data');
                }
                else if (entry.isFile()) {
                    dataSize += fs.statSync(fullPath).size;
                }
            }
            catch {
                // Skip inaccessible top-level entries
            }
        }
        const limit = 100 * 1024 * 1024; // 100 MB applies to the data portion only
        const pct = (dataSize / limit) * 100;
        const dataStr = `${formatBytes(dataSize)} / 100 MB limit`;
        const modelsStr = modelsSize > 0 ? ` + ${formatBytes(modelsSize)} models` : '';
        if (pct >= 95) {
            return {
                label: 'Disk',
                status: 'fail',
                message: `${dataStr}${modelsStr} — at limit!`,
                fix: 'Run `shieldcortex memories prune --execute` or `memories dedupe --execute` to trim the DB',
            };
        }
        else if (pct >= 80) {
            return {
                label: 'Disk',
                status: 'warn',
                message: `${dataStr}${modelsStr} — approaching limit`,
                fix: 'Consider running `shieldcortex memories prune` or `memories dedupe`',
            };
        }
        else {
            return { label: 'Disk', status: 'pass', message: `${dataStr}${modelsStr}` };
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Disk', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 7: Lock file ───────────────────────────────────
//
// A lock is stale only if its recorded PID is no longer running. Pure mtime age
// is unreliable: a long-running daemon (e.g. `shieldcortex dashboard` started
// at boot) holds the same lock for days, and flagging it stale tells the user
// to delete a file that is still in active use.
export async function checkLockFile(scDir = getShieldCortexDir()) {
    if (!fs.existsSync(scDir)) {
        return { label: 'Lock', status: 'pass', message: 'clean' };
    }
    try {
        const lockFiles = [];
        const entries = fs.readdirSync(scDir);
        for (const entry of entries) {
            if (entry.endsWith('.lock')) {
                lockFiles.push(entry);
            }
        }
        if (lockFiles.length === 0) {
            return { label: 'Lock', status: 'pass', message: 'clean' };
        }
        const stale = [];
        const active = [];
        // Fallback only used when the lock file is unparseable or has no PID.
        const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
        for (const lockFile of lockFiles) {
            const lockPath = path.join(scDir, lockFile);
            let pid = null;
            try {
                const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
                if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) {
                    pid = parsed.pid;
                }
            }
            catch {
                // Unparseable lock — fall through to mtime fallback below.
            }
            if (pid !== null) {
                try {
                    process.kill(pid, 0);
                    active.push(lockFile);
                }
                catch (err) {
                    if (err.code === 'ESRCH') {
                        stale.push(lockFile);
                    }
                    else {
                        // EPERM = process exists, owned by another user. Treat as active.
                        active.push(lockFile);
                    }
                }
            }
            else {
                const stat = fs.statSync(lockPath);
                if (stat.mtimeMs < oneDayAgo) {
                    stale.push(lockFile);
                }
                else {
                    active.push(lockFile);
                }
            }
        }
        if (stale.length > 0) {
            return {
                label: 'Lock',
                status: 'warn',
                message: `stale lock file found: ${stale.join(', ')}`,
                fix: `Remove ${stale.map(f => `\`~/.shieldcortex/${f}\``).join(', ')}`,
            };
        }
        return { label: 'Lock', status: 'pass', message: `clean (${active.length} active lock${active.length !== 1 ? 's' : ''})` };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Lock', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 7.5: OpenClaw residue (orphans only) ───────────
async function checkOpenClawResidue() {
    const env = detectEnvironment();
    if (!env.hasOpenClaw) {
        return { label: 'OpenClaw residue', status: 'info', message: 'skipped (OpenClaw not detected)' };
    }
    try {
        const { scanForOrphans } = await import('../setup/deep-clean.js');
        const report = scanForOrphans();
        if (report.orphanCount === 0) {
            // Legitimate install state is not flagged — a fresh `openclaw install`
            // writes plugin config entries that SHOULD be there, and those are not
            // reported as residue.
            const summary = report.installState.pluginInstalled && report.installState.hookInstalled
                ? 'clean (plugin + hook installed, config aligned)'
                : report.installState.pluginInstalled
                    ? 'clean (plugin installed, hook absent but non-orphaned)'
                    : report.installState.hookInstalled
                        ? 'clean (hook installed, plugin absent but non-orphaned)'
                        : 'clean (no ShieldCortex artefacts detected)';
            return { label: 'OpenClaw residue', status: 'pass', message: summary };
        }
        const first = report.paths.slice(0, 3).map((p) => p.description).join('; ');
        const suffix = report.paths.length > 3 ? ` (+${report.paths.length - 3} more)` : '';
        return {
            label: 'OpenClaw residue',
            status: 'warn',
            message: `${report.orphanCount} orphan${report.orphanCount === 1 ? '' : 's'} — ${first}${suffix}`,
            fix: 'Run `shieldcortex uninstall --deep --confirm` to purge, or reinstall with `shieldcortex openclaw install`',
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'OpenClaw residue', status: 'warn', message: `check failed — ${msg}` };
    }
}
/**
 * Detects a bare/stale `shieldcortex` package sitting in OpenClaw's plugin
 * `node_modules`. Background: the supported OpenClaw plugin is the dedicated
 * package `@drakon-systems/shieldcortex-realtime` (see
 * docs/openclaw-integration.md and src/setup/openclaw.ts — the install runs
 * `openclaw plugins install @drakon-systems/shieldcortex-realtime`). The
 * *main* `shieldcortex` package still carries an `openclaw` key in its
 * package.json, so if a stale/linked/transitive copy lands in
 * `~/.openclaw/npm/node_modules/shieldcortex`, OpenClaw's npm auto-discovery
 * picks it up and tries to load `openclaw.extensions[0]` relative to it.
 *
 * On Jarvis (2026-05-15) such a copy crash-looped the gateway and had to be
 * diagnosed by hand over SSH. The exact crash exception is still under
 * investigation, so this check does NOT claim to identify it — it surfaces
 * the *confirmed-anomalous filesystem state* that preceded it:
 *
 *   - FAIL: bare `shieldcortex` present AND its declared extension entry is
 *     missing on disk (stale/unbuilt — OpenClaw cannot load it).
 *   - WARN: bare `shieldcortex` present with the entry intact (still the
 *     wrong package in the wrong place; the realtime plugin is supported).
 *
 * Cannot false-positive on a healthy install: those carry
 * `@drakon-systems/shieldcortex-realtime`, never bare `shieldcortex`, here.
 *
 * `npmModulesDir` is injectable for tests; defaults to
 * `~/.openclaw/npm/node_modules`.
 */
export async function checkOpenClawPluginPackage(npmModulesDir = path.join(os.homedir(), '.openclaw', 'npm', 'node_modules')) {
    const label = 'OpenClaw plugin pkg';
    if (!fs.existsSync(npmModulesDir)) {
        return { label, status: 'info', message: 'skipped (OpenClaw plugin dir not present)' };
    }
    const barePkgDir = path.join(npmModulesDir, 'shieldcortex');
    const barePkgJson = path.join(barePkgDir, 'package.json');
    if (!fs.existsSync(barePkgJson)) {
        return {
            label,
            status: 'pass',
            message: 'clean (no misplaced bare `shieldcortex`; realtime plugin is the supported path)',
        };
    }
    // A bare `shieldcortex` here is never the supported state. Decide severity
    // by whether its declared OpenClaw extension entry actually exists.
    let extEntry = null;
    try {
        const pkg = JSON.parse(fs.readFileSync(barePkgJson, 'utf-8'));
        const exts = pkg.openclaw?.extensions;
        if (Array.isArray(exts) && typeof exts[0] === 'string') {
            extEntry = exts[0];
        }
    }
    catch {
        return {
            label,
            status: 'warn',
            message: 'bare `shieldcortex` in OpenClaw plugin dir has an unreadable package.json — OpenClaw may mis-discover it',
            fix: `Remove ${barePkgDir.replace(os.homedir(), '~')} — the supported plugin is @drakon-systems/shieldcortex-realtime`,
        };
    }
    if (extEntry) {
        const resolved = path.resolve(barePkgDir, extEntry);
        if (!fs.existsSync(resolved)) {
            return {
                label,
                status: 'fail',
                message: `bare \`shieldcortex\` in OpenClaw plugin dir declares extension \`${extEntry}\` ` +
                    `but ${resolved.replace(os.homedir(), '~')} is missing — OpenClaw cannot load this ` +
                    `plugin. This is the stale state behind the gateway crash-loop incident.`,
                fix: `Remove ${barePkgDir.replace(os.homedir(), '~')} then reinstall the supported plugin: ` +
                    `\`openclaw plugins install @drakon-systems/shieldcortex-realtime\``,
            };
        }
    }
    // Expected peer-dep state: OpenClaw's managed-peer-deps installer drops a
    // bare `shieldcortex` here to satisfy the realtime plugin's
    // peerDependencies.shieldcortex. Since v4.18.3 (root-manifest fix) and
    // v4.20.0 (extensions removed), this copy is a passive peer-dep artifact —
    // it cannot be discovered as a plugin and cannot cause the crash-loop.
    //
    // INFO when the bare version satisfies realtime's declared peer range
    // (in-range, even if not strictly equal). WARN only on genuine surprises:
    // out-of-range peer, missing root manifest, missing realtime sibling.
    const bareVersion = readPkgVersion(barePkgJson);
    const realtimePkgJson = path.join(npmModulesDir, '@drakon-systems', 'shieldcortex-realtime', 'package.json');
    const realtimeVersion = readPkgVersion(realtimePkgJson);
    const realtimePeerRange = readShieldcortexPeerRange(realtimePkgJson);
    const rootManifestPresent = fs.existsSync(path.join(barePkgDir, 'openclaw.plugin.json'));
    const inRange = bareVersion !== null &&
        realtimePeerRange !== null &&
        semver.satisfies(bareVersion, realtimePeerRange);
    if (inRange && rootManifestPresent && realtimeVersion !== null) {
        return {
            label,
            status: 'info',
            message: `bare \`shieldcortex@${bareVersion}\` satisfies ` +
                `@drakon-systems/shieldcortex-realtime@${realtimeVersion}'s peer range ` +
                `(${realtimePeerRange}); root manifest present (expected peer-dep artifact)`,
        };
    }
    // Defensive fallback: if realtime is present but we couldn't read its peer
    // range (corrupt package.json, missing field), keep the v4.19.1 behaviour —
    // strict equality + root manifest → INFO. Prevents a doctor regression on
    // edge installs where the peer-range read fails.
    if (realtimePeerRange === null &&
        bareVersion !== null &&
        realtimeVersion !== null &&
        bareVersion === realtimeVersion &&
        rootManifestPresent) {
        return {
            label,
            status: 'info',
            message: `bare \`shieldcortex@${bareVersion}\` is the expected peer-dep of ` +
                `@drakon-systems/shieldcortex-realtime@${realtimeVersion}; root manifest present`,
        };
    }
    const outOfRange = bareVersion !== null &&
        realtimeVersion !== null &&
        realtimePeerRange !== null &&
        !inRange;
    return {
        label,
        status: 'warn',
        message: outOfRange
            ? `bare \`shieldcortex@${bareVersion}\` does not satisfy ` +
                `@drakon-systems/shieldcortex-realtime@${realtimeVersion}'s peer range ` +
                `(${realtimePeerRange}) — realtime may not load correctly`
            : 'bare `shieldcortex` present in OpenClaw plugin dir — this is not the supported plugin ' +
                'and OpenClaw will auto-discover it; the supported package is @drakon-systems/shieldcortex-realtime',
        fix: outOfRange
            ? `Update the realtime plugin to refresh the peer dep: ` +
                `\`openclaw plugins update @drakon-systems/shieldcortex-realtime\` ` +
                `(rm of the bare copy alone does not stick — peer-resolution restores it from cache)`
            : `Remove ${barePkgDir.replace(os.homedir(), '~')} (the dedicated realtime plugin handles ` +
                `OpenClaw integration; the bare main package should not live here)`,
    };
}
/** Read a package's `version` field; returns null on any failure. */
function readPkgVersion(pkgJson) {
    try {
        const v = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')).version;
        return typeof v === 'string' ? v : null;
    }
    catch {
        return null;
    }
}
/**
 * Read a package's `peerDependencies.shieldcortex` semver range; returns null
 * if the package.json is unreadable, missing the field, or the value is not a
 * valid semver range. Callers fall back to strict equality when null.
 */
function readShieldcortexPeerRange(pkgJson) {
    try {
        const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
        const range = pkg.peerDependencies?.shieldcortex;
        return typeof range === 'string' && semver.validRange(range) ? range : null;
    }
    catch {
        return null;
    }
}
// ── Check 9: Auto-memory sampling rate (#44) ──────────────
/**
 * Reports the resolved `autoMemory.stopHookSamplingTurns` and salience-bypass
 * setting. Defaults dropped 10 → 5 (and salience bypass added) in v4.14.0;
 * warn if a user pinned a sparser cadence likely to under-feed LTM.
 *
 * Reads the config file directly rather than importing the .mjs helper so
 * doctor doesn't depend on path layout between dist/ and scripts/.
 */
async function checkAutoMemorySampling() {
    try {
        const configPath = path.join(getShieldCortexDir(), 'config.json');
        let raw = {};
        if (fs.existsSync(configPath)) {
            try {
                raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
            }
            catch {
                /* fall through to defaults */
            }
        }
        const overrides = raw.autoMemory ?? {};
        const sampling = typeof overrides.stopHookSamplingTurns === 'number' &&
            Number.isFinite(overrides.stopHookSamplingTurns) &&
            overrides.stopHookSamplingTurns > 0
            ? Math.floor(overrides.stopHookSamplingTurns)
            : 5; // default in auto-memory-config.mjs as of v4.14.0
        const bypassEnabled = typeof overrides.stopHookSalienceBypass === 'boolean' ? overrides.stopHookSalienceBypass : true;
        const bypass = bypassEnabled ? 'on' : 'off';
        if (sampling <= 5) {
            return {
                label: 'Auto-memory sampling',
                status: 'pass',
                message: `every ${sampling} turn(s), salience-bypass ${bypass}`,
            };
        }
        return {
            label: 'Auto-memory sampling',
            status: 'warn',
            message: `every ${sampling} turn(s), salience-bypass ${bypass} — sparser than recommended`,
            fix: 'Edit ~/.shieldcortex/config.json autoMemory.stopHookSamplingTurns (≤ 5 recommended)',
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Auto-memory sampling', status: 'info', message: `check skipped — ${msg}` };
    }
}
// ── Check 10: Brain-worker freshness (#45) ────────────────
/**
 * The MCP server starts a lite-profile brain worker on connect (v4.14.0).
 * Each light tick writes to ~/.shieldcortex/state/worker.json. If the
 * timestamp is missing or older than 30 min, consolidation has likely
 * stalled — STM won't graduate to LTM.
 */
function isPidAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        const code = err.code;
        if (code === 'ESRCH')
            return false; // No such process — definitely dead
        if (code === 'EPERM')
            return true; // Process exists but owned by another user
        return null; // Unknown — don't make claims
    }
}
function workerRecoveryFix() {
    // The right recovery is platform-dependent. On a headless Linux host (most
    // common shape: OpenClaw + bot user, no Claude Code), the durable answer
    // is `service install --headless` plus `loginctl enable-linger`. On macOS
    // and on dev machines running Claude Code, restarting the editor spawns
    // a fresh MCP-bound worker.
    if (process.platform === 'linux') {
        return 'For persistence, run `shieldcortex service install --headless` (recommended on headless hosts) or restart Claude Code';
    }
    return 'Restart Claude Code or run `shieldcortex service install` to supervise the worker';
}
async function checkBrainWorker() {
    if (process.env.SHIELDCORTEX_DISABLE_WORKER === '1') {
        return {
            label: 'Brain worker',
            status: 'info',
            message: 'disabled via SHIELDCORTEX_DISABLE_WORKER=1',
        };
    }
    const statePath = path.join(getShieldCortexDir(), 'state', 'worker.json');
    if (!fs.existsSync(statePath)) {
        return {
            label: 'Brain worker',
            status: 'warn',
            message: 'no worker.json — worker has not run yet',
            fix: workerRecoveryFix(),
        };
    }
    try {
        const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
        const last = raw.lastLightTick ? new Date(raw.lastLightTick) : null;
        if (!last || Number.isNaN(last.getTime())) {
            return {
                label: 'Brain worker',
                status: 'warn',
                message: 'worker.json present but lastLightTick missing/invalid',
                fix: workerRecoveryFix(),
            };
        }
        const ageMs = Date.now() - last.getTime();
        const ageMin = Math.round(ageMs / 60000);
        const pid = typeof raw.pid === 'number' ? raw.pid : null;
        const profile = raw.profile ?? '?';
        // Pid liveness changes the meaning of staleness. A stale tick from a
        // *dead* process is a different failure mode than a stale tick from a
        // *running* process — only the first means "the worker crashed and
        // nothing's coming". The fix-hint diverges accordingly.
        const alive = pid != null ? isPidAlive(pid) : null;
        if (pid != null && alive === false) {
            return {
                label: 'Brain worker',
                status: 'warn',
                message: `process gone (pid ${pid} dead, last tick ${ageMin}m ago, profile=${profile})`,
                fix: workerRecoveryFix(),
            };
        }
        if (ageMs > 30 * 60 * 1000) {
            return {
                label: 'Brain worker',
                status: 'warn',
                message: `last tick ${ageMin}m ago (profile=${profile}, pid=${pid ?? '?'}${alive === true ? ', alive' : ''})`,
                fix: alive === true
                    ? 'Worker process alive but ticks stalled — restart Claude Code or `shieldcortex service repair`'
                    : workerRecoveryFix(),
            };
        }
        return {
            label: 'Brain worker',
            status: 'pass',
            message: `last tick ${ageMin}m ago (profile=${profile}, pid=${pid ?? '?'})`,
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Brain worker', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 11: Project-key consistency (#42) ───────────────
/**
 * Detects rows tagged with both legacy basename keys and canonical
 * owner-repo keys (the symptom of pre-v4.14.0 stop-hook writes). If any
 * basename collides with a `<something>-<basename>` form already in the
 * DB, point the user at `repair-project-keys`.
 */
async function checkProjectKeyConsistency() {
    const dbPath = getDbPath();
    if (!fs.existsSync(dbPath)) {
        return { label: 'Project keys', status: 'info', message: 'skipped (no DB yet)' };
    }
    try {
        const Database = require('better-sqlite3');
        const db = new Database(dbPath, { readonly: true });
        try {
            const rows = db
                .prepare("SELECT DISTINCT project FROM memories WHERE project IS NOT NULL AND project != ''")
                .all();
            const keys = rows.map((r) => r.project);
            const collisions = [];
            for (const candidate of keys) {
                // A candidate is "legacy-looking" if it has no hyphen (cwd basename).
                if (candidate.includes('-'))
                    continue;
                const suffix = `-${candidate}`;
                const matched = keys.find((k) => k !== candidate && k.endsWith(suffix));
                if (matched)
                    collisions.push({ legacy: candidate, canonical: matched });
            }
            if (collisions.length === 0) {
                return { label: 'Project keys', status: 'pass', message: `${keys.length} distinct, no legacy/canonical collisions` };
            }
            const example = collisions.slice(0, 3).map((c) => `${c.legacy} ↔ ${c.canonical}`).join('; ');
            const more = collisions.length > 3 ? ` (+${collisions.length - 3} more)` : '';
            return {
                label: 'Project keys',
                status: 'warn',
                message: `${collisions.length} legacy/canonical collision(s): ${example}${more}`,
                fix: 'Run `shieldcortex memories repair-project-keys --scan-paths <root>` (dry-run by default)',
            };
        }
        finally {
            db.close();
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Project keys', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Check 12: Hook timeouts (#43) ─────────────────────────
/**
 * Compares each ShieldCortex hook's timeout in ~/.claude/settings.json
 * against the canonical values written by `shieldcortex setup`. Catches
 * the v4.13.x silent-recall failure mode where users on a hand-edited
 * settings.json still ran with `UserPromptSubmit.timeout: 2`.
 */
async function checkHookTimeouts() {
    const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
    if (!fs.existsSync(settingsPath)) {
        return { label: 'Hook timeouts', status: 'info', message: 'skipped (settings.json not found)' };
    }
    try {
        const { CANONICAL_HOOK_TIMEOUTS } = await import('../setup/settings-hooks.js');
        const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
        const hooks = settings.hooks ?? {};
        const drift = [];
        for (const [name, expected] of Object.entries(CANONICAL_HOOK_TIMEOUTS)) {
            const entries = hooks[name];
            if (!Array.isArray(entries))
                continue;
            for (const entry of entries) {
                for (const h of entry.hooks ?? []) {
                    if (typeof h.command !== 'string' || !h.command.includes('shieldcortex'))
                        continue;
                    if (typeof h.timeout === 'number' && h.timeout < expected) {
                        drift.push(`${name}=${h.timeout}s (canonical ${expected}s)`);
                    }
                }
            }
        }
        if (drift.length === 0) {
            return { label: 'Hook timeouts', status: 'pass', message: 'all canonical' };
        }
        return {
            label: 'Hook timeouts',
            status: 'warn',
            message: `below canonical: ${drift.join(', ')}`,
            fix: 'Re-run `shieldcortex install` to restore canonical timeouts',
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Hook timeouts', status: 'info', message: `check skipped — ${msg}` };
    }
}
// ── Check 8: Model cache ─────────────────────────────────
async function checkModelCache() {
    const cacheDir = path.join(os.homedir(), '.cache', 'shieldcortex', 'models', 'Xenova', 'all-MiniLM-L6-v2');
    if (!fs.existsSync(cacheDir)) {
        return {
            label: 'Embeddings',
            status: 'info',
            message: 'model not cached — will download on first recall',
        };
    }
    try {
        let totalSize = 0;
        const entries = fs.readdirSync(cacheDir);
        for (const entry of entries) {
            const fullPath = path.join(cacheDir, entry);
            try {
                const stat = fs.statSync(fullPath);
                if (stat.isFile())
                    totalSize += stat.size;
            }
            catch {
                // Skip
            }
        }
        return {
            label: 'Embeddings',
            status: 'pass',
            message: `model cached (${formatBytes(totalSize)})`,
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { label: 'Embeddings', status: 'warn', message: `check failed — ${msg}` };
    }
}
// ── Main runner ───────────────────────────────────────────
export async function runDoctor() {
    console.log(`\n${bold}ShieldCortex Doctor${reset} v${pkg.version}\n`);
    const results = [];
    // Run checks sequentially (some depend on DB access)
    const checks = [
        checkDatabase,
        checkSchema,
        checkWritePath, // Smoke test: real INSERT/SELECT/DELETE round-trip — catches silent schema drift
        checkMemoryStats,
        checkHooks,
        checkHookTimeouts,
        checkAutoMemoryHooks,
        checkAutoMemorySampling,
        checkBrainWorker,
        checkProjectKeyConsistency,
        checkProcesses,
        checkDiskUsage,
        checkLockFile,
        checkOpenClawResidue,
        checkOpenClawPluginPackage,
        checkModelCache,
    ];
    for (const check of checks) {
        try {
            const result = await check();
            if (Array.isArray(result)) {
                results.push(...result);
            }
            else {
                results.push(result);
            }
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            results.push({ label: 'Unknown', status: 'fail', message: `check crashed — ${msg}` });
        }
    }
    // Print results
    for (const r of results) {
        console.log(`  ${icon(r.status)} ${bold}${r.label}:${reset} ${r.message}`);
    }
    // Summary
    const passed = results.filter(r => r.status === 'pass').length;
    const warnings = results.filter(r => r.status === 'warn').length;
    const failures = results.filter(r => r.status === 'fail').length;
    const infos = results.filter(r => r.status === 'info').length;
    const total = results.length;
    console.log('');
    const parts = [];
    parts.push(`${passed}/${total} checks passed`);
    if (warnings > 0)
        parts.push(`${yellow}${warnings} warning${warnings !== 1 ? 's' : ''}${reset}`);
    if (failures > 0)
        parts.push(`${red}${failures} failure${failures !== 1 ? 's' : ''}${reset}`);
    if (infos > 0)
        parts.push(`${infos} info`);
    console.log(`  ${parts.join(', ')}`);
    // Suggested fixes
    const fixes = results.filter(r => r.fix);
    if (fixes.length > 0) {
        console.log(`\n  ${bold}Suggested fixes:${reset}`);
        for (const f of fixes) {
            console.log(`  ${dim}\u2192${reset} ${f.fix}`);
        }
    }
    console.log('');
}
