UNPKG

@phren/cli

Version:

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

1,030 lines (1,029 loc) • 49.3 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 } 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 = path.join(destDir, skill.format === "folder" ? skill.name : path.basename(skill.path)); 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), }); 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; }