UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

1,087 lines • 52.2 kB
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import { debugLog, EXEC_TIMEOUT_QUICK_MS, getProjectDirs, isRecord, homeDir, homePath, hookConfigPath, listInvalidProjectDirs, runtimeHealthFile, } from "../shared.js";
import { migrateInvalidProjectNames, formatMigrationSummary } from "../project-migrate.js";
import { ROOT_MANIFEST_FILENAME } from "../phren-paths.js";
import { STORES_FILENAME } from "../store-registry.js";
import { commandVersion, versionAtLeast, nearestWritableTarget, resolveEntryScript } from "../init/shared.js";
import { validateGovernanceJson } from "../shared/governance.js";
import { errorMessage } from "../utils.js";
import { buildIndex, queryRows } from "../shared/index.js";
import { validateTaskFormat, validateFindingsFormat } from "../shared/content.js";
import { commandExists, detectInstalledTools, isEphemeralNpxPath, findStaleHookEntrypoints } from "../hooks.js";
import { validateSkillFrontmatter, validateSkillsDir } from "./skills.js";
import { verifyFileChecksums, updateFileChecksums } from "./checksums.js";
import { buildSkillManifest, skillMirrorPath } from "../skill/registry.js";
import { inspectTaskHygiene } from "../task/hygiene.js";
import { resolveTaskFilePath, TASK_FILE_ALIASES } from "../data/tasks.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { repairPreexistingInstall } from "../init/setup.js";
import { getManagementPreset, resolveManagementCapabilities } from "../init/management-preset.js";
import { getMachineName, lookupProfile, findProfileFile, getProfileProjects, findProjectDir, } from "./link.js";
import { claudeProjectKey } from "./context.js";
import { scanContextImports, fixContextImports } from "./context-imports.js";
import { getProjectOwnershipMode, readProjectConfig } from "../project-config.js";
import { readInstallPreferences } from "../init/preferences.js";
import { logger } from "../logger.js";
import { CONTEXT_COST_LIMITS, medianHookInjectionTokens, storeWeight } from "../store-weight.js";
import { resolveMcpProfile } from "../mcp/profile.js";
import { activeStoreAuthFailure, isGitAuthFailure, recordStoreAuthFailure, storeAuthDetail, storeSyncRemote } from "../sync/auth.js";
import { storeCredentialCheck } from "../sync/auth-doctor.js";
import { describeAutoSave } from "../sync/outcome.js";
export function wrapperState(tool, env = process.env, resolve = resolveOnPath) {
    const isWindows = process.platform === "win32";
    const wrapperPath = homePath(".local", "bin", isWindows ? `${tool}.cmd` : tool);
    if (!fs.existsSync(wrapperPath))
        return { state: "missing" };
    const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
    const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
    if (!dirs.some((dir) => same(dir, path.dirname(wrapperPath))))
        return { state: "off-path" };
    const first = resolve(tool);
    return first && same(first, wrapperPath) ? { state: "active" } : { state: "shadowed", by: first };
}
function resolveOnPath(tool) {
    try {
        const raw = execFileSync(process.platform === "win32" ? "where.exe" : "which", [tool], {
            encoding: "utf8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: EXEC_TIMEOUT_QUICK_MS,
        }).trim();
        // `where.exe` can print multiple paths, one per line; check the first hit.
        return raw.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
    }
    catch (err) {
        debugLog(`wrapperState: resolve ${tool} failed: ${errorMessage(err)}`);
        return "";
    }
}
/** One doctor line per wrapper. The phren wrapper works off PATH too: hooks call it by its full path. */
export function wrapperCheck(tool, state) {
    const where = `~/.local/bin/${tool}${process.platform === "win32" ? ".cmd" : ""}`;
    const name = tool === "phren" ? "wrapper:phren-cli" : `wrapper:${tool}`;
    const label = tool === "phren" ? "phren CLI" : tool;
    switch (state.state) {
        case "active":
            return { name, ok: true, detail: `${label} wrapper active via ${where}` };
        case "off-path":
            return {
                name, ok: true,
                detail: `${label} wrapper installed at ${where}; ~/.local/bin is not on this process's PATH ` +
                    `(shell startup files such as .zshrc add it only to interactive shells), so doctor cannot confirm it comes first there`,
            };
        case "shadowed":
            return {
                name, ok: false,
                detail: state.by
                    ? `${tool} resolves to ${state.by} before the wrapper at ${where}; move ~/.local/bin earlier in PATH`
                    : `${label} wrapper at ${where} is on PATH but does not run; check that it is executable`,
            };
        case "missing":
            return {
                name, ok: false,
                detail: tool === "phren" ? "phren CLI wrapper missing — run 'npx @phren/cli init' to install" : `${tool} wrapper missing`,
            };
    }
}
function gitRemoteStatus(phrenPath, syncIntent) {
    const auth = activeStoreAuthFailure(phrenPath);
    if (auth)
        return { ok: false, detail: storeAuthDetail(auth) };
    try {
        execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
            env: nonInteractiveGitEnv(),
            stdio: ["ignore", "ignore", "ignore"],
            timeout: EXEC_TIMEOUT_QUICK_MS,
        });
    }
    catch {
        return { ok: false, detail: "phren path is not a git repository" };
    }
    let remote;
    const remoteName = storeSyncRemote(phrenPath)?.remoteName || "origin";
    try {
        remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "--", remoteName], {
            env: nonInteractiveGitEnv(),
            encoding: "utf8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: EXEC_TIMEOUT_QUICK_MS,
        }).trim();
    }
    catch {
        // no remote configured
    }
    if (!remote) {
        if (syncIntent === "sync") {
            return {
                ok: false,
                detail: "sync configured but no git remote found. Run: cd ~/.phren && git remote add origin <YOUR_REPO_URL> && git push -u origin main",
            };
        }
        return { ok: true, detail: "no remote configured (local-only sync mode)" };
    }
    // Remote exists — verify it's reachable
    try {
        execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "--", remoteName], {
            env: nonInteractiveGitEnv(),
            stdio: ["ignore", "ignore", "pipe"],
            timeout: 10_000,
        });
        return { ok: true, detail: `${remoteName}=${remote}` };
    }
    catch (err) {
        if (isGitAuthFailure(err)) {
            return { ok: false, detail: storeAuthDetail(recordStoreAuthFailure(phrenPath, remoteName, remote)) };
        }
        if (syncIntent === "sync") {
            return { ok: false, detail: `${remoteName}=${remote} (unreachable); check your network or SSH keys` };
        }
        return { ok: true, detail: `${remoteName}=${remote} (unreachable, local-only mode)` };
    }
}
/**
 * Root-level config the CLI reads straight off disk. Sparse-checkout can leave these
 * tracked-but-unmaterialized, and both failures are silent: without the root manifest
 * the MCP entrypoint never recognizes the store path (so MCP never starts), and without
 * stores.yaml the registry falls back to a single implicit store, hiding every team store.
 */
function rootConfigStatus(phrenPath, filename) {
    if (fs.existsSync(path.join(phrenPath, filename))) {
        return { present: true, trackedInHead: false };
    }
    try {
        execFileSync("git", ["cat-file", "-e", `HEAD:${filename}`], {
            env: nonInteractiveGitEnv(),
            cwd: phrenPath,
            stdio: "ignore",
            timeout: EXEC_TIMEOUT_QUICK_MS,
        });
        return { present: false, trackedInHead: true };
    }
    catch {
        return { present: false, trackedInHead: false };
    }
}
/** Materialize a tracked-but-excluded root config file by widening sparse-checkout. */
function materializeRootConfig(phrenPath, filename) {
    try {
        execFileSync("git", ["sparse-checkout", "add", `/${filename}`], {
            env: nonInteractiveGitEnv(),
            cwd: phrenPath,
            stdio: "ignore",
            timeout: EXEC_TIMEOUT_QUICK_MS,
        });
        return fs.existsSync(path.join(phrenPath, filename));
    }
    catch (err) {
        debugLog(`materializeRootConfig(${filename}): ${errorMessage(err)}`);
        return false;
    }
}
function pushSkillMirrorChecks(checks, scope, manifest, destDir) {
    const parentDir = path.dirname(destDir);
    checks.push({
        name: `skills-manifest:${scope}`,
        ok: fs.existsSync(path.join(parentDir, "skill-manifest.json")),
        detail: fs.existsSync(path.join(parentDir, "skill-manifest.json"))
            ? `generated: ${path.join(parentDir, "skill-manifest.json")}`
            : `missing generated manifest at ${path.join(parentDir, "skill-manifest.json")}`,
    });
    checks.push({
        name: `skills-commands:${scope}`,
        ok: fs.existsSync(path.join(parentDir, "skill-commands.json")),
        detail: fs.existsSync(path.join(parentDir, "skill-commands.json"))
            ? `generated: ${path.join(parentDir, "skill-commands.json")}`
            : `missing generated command registry at ${path.join(parentDir, "skill-commands.json")}`,
    });
    for (const skill of manifest.skills.filter((entry) => entry.visibleToAgents)) {
        const dest = skillMirrorPath(destDir, skill);
        let ok = false;
        try {
            ok = fs.existsSync(dest) && fs.realpathSync(dest) === fs.realpathSync(skill.root);
        }
        catch (err) {
            debugLog(`doctor: skill mirror check failed for ${dest}: ${errorMessage(err)}`);
            ok = false;
        }
        checks.push({
            name: `skills-mirror:${scope}/${skill.name}`,
            ok,
            detail: ok ? "ok" : `missing/drifted link at ${dest}`,
        });
    }
    for (const problem of manifest.problems) {
        checks.push({
            name: `skills-problem:${scope}:${problem.code}`,
            ok: false,
            detail: problem.message,
        });
    }
}
export async function runDoctor(phrenPath, fix = false, checkData = false, confirmStoreRemoval) {
    // Import runLink lazily to avoid circular dependency at module load time
    const { runLink } = await import("./link.js");
    const checks = [];
    const machine = getMachineName();
    const profile = lookupProfile(phrenPath, machine);
    const gitVersion = commandVersion("git");
    const nodeVersion = commandVersion("node");
    checks.push({
        name: "git-installed",
        ok: Boolean(gitVersion),
        detail: gitVersion || "git not found in PATH",
    });
    checks.push({
        name: "node-version",
        ok: versionAtLeast(nodeVersion, 20),
        detail: nodeVersion || "node not found in PATH",
    });
    const prefs = readInstallPreferences(phrenPath);
    const managementPreset = getManagementPreset(phrenPath);
    const caps = resolveManagementCapabilities(phrenPath);
    const gitRemote = gitRemoteStatus(phrenPath, prefs.syncIntent);
    checks.push({
        name: "git-remote",
        ok: gitRemote.ok,
        detail: gitRemote.detail,
    });
    // Root config must be materialized on disk, not merely tracked in git.
    const manifestStatus = rootConfigStatus(phrenPath, ROOT_MANIFEST_FILENAME);
    const manifestRepaired = !manifestStatus.present && manifestStatus.trackedInHead && fix
        ? materializeRootConfig(phrenPath, ROOT_MANIFEST_FILENAME)
        : false;
    checks.push({
        name: "root-manifest",
        ok: manifestStatus.present || manifestRepaired,
        detail: manifestStatus.present
            ? path.join(phrenPath, ROOT_MANIFEST_FILENAME)
            : manifestRepaired
                ? `${ROOT_MANIFEST_FILENAME} restored (was excluded by sparse-checkout)`
                : manifestStatus.trackedInHead
                    ? `${ROOT_MANIFEST_FILENAME} is tracked in git but excluded by sparse-checkout — MCP cannot start; run 'phren doctor --fix'`
                    : `${ROOT_MANIFEST_FILENAME} missing from ${phrenPath}`,
    });
    // stores.yaml is optional (single-store installs have none), so this is only a
    // failure when it exists in git but was never materialized.
    const storesStatus = rootConfigStatus(phrenPath, STORES_FILENAME);
    if (!storesStatus.present && storesStatus.trackedInHead) {
        const storesRepaired = fix ? materializeRootConfig(phrenPath, STORES_FILENAME) : false;
        checks.push({
            name: "store-registry-materialized",
            ok: storesRepaired,
            detail: storesRepaired
                ? `${STORES_FILENAME} restored (was excluded by sparse-checkout)`
                : `${STORES_FILENAME} is tracked in git but excluded by sparse-checkout — team stores are hidden; run 'phren doctor --fix'`,
        });
    }
    checks.push({
        name: "machine-registered",
        ok: Boolean(profile),
        detail: profile
            ? `machine=${machine} profile=${profile}`
            : `no profile mapping for machine=${machine} in machines.yaml`,
    });
    const profileFile = profile ? findProfileFile(phrenPath, profile) : null;
    checks.push({
        name: "profile-exists",
        ok: Boolean(profileFile),
        detail: profileFile ? `profile file found: ${profileFile}` : "profile file missing",
    });
    const projects = profileFile ? getProfileProjects(profileFile) : [];
    checks.push({
        name: "profile-projects",
        ok: projects.length > 0,
        detail: projects.length ? `${projects.length} projects in profile` : "no projects listed",
    });
    const invalidProjectDirs = listInvalidProjectDirs(phrenPath);
    checks.push({
        name: "project-names-valid",
        ok: invalidProjectDirs.length === 0,
        detail: invalidProjectDirs.length === 0
            ? "all project directories use valid names"
            : `${invalidProjectDirs.length} invalid project dir(s): ${invalidProjectDirs.join(", ")}. These are ignored by the UI and indexer. Run \`phren doctor --fix\` to rename to lowercase.`,
    });
    // Filesystem speed check
    const fsBenchFile = path.join(phrenPath, ".fs-bench-tmp");
    let fsMs = 0;
    try {
        const t0 = Date.now();
        fs.writeFileSync(fsBenchFile, "phren-fs-check");
        fs.readFileSync(fsBenchFile, "utf8");
        fs.unlinkSync(fsBenchFile);
        fsMs = Date.now() - t0;
    }
    catch (err) {
        logger.debug("doctor", `doctor fsBenchmark: ${errorMessage(err)}`);
        fsMs = -1;
        try {
            fs.unlinkSync(fsBenchFile);
        }
        catch (e2) {
            logger.debug("doctor", `doctor fsBenchmarkCleanup: ${e2 instanceof Error ? e2.message : String(e2)}`);
        }
    }
    const fsSlow = fsMs > 500 || fsMs < 0;
    checks.push({
        name: "filesystem-speed",
        ok: !fsSlow,
        detail: fsMs < 0
            ? "could not benchmark filesystem, check ~/.phren permissions"
            : `write+read+delete in ${fsMs}ms${fsSlow ? " (slow, check if ~/.phren is on a network mount)" : ""}`,
    });
    // Both generated home files are self-heal surfaces; under a preset without
    // selfHeal a missing one is expected, not a failure.
    const notSelfHealed = `not re-created under ${managementPreset} preset`;
    const contextFile = homePath(".phren-context.md");
    checks.push({
        name: "context-file",
        ok: fs.existsSync(contextFile) || !caps.selfHeal,
        detail: fs.existsSync(contextFile) ? contextFile : caps.selfHeal ? "missing ~/.phren-context.md" : notSelfHealed,
    });
    const memoryFile = path.join(homeDir(), ".claude", "projects", claudeProjectKey(), "memory", "MEMORY.md");
    checks.push({
        name: "root-memory",
        ok: fs.existsSync(memoryFile) || !caps.selfHeal,
        detail: fs.existsSync(memoryFile) ? memoryFile : caps.selfHeal ? "missing generated MEMORY.md" : notSelfHealed,
    });
    // Under presets that don't symlink into ~/.claude, the "missing" links are
    // expected — report them as ok with a preset note instead of failures.
    if (caps.linkGlobalClaudeMd) {
        const globalClaudeSrc = path.join(phrenPath, "global", "AGENTS.md");
        const globalClaudeDest = homePath(".claude", "CLAUDE.md");
        let globalLinkOk = false;
        try {
            globalLinkOk = fs.existsSync(globalClaudeDest) && fs.realpathSync(globalClaudeDest) === fs.realpathSync(globalClaudeSrc);
        }
        catch (err) {
            debugLog(`doctor: global AGENTS.md symlink check failed: ${errorMessage(err)}`);
            globalLinkOk = false;
        }
        checks.push({
            name: "global-link",
            ok: globalLinkOk,
            detail: globalLinkOk ? "global AGENTS.md symlink ok" : "global AGENTS.md link drifted/missing",
        });
    }
    else {
        checks.push({
            name: "global-link",
            ok: true,
            detail: `not managed under ${managementPreset} preset (phren does not write ~/.claude/CLAUDE.md)`,
        });
    }
    if (caps.installSkillLinks) {
        pushSkillMirrorChecks(checks, "global", buildSkillManifest(phrenPath, profile || "", "global", homePath(".claude", "skills")), homePath(".claude", "skills"));
    }
    else {
        checks.push({ name: "skills-mirror:global", ok: true, detail: `skills not mirrored under ${managementPreset} preset` });
    }
    for (const project of projects) {
        if (project === "global")
            continue;
        const config = readProjectConfig(phrenPath, project);
        const ownership = getProjectOwnershipMode(phrenPath, project, config);
        const target = findProjectDir(project);
        if (!caps.repoMirroring) {
            checks.push({
                name: `ownership:${project}`,
                ok: true,
                detail: `repo mirrors disabled (${managementPreset} preset)`,
            });
            continue;
        }
        if (ownership !== "phren-managed") {
            checks.push({
                name: `ownership:${project}`,
                ok: true,
                detail: `repo mirrors disabled (${ownership})`,
            });
            continue;
        }
        if (!target) {
            checks.push({ name: `project-path:${project}`, ok: false, detail: "project directory not found on disk" });
            continue;
        }
        for (const f of ["AGENTS.md", "REFERENCE.md", FINDINGS_FILENAME]) {
            const src = path.join(phrenPath, project, f);
            if (!fs.existsSync(src))
                continue;
            const dest = path.join(target, f);
            let ok = false;
            try {
                ok = fs.existsSync(dest) && fs.realpathSync(dest) === fs.realpathSync(src);
            }
            catch (err) {
                debugLog(`doctor: symlink check failed for ${dest}: ${errorMessage(err)}`);
                ok = false;
            }
            checks.push({
                name: `symlink:${project}/${f}`,
                ok,
                detail: ok ? "ok" : `missing/drifted link at ${dest}`,
            });
        }
        pushSkillMirrorChecks(checks, project, buildSkillManifest(phrenPath, profile || "", project, path.join(target, ".claude", "skills")), path.join(target, ".claude", "skills"));
    }
    // Store registry health
    try {
        const { resolveAllStores, storesFilePath, attachedStoresFilePath, describeUnavailableStore, ignoredSyncedStores } = await import("../store-registry.js");
        if (fs.existsSync(storesFilePath(phrenPath)) || fs.existsSync(attachedStoresFilePath(phrenPath))) {
            const stores = resolveAllStores(phrenPath);
            checks.push({
                name: "store-registry",
                ok: stores.length > 0,
                detail: stores.length > 0
                    ? `${stores.length} stores on this machine`
                    : "stores.yaml exists but no stores parsed",
            });
            // Only this machine's attachments are checked. Team stores another
            // machine joined with an older phren still sit in the synced file.
            const ignored = ignoredSyncedStores(phrenPath);
            if (ignored.length > 0) {
                checks.push({
                    name: "store-registry-synced",
                    ok: true,
                    detail: `ignoring ${ignored.map((s) => s.name).join(", ")} in the synced ${path.basename(storesFilePath(phrenPath))}: ` +
                        `team stores are attached per machine (${attachedStoresFilePath(phrenPath)}); remove them from stores.yaml once every machine runs this phren`,
                });
            }
            // Projects claimed by a store that is not attached here cannot be written
            // at all — phren refuses rather than diverting them to the primary store.
            const orphanedClaims = stores
                .filter((store) => store.available === false && store.projects?.length)
                .map((store) => `${store.name} → ${store.projects.join(", ")}`);
            checks.push({
                name: "store-claims-attached",
                ok: orphanedClaims.length === 0,
                detail: orphanedClaims.length === 0
                    ? "every claimed project resolves to an attached store"
                    : `projects claimed by unattached stores (writes to them will fail): ${orphanedClaims.join("; ")}`,
            });
            for (const store of stores) {
                const credentials = await storeCredentialCheck(phrenPath, store, fix, confirmStoreRemoval);
                if (credentials) {
                    checks.push(credentials);
                    continue;
                }
                const pathExists = store.available !== false;
                const gitExists = pathExists && fs.existsSync(path.join(store.path, ".git"));
                if (!pathExists) {
                    checks.push({
                        name: `store:${store.name}`,
                        ok: false,
                        detail: describeUnavailableStore(store),
                    });
                }
                else if (!gitExists) {
                    checks.push({
                        name: `store:${store.name}`,
                        ok: false,
                        detail: `store '${store.name}' path exists but .git directory missing`,
                    });
                }
                else {
                    checks.push({
                        name: `store:${store.name}`,
                        ok: true,
                        detail: `${store.role} store, sync=${store.sync}${store.remote ? `, remote=${store.remote}` : ""}`,
                    });
                }
            }
        }
    }
    catch (err) {
        debugLog(`doctor: store registry check failed: ${errorMessage(err)}`);
    }
    const settingsPath = hookConfigPath("claude");
    const configWritable = nearestWritableTarget(settingsPath);
    checks.push({
        name: "config-writable",
        ok: configWritable,
        detail: configWritable ? `writable: ${settingsPath}` : `not writable: ${settingsPath}`,
    });
    let hookOk = false;
    let lifecycleOk = false;
    let hooksEphemeral = false;
    let staleEntrypoints = [];
    /** Every `command` string under a Claude settings.json hook event. */
    const phrenHookCommands = (hooks) => {
        const commands = [];
        for (const event of ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"]) {
            const entries = hooks[event];
            if (!Array.isArray(entries))
                continue;
            for (const entry of entries) {
                if (!isRecord(entry) || !Array.isArray(entry.hooks))
                    continue;
                for (const hook of entry.hooks) {
                    if (isRecord(hook) && typeof hook.command === "string")
                        commands.push(hook.command);
                }
            }
        }
        return commands.filter((command) => /hook-(prompt|stop|session-start|tool)\b/.test(command));
    };
    const readHookState = () => {
        const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
        const hooks = isRecord(cfg?.hooks) ? cfg.hooks : {};
        const promptHooks = JSON.stringify(hooks.UserPromptSubmit || []);
        const stopHooks = JSON.stringify(hooks.Stop || []);
        const startHooks = JSON.stringify(hooks.SessionStart || []);
        const commands = phrenHookCommands(hooks);
        return {
            hookOk: promptHooks.includes("hook-prompt"),
            lifecycleOk: stopHooks.includes("hook-stop") && startHooks.includes("hook-session-start"),
            // A hook command that points into the npx cache (~/.npm/_npx/<hash>/…)
            // works until npx prunes that cache, then breaks silently.
            ephemeral: [promptHooks, stopHooks, startHooks].some(isEphemeralNpxPath),
            // A command whose entrypoint no longer exists is worse: it fails on every
            // single turn with MODULE_NOT_FOUND. `npm i -g @phren/cli` moved the
            // package entry in 0.1.40, so every upgraded install hit exactly this and
            // doctor still reported the path as fine.
            stale: findStaleHookEntrypoints(commands),
        };
    };
    try {
        const state = readHookState();
        hookOk = state.hookOk;
        lifecycleOk = state.lifecycleOk;
        hooksEphemeral = state.ephemeral;
        staleEntrypoints = state.stale;
    }
    catch (err) {
        debugLog(`doctor: failed to read Claude settings for hook check: ${errorMessage(err)}`);
        hookOk = false;
        lifecycleOk = false;
    }
    // Repair stale/ephemeral hook commands before reporting, so `--fix` actually
    // fixes the thing rather than reporting it as OK and doing nothing.
    let hookPathRepaired = false;
    if (fix && (staleEntrypoints.length > 0 || hooksEphemeral)) {
        try {
            const { configureClaude } = await import("../init/config.js");
            configureClaude(phrenPath);
            const state = readHookState();
            hookOk = state.hookOk;
            lifecycleOk = state.lifecycleOk;
            hooksEphemeral = state.ephemeral;
            hookPathRepaired = state.stale.length === 0 && !state.ephemeral;
            staleEntrypoints = state.stale;
        }
        catch (err) {
            debugLog(`doctor: hook path repair failed: ${errorMessage(err)}`);
        }
    }
    checks.push({
        name: "claude-hooks",
        ok: hookOk,
        detail: hookOk ? "prompt hook configured" : "missing prompt hook in settings.json",
    });
    checks.push({
        name: "lifecycle-hooks",
        ok: lifecycleOk,
        detail: lifecycleOk
            ? "session-start + stop lifecycle hooks configured"
            : "missing lifecycle hooks (expected hook-session-start and hook-stop)",
    });
    checks.push({
        name: "hook-path-stable",
        ok: !hooksEphemeral && staleEntrypoints.length === 0,
        detail: staleEntrypoints.length > 0
            ? `hook commands point at ${staleEntrypoints.length} entrypoint(s) that no longer exist: ${staleEntrypoints.join(", ")}. `
                + `Every hook fails with MODULE_NOT_FOUND — usually after \`npm install -g @phren/cli\` moved the package entry. `
                + `Run \`phren doctor --fix\` to repoint them at ${resolveEntryScript()}`
            : hooksEphemeral
                ? "hook commands point into the ephemeral npx cache (~/.npm/_npx/…); re-run `npx @phren/cli init` to repoint at the stable ~/.local/bin/phren wrapper"
                : hookPathRepaired
                    ? "hook commands repaired to the current entrypoint"
                    : "hook commands resolve to an entrypoint that exists",
    });
    const runtimeHealthPath = runtimeHealthFile(phrenPath);
    let runtime = null;
    if (fs.existsSync(runtimeHealthPath)) {
        try {
            runtime = JSON.parse(fs.readFileSync(runtimeHealthPath, "utf8"));
        }
        catch (err) {
            logger.debug("doctor", `doctor runtimeHealth: ${errorMessage(err)}`);
            runtime = null;
        }
    }
    // What an agent pays before it says a word, and per prompt after. None of
    // the tidying stays done unless this is visible.
    try {
        const weight = storeWeight(phrenPath);
        const profile = resolveMcpProfile(phrenPath);
        const injection = medianHookInjectionTokens(phrenPath);
        const problems = [];
        if (weight.globalClaude > CONTEXT_COST_LIMITS.globalClaudeWords)
            problems.push(`global AGENTS.md is ${weight.globalClaude} words (aim under ${CONTEXT_COST_LIMITS.globalClaudeWords})`);
        if (profile === "full")
            problems.push("MCP profile is full (61 tools, ~53k chars of schema per session); `phren config mcp-profile core` is ~17k");
        if (injection.medianTokens > CONTEXT_COST_LIMITS.medianInjectionTokens)
            problems.push(`median hook injection is ${injection.medianTokens} tokens over the last ${injection.prompts} prompts`);
        checks.push({
            name: "context-cost",
            ok: problems.length === 0,
            detail: problems.length
                ? problems.join("; ")
                : `global AGENTS.md ${weight.globalClaude} words · MCP profile ${profile} (${profile === "core" ? "10 tools, ~17k chars" : "61 tools, ~53k chars"})${injection.prompts ? ` · median injection ${injection.medianTokens} tokens over ${injection.prompts} prompts` : ""} · store: findings ${weight.findings.toLocaleString("en-US")} words, archive ${weight.reference.toLocaleString("en-US")}, tasks ${weight.tasks.toLocaleString("en-US")}, skills ${weight.skills.toLocaleString("en-US")}`,
        });
    }
    catch (err) {
        logger.debug("doctor", `context-cost: ${errorMessage(err)}`);
    }
    checks.push({
        name: "runtime-health-file",
        ok: Boolean(runtime),
        detail: runtime ? runtimeHealthPath : "missing or unreadable .runtime/runtime-health.json",
    });
    const lastAutoSave = runtime?.["lastAutoSave"];
    const autoSaveObj = isRecord(lastAutoSave) ? lastAutoSave : null;
    const autoSaveStatus = typeof autoSaveObj?.["status"] === "string" ? autoSaveObj["status"] : undefined;
    const autoSaveAt = typeof autoSaveObj?.["at"] === "string" ? autoSaveObj["at"] : undefined;
    checks.push({
        name: "runtime-auto-save",
        ok: autoSaveStatus === "saved-pushed" || autoSaveStatus === "saved-local" || autoSaveStatus === "clean",
        detail: describeAutoSave({ status: autoSaveStatus, at: autoSaveAt, detail: typeof autoSaveObj?.["detail"] === "string" ? autoSaveObj["detail"] : undefined }, isRecord(runtime?.["lastSync"]) ? runtime["lastSync"] : undefined),
    });
    // Each store against its remote: diverged, unsynced for over an hour, or
    // failing. --fix first collapses repeated task ids (the data layer refuses
    // to edit them), then runs one full reconcile.
    try {
        const { resolveAllStores } = await import("../store-registry.js");
        const { syncStore, storeSyncState } = await import("../sync/store-sync.js");
        const { dedupeTaskIds } = await import("../sync/task-merge.js");
        for (const store of resolveAllStores(phrenPath).filter((entry) => entry.available !== false && fs.existsSync(path.join(entry.path, ".git")))) {
            const repeated = [];
            for (const dir of fs.readdirSync(store.path, { withFileTypes: true })) {
                if (!dir.isDirectory() || dir.name.startsWith("."))
                    continue;
                const file = path.join(store.path, dir.name, "tasks.md");
                let text;
                try {
                    text = fs.readFileSync(file, "utf8");
                }
                catch {
                    continue;
                }
                const result = dedupeTaskIds(text);
                if (!result.dropped)
                    continue;
                if (fix)
                    fs.writeFileSync(file, result.content);
                repeated.push(`${dir.name}/tasks.md (${result.dropped})`);
            }
            if (repeated.length) {
                checks.push({
                    name: `task-ids:${store.name}`,
                    ok: fix,
                    detail: fix ? `collapsed repeated task ids in ${repeated.join(", ")}` : `repeated task ids in ${repeated.join(", ")}; edits to those tasks are refused. Run \`phren doctor --fix\`.`,
                });
            }
            let synced = "";
            if (fix) {
                const outcome = await syncStore(store.path, { push: store.role !== "readonly" && store.sync !== "pull-only", source: "doctor" });
                synced = `${outcome.status}: ${outcome.detail}${outcome.cleaned.length ? ` (${outcome.cleaned.join("; ")})` : ""}`;
            }
            const state = await storeSyncState(store.name, store.path);
            if (!state.upstream && !synced)
                continue;
            const healthy = !state.diverged && !state.stale && !state.error;
            checks.push({
                name: `store-sync:${store.name}`,
                ok: healthy,
                detail: [
                    healthy ? `in sync${state.lastSyncedAt ? `, last synced ${state.lastSyncedAt}` : ""}` : state.warning ?? "not in sync",
                    synced && `doctor --fix: ${synced}`,
                    !healthy && !fix ? "run `phren doctor --fix` to reconcile" : "",
                ].filter(Boolean).join("; "),
            });
        }
    }
    catch (err) {
        checks.push({ name: "store-sync", ok: false, detail: `could not check store sync: ${errorMessage(err)}` });
    }
    checks.push({
        name: "runtime-prompt",
        ok: Boolean(runtime?.["lastPromptAt"]),
        detail: runtime?.["lastPromptAt"] ? `last prompt hook run @ ${runtime["lastPromptAt"]}` : "no prompt runtime record yet",
    });
    try {
        const db = await buildIndex(phrenPath, profile || undefined);
        const healthRow = queryRows(db, "SELECT count(*) FROM docs", []);
        const count = Number(healthRow?.[0]?.[0] ?? 0);
        checks.push({
            name: "fts-index",
            ok: Number.isFinite(count) && count >= 0,
            detail: `index query ok (docs=${count})`,
        });
    }
    catch (err) {
        checks.push({
            name: "fts-index",
            ok: false,
            detail: `index build/query failed: ${errorMessage(err)}`,
        });
    }
    // Repair before the hook and wrapper checks below, so they report what
    // --fix left behind: the relink is what writes codex.json, the Copilot and
    // Cursor hook files and the wrappers.
    if (fix) {
        const repaired = repairPreexistingInstall(phrenPath);
        const details = [];
        if (repaired.removedLegacyProjects > 0)
            details.push(`removed ${repaired.removedLegacyProjects} legacy sample profile entries`);
        if (repaired.createdContextFile)
            details.push("recreated ~/.phren-context.md");
        if (repaired.createdRootMemory)
            details.push("recreated generated MEMORY.md");
        if (details.length === 0)
            details.push("baseline repair complete");
        checks.push({ name: "baseline-repair", ok: true, detail: details.join("; ") });
    }
    if (fix && invalidProjectDirs.length > 0) {
        const migration = migrateInvalidProjectNames(phrenPath);
        const unresolved = migration.outcomes.filter((o) => o.action !== "renamed");
        checks.push({
            name: "project-names-migrate",
            ok: unresolved.length === 0,
            detail: formatMigrationSummary(migration),
        });
    }
    if (fix && profile && profileFile) {
        await runLink(phrenPath, { machine, profile });
        checks.push({ name: "self-heal", ok: true, detail: "relinked hooks, symlinks, context, memory pointers" });
    }
    else if (fix) {
        checks.push({ name: "self-heal", ok: false, detail: "relink blocked: machine/profile not fully configured" });
    }
    const detected = detectInstalledTools();
    if (detected.has("copilot")) {
        const copilotHooks = hookConfigPath("copilot", phrenPath);
        const copilotWritable = nearestWritableTarget(copilotHooks);
        checks.push({
            name: "copilot-hooks",
            ok: fs.existsSync(copilotHooks),
            detail: fs.existsSync(copilotHooks) ? "copilot hooks config present" : "missing copilot hooks config",
        });
        checks.push({
            name: "copilot-config-writable",
            ok: copilotWritable,
            detail: copilotWritable ? `writable: ${copilotHooks}` : `not writable: ${copilotHooks}`,
        });
    }
    if (detected.has("cursor")) {
        const cursorHooks = hookConfigPath("cursor", phrenPath);
        const cursorWritable = nearestWritableTarget(cursorHooks);
        checks.push({
            name: "cursor-hooks",
            ok: fs.existsSync(cursorHooks),
            detail: fs.existsSync(cursorHooks) ? "cursor hooks config present" : "missing ~/.cursor/hooks.json",
        });
        checks.push({
            name: "cursor-config-writable",
            ok: cursorWritable,
            detail: cursorWritable ? `writable: ${cursorHooks}` : `not writable: ${cursorHooks}`,
        });
    }
    if (detected.has("codex")) {
        const codexHooks = hookConfigPath("codex", phrenPath);
        const codexWritable = nearestWritableTarget(codexHooks);
        checks.push({
            name: "codex-hooks",
            ok: fs.existsSync(codexHooks),
            detail: fs.existsSync(codexHooks) ? "codex hooks config present" : "missing codex hooks config in phren root",
        });
        checks.push({
            name: "codex-config-writable",
            ok: codexWritable,
            detail: codexWritable ? `writable: ${codexHooks}` : `not writable: ${codexHooks}`,
        });
    }
    for (const tool of ["copilot", "cursor", "codex"]) {
        // A tool can count as detected from its config folder alone (Copilot's
        // ~/.copilot, say); a wrapper only makes sense around a binary on PATH.
        if (!detected.has(tool) || !commandExists(tool))
            continue;
        checks.push(wrapperCheck(tool, wrapperState(tool)));
    }
    checks.push(wrapperCheck("phren", wrapperState("phren")));
    if (!fix) {
        // Read-only mode: just check if hook configs exist, don't write anything
        const detectedTools = detected;
        const hookChecks = [];
        const missing = [];
        for (const tool of detectedTools) {
            let configPath = "";
            if (tool === "copilot" || tool === "cursor" || tool === "codex") {
                configPath = hookConfigPath(tool, phrenPath);
            }
            if (configPath && fs.existsSync(configPath))
                hookChecks.push(tool);
            else if (configPath)
                missing.push(tool);
        }
        checks.push({
            name: "hooks",
            ok: missing.length === 0,
            detail: hookChecks.length
                ? `hook configs present for: ${hookChecks.join(", ")}${missing.length ? `; missing: ${missing.join(", ")}` : ""}`
                : detectedTools.size === 0
                    ? "no external tools detected"
                    : `missing hook configs for: ${missing.join(", ")}`,
        });
    }
    if (checkData) {
        const governanceChecks = [
            { file: "retention-policy.json", schema: "retention-policy" },
            { file: "workflow-policy.json", schema: "workflow-policy" },
            { file: "index-policy.json", schema: "index-policy" },
        ];
        for (const item of governanceChecks) {
            const filePath = path.join(phrenPath, ".config", item.file);
            const exists = fs.existsSync(filePath);
            const valid = exists ? validateGovernanceJson(filePath, item.schema) : false;
            checks.push({
                name: `data:governance:${item.file}`,
                ok: exists && valid,
                detail: !exists ? "missing governance file" : valid ? "valid" : "invalid JSON/schema",
            });
        }
        const runtimeChecks = [
            { filePath: runtimeHealthFile(phrenPath), name: "data:runtime:runtime-health.json" },
        ];
        for (const item of runtimeChecks) {
            const exists = fs.existsSync(item.filePath);
            checks.push({
                name: item.name,
                ok: exists,
                detail: exists ? "present" : "missing runtime file",
            });
        }
        for (const projectDir of getProjectDirs(phrenPath, profile)) {
            const projectName = path.basename(projectDir);
            if (projectName === "global")
                continue;
            const taskPath = resolveTaskFilePath(phrenPath, projectName);
            if (taskPath && fs.existsSync(taskPath)) {
                const content = fs.readFileSync(taskPath, "utf8");
                const issues = validateTaskFormat(content);
                checks.push({
                    name: `data:tasks:${projectName}`,
                    ok: issues.length === 0,
                    detail: issues.length ? issues.join("; ") : "valid task file",
                });
                const repoPath = findProjectDir(projectName);
                const hygiene = inspectTaskHygiene(phrenPath, projectName, repoPath);
                checks.push({
                    name: `data:task-hygiene:${projectName}`,
                    ok: hygiene.ok,
                    detail: hygiene.detail,
                });
            }
            const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
            if (fs.existsSync(findingsPath)) {
                const content = fs.readFileSync(findingsPath, "utf8");
                const issues = validateFindingsFormat(content);
                checks.push({
                    name: `data:findings:${projectName}`,
                    ok: issues.length === 0,
                    detail: issues.length ? issues.join("; ") : "valid",
                });
            }
        }
        // Detect conflict markers in project markdown files
        for (const projectDir of getProjectDirs(phrenPath, profile)) {
            const projectName = path.basename(projectDir);
            if (projectName === "global")
                continue;
            for (const mdFile of [FINDINGS_FILENAME, ...TASK_FILE_ALIASES, "review.md", "AGENTS.md", "REFERENCE.md"]) {
                const filePath = path.join(projectDir, mdFile);
                if (!fs.existsSync(filePath))
                    continue;
                const content = fs.readFileSync(filePath, "utf8");
                const hasConflict = /^<{7} |^={7}$|^>{7} /m.test(content);
                if (hasConflict) {
                    checks.push({
                        name: `data:conflict-markers:${projectName}/${mdFile}`,
                        ok: false,
                        detail: `${projectName}/${mdFile} contains git conflict markers`,
                    });
                }
            }
        }
        // Validate skill frontmatter in global/skills under phren data dir
        const globalSkillsDir = path.join(phrenPath, "global", "skills");
        const skillResults = validateSkillsDir(fs.existsSync(globalSkillsDir) ? globalSkillsDir : path.join(phrenPath, "global"));
        const invalidSkills = skillResults.filter(r => !r.valid);
        checks.push({
            name: "data:skills-frontmatter",
            ok: invalidSkills.length === 0,
            detail: invalidSkills.length
                ? `${invalidSkills.length} skill(s) with invalid frontmatter: ${invalidSkills.flatMap(r => r.errors).join("; ")}`
                : `${skillResults.length} skill(s) validated`,
        });
        // Validate phren.SKILL.md manifest
        const manifestPath = path.join(phrenPath, "phren.SKILL.md");
        if (fs.existsSync(manifestPath)) {
            const manifestResult = validateSkillFrontmatter(fs.readFileSync(manifestPath, "utf8"), manifestPath);
            checks.push({
                name: "data:skill-manifest",
                ok: manifestResult.valid,
                detail: manifestResult.valid ? "phren.SKILL.md frontmatter valid" : manifestResult.errors.join("; "),
            });
        }
        // Verify file checksums
        const checksumResults = verifyFileChecksums(phrenPath);
        const mismatches = checksumResults.filter((r) => r.status === "mismatch");
        const missingFiles = checksumResults.filter((r) => r.status === "missing");
        if (checksumResults.length > 0) {
            checks.push({
                name: "data:file-checksums",
                ok: mismatches.length === 0 && missingFiles.length === 0,
                detail: mismatches.length || missingFiles.length
                    ? `${mismatches.length} mismatch(es), ${missingFiles.length} missing`
                    : `${checksumResults.length} file(s) verified`,
            });
        }
        if (fix) {
            updateFileChecksums(phrenPath, profile);
        }
    }
    // Zombie blocked-task scan: pre-0.1.25 task lifecycle promoted tasks to Active
    // and stamped them "Blocked: Command failed: git add -A" on transient git
    // failures. Those tasks never resolve themselves. With --fix, archive them
    // to Done with a phren-managed comment so they stop appearing in Active.
    try {
        const zombieResult = scanAndOptionallyFixZombieTasks(phrenPath, fix);
        if (zombieResult.scanned > 0) {
            const cleared = zombieResult.zombies === 0 || fix;
            checks.push({
                name: "zombie-blocked-tasks",
                ok: cleared,
                detail: zombieResult.zombies === 0
                    ? `0 zombie blocked tasks across ${zombieResult.scanned} project(s)`
                    : fix
                        ? `archived ${zombieResult.zombies} zombie task(s) across ${zombieResult.fixedProjects} project(s)`
                        : `${zombieResult.zombies} zombie blocked task(s) found across ${zombieResult.affectedProjects} project(s). Run \`phren doctor --fix\` to archive them.`,
            });
        }
    }
    catch (err) {
        debugLog(`doctor: zombie task scan failed: ${errorMessage(err)}`);
    }
    // `@path` import lines in store-managed AGENTS.md/CLAUDE.md resolve outside
    // the repo (the repo file is a symlink into the store), so Claude Code blocks
    // on its external-imports dialog. With --fix, rewrite them as plain mentions.
    try {
        const importHits = scanContextImports(phrenPath);
        const fixedFiles = importHits.length && fix ? fixContextImports(phrenPath, importHits) : [];
        const scopes = [...new Set(importHits.map((h) => h.scope))];
        checks.push({
            name: "context-imports",
            ok: importHits.length === 0 || fixedFiles.length > 0,
            detail: importHits.length === 0
                ? "no @imports in managed AGENTS.md/CLAUDE.md"
                : fixedFiles.length
                    ? `rewrote ${importHits.length} @import line(s) as plain references in ${fixedFiles.length} file(s) (${scopes.join(", ")})`
                    : `${importHits.length} @import line(s) in ${scopes.join(", ")} resolve outside the repo and trigger Claude Code's "Allow external CLAUDE.md file imports?" dialog. Run \`phren doctor --fix\` to rewrite them as plain references.`,
        });
    }
    catch (err) {
        debugLog(`doctor: context-imports scan failed: ${errorMessage(err)}`);
    }
    // Spoken replies are optional, so only a stored key other users can read,
    // or a broken file, fails the check. The key itself is never shown.
    try {
        const { speechKeyStatus } = await import("../bridge/speech-key.js");
        const speech = await speechKeyStatus();
        checks.push({ name: "speech-key", ok: !speech.problem, detail: `${speech.configured ? "configured" : "not configured"}: ${speech.detail}` });
    }
    catch (err) {
        debugLog(`doctor: speech-key check failed: ${errorMessage(err)}`);
    }
    const ok = checks.every((c) => c.ok);
    return { ok, machine, profile: profile || undefined, checks };
}
const ZOMBIE_BLOCKED_CONTEXT_RE = /^\s*Context:\s*Blocked:\s*Command failed:\s*git\s+(?:add|commit|push|stage|pull|fetch|stash)\b/i;
function scanAndOptionallyFixZombieTasks(phrenPath, fix) {
    const result = { scanned: 0, affectedProjects: 0, fixedProjects: 0, zombies: 0 };
    const projectDirs = getProjectDirs(phrenPath);
    for (const project of projectDirs) {
        const filePath = resolveTaskFilePath(phrenPath, project);
        if (!filePath || !fs.existsSync(filePath))
            continue;
        result.scanned += 1;
        const original = fs.readFileSync(filePath, "utf8");
        const lines = original.split("\n");
        // Walk in Active section; find task lines whose next line is a zombie Context.
        let section = null;
        const zombieIndices = [];
        for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            const heading = line.match(/^##\s+(\w+)/);
            if (heading) {
                section = heading[1];
                continue;
            }
            if (section !== "Active")
                continue;
            if (!/^- \[ \]/.test(line))
                continue;
            const next = lines[i + 1];
            if (next && ZOMBIE_BLOCKED_CONTEXT_RE.test(next)) {
                zombieIndices.push(i);
            }
        }
        if (zombieIndices.length === 0)
            continue;
        result.affectedProjects += 1;
        result.zombies += zombieIndices.length;
        if (!fix)
            continue;
        // Build edited file: move zombie tasks (and their Context line) to ## Done.
        // Mark them [x] and prepend "(phren: archived zombie git-failure block)".
        const archived = [];
        const keepLines = [];
        section = null;
        let insertDoneAfter = null;
        for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            const heading = line.match(/^##\s+(\w+)/);
            if (heading) {
                section = heading[1];
                if (section === "Done")
                    insertDoneAfter = keepLines.length;
                keepLines.push(line);
                continue;
            }
            if (section === "Active" && zombieIndices.includes(i)) {
                const taskLine = line.replace(/^- \[ \]/, "- [x]");
                archived.push(`${taskLine}  <!-- phren: archived zombie git-failure block -->`);
                // Carry Context line if present.
                const next = lines[i + 1];
                if (next && ZOMBIE_BLOCKED_CONTEXT_RE.test(next)) {
                    archived.push(next);
                    i += 1;
                }
                continue;
            }
            keepLines.push(line);
        }
        if (archived.length > 0 && insertDoneAfter !== null) {
            // Insert the archived block right after `## Done` heading (and the blank line).
            const before = keepLines.slice(0, insertDoneAfter + 1);
            const after = keepLines.slice(insertDoneAfter + 1);
            const archivedBlock = ["", ...archived];
            const next = [...before, ...archivedBlock, ...after].join("\n");
            fs.writeFileSync(filePath, next, "utf8");
            result.fixedProjects += 1;
        }
    }
    return result;
}