@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
509 lines (508 loc) • 22.9 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { mcpResponse, resolveStoreForProject } from "./types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { runtimeFile, getProjectDirs, projectSlugFromPath } from "../shared.js";
import { findFtsCacheForPath } from "../shared/index.js";
import { isValidProjectName, errorMessage } from "../utils.js";
import { readReviewQueue, readReviewQueueAcrossProjects, approveQueueItem, rejectQueueItem, editQueueItem } from "../data/access.js";
import { addProjectFromPath } from "../core/project.js";
import { PROJECT_OWNERSHIP_MODES, parseProjectOwnershipMode } from "../project-config.js";
import { resolveRuntimeProfile } from "../runtime-profile.js";
import { getMachineName } from "../machine-identity.js";
import { resolveAllStores, describeUnavailableStore } from "../store-registry.js";
import { getProjectConsolidationStatus, CONSOLIDATION_ENTRY_THRESHOLD } from "../content/validate.js";
import { logger } from "../logger.js";
import { getRuntimeHealth } from "../governance/policy.js";
import { countUnsyncedCommits } from "../cli/session-git.js";
// ── Handlers ─────────────────────────────────────────────────────────────────
async function handleAddProject(ctx, { path: targetPath, profile: requestedProfile, ownership, store: storeName }) {
const { phrenPath, profile, withWriteQueue } = ctx;
return withWriteQueue(async () => {
try {
// Resolve the target store path: explicit store > auto-route by project claim > primary
let targetPhrenPath = phrenPath;
let storeRole = "primary";
const projectName = targetPath ? projectSlugFromPath(targetPath) : undefined;
if (storeName) {
const stores = resolveAllStores(phrenPath);
const store = stores.find((s) => s.name === storeName);
if (!store) {
return mcpResponse({ ok: false, error: `Store "${storeName}" not found` });
}
if (store.role === "readonly") {
return mcpResponse({ ok: false, error: `Store "${storeName}" is read-only` });
}
if (store.available === false) {
return mcpResponse({ ok: false, error: describeUnavailableStore(store) });
}
targetPhrenPath = store.path;
storeRole = store.role;
}
else if (projectName) {
// Same rule as every other write: a store that claims this project but
// is not attached here must fail, never fall through to the primary
// store — that is what copied work projects into the personal store.
try {
const resolved = resolveStoreForProject(ctx, projectName);
targetPhrenPath = resolved.phrenPath;
storeRole = resolved.storeRole;
}
catch (err) {
return mcpResponse({ ok: false, error: errorMessage(err) });
}
}
const added = addProjectFromPath(phrenPath, targetPath, requestedProfile || profile || undefined, parseProjectOwnershipMode(ownership) ?? undefined, { writeToPath: targetPhrenPath });
if (!added.ok) {
return mcpResponse({
ok: false,
error: added.error,
});
}
await ctx.rebuildIndex();
return mcpResponse({
ok: true,
message: `Added project "${added.data.project}" (${added.data.ownership}) from ${added.data.path}` +
(storeRole !== "primary" ? ` [store: ${storeName || storeRole}]` : "") +
`.`,
data: added.data,
});
}
catch (err) {
return mcpResponse({
ok: false,
error: errorMessage(err),
});
}
});
}
async function handleHealthCheck(ctx, { include_consolidation }) {
const { phrenPath, profile } = ctx;
const activeProfile = (() => {
try {
return resolveRuntimeProfile(phrenPath);
}
catch {
return profile || "";
}
})();
// Version
let version = "unknown";
try {
const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
version = pkg.version || "unknown";
}
catch (err) {
logger.debug("healthCheck version", errorMessage(err));
}
// FTS index (lives in /tmpphren-fts-*/, not .runtime/)
let indexStatus = { exists: false };
try {
indexStatus = findFtsCacheForPath(phrenPath, activeProfile);
}
catch (err) {
logger.debug("healthCheck ftsCacheCheck", errorMessage(err));
}
// Hook registration
let hooksEnabled = false;
try {
const { getHooksEnabledPreference } = await import("../init/preferences.js");
hooksEnabled = getHooksEnabledPreference(phrenPath);
}
catch (err) {
logger.debug("healthCheck hooksEnabled", errorMessage(err));
}
let mcpEnabled = false;
try {
const { getMcpEnabledPreference } = await import("../init/preferences.js");
mcpEnabled = getMcpEnabledPreference(phrenPath);
}
catch (err) {
logger.debug("healthCheck mcpEnabled", errorMessage(err));
}
// Profile/machine info
const machineName = (() => {
try {
return getMachineName();
}
catch (err) {
logger.debug("healthCheck machineName", errorMessage(err));
}
return undefined;
})();
const projectCount = getProjectDirs(phrenPath, activeProfile).length;
// Proactivity and taskMode
let proactivity = "high";
let taskMode = "auto";
try {
const { getWorkflowPolicy } = await import("../governance/policy.js");
const workflowPolicy = getWorkflowPolicy(phrenPath);
taskMode = workflowPolicy.taskMode;
}
catch (err) {
logger.debug("healthCheck taskMode", errorMessage(err));
}
let syncIntent;
try {
const { readInstallPreferences } = await import("../init/preferences.js");
const prefs = readInstallPreferences(phrenPath);
proactivity = prefs.proactivity || "high";
syncIntent = prefs.syncIntent;
}
catch (err) {
logger.debug("healthCheck proactivity", errorMessage(err));
}
// Determine sync status from intent + git remote state
let syncStatus = "local-only";
let syncDetail = "no git remote configured";
try {
const { execFileSync } = await import("child_process");
const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
env: nonInteractiveGitEnv(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
if (remote) {
try {
execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: 10_000,
});
syncStatus = "synced";
syncDetail = `origin=${remote}`;
}
catch {
syncStatus = syncIntent === "sync" ? "broken" : "local-only";
syncDetail = `origin=${remote} (unreachable)`;
}
}
else if (syncIntent === "sync") {
syncStatus = "broken";
syncDetail = "sync was configured but no remote found";
}
}
catch {
if (syncIntent === "sync") {
syncStatus = "broken";
syncDetail = "sync was configured but no remote found";
}
}
let consolidation = null;
if (include_consolidation !== false) {
try {
const projectDirsForConsol = getProjectDirs(phrenPath, activeProfile);
const consolResults = [];
for (const dir of projectDirsForConsol) {
const status = getProjectConsolidationStatus(dir);
if (!status)
continue;
consolResults.push({ ...status, threshold: CONSOLIDATION_ENTRY_THRESHOLD });
}
consolidation = consolResults;
}
catch (err) {
logger.debug("healthCheck consolidation", errorMessage(err));
consolidation = null;
}
}
const consolSummary = consolidation && consolidation.length > 0
? consolidation.filter(r => r.recommended).length > 0
? `Consolidation: ${consolidation.filter(r => r.recommended).length} project(s) need consolidation`
: `Consolidation: all projects OK`
: null;
// ── Surface RuntimeHealth warnings ────────────────────────────────────
const warnings = [];
try {
const health = getRuntimeHealth(phrenPath);
// Unsynced commits
const unsynced = health.lastSync?.unsyncedCommits;
if (typeof unsynced === "number" && unsynced > 0) {
warnings.push(`Unsynced commits: ${unsynced} (last push: ${health.lastSync?.lastPushStatus ?? "unknown"})`);
}
// Last auto-save error
if (health.lastAutoSave?.status === "error") {
warnings.push(`Last auto-save failed: ${health.lastAutoSave.detail ?? "unknown error"}`);
}
// Last push error
if (health.lastSync?.lastPushStatus === "error") {
warnings.push(`Last push failed: ${health.lastSync.lastPushDetail ?? "unknown error"}`);
}
// Check live unsynced commit count (may differ from cached value)
if (syncStatus === "synced" && (!unsynced || unsynced === 0)) {
try {
const liveUnsynced = await countUnsyncedCommits(phrenPath);
if (liveUnsynced > 0) {
warnings.push(`Unsynced commits: ${liveUnsynced} (not yet pushed to remote)`);
}
}
catch (err) {
logger.debug("healthCheck liveUnsyncedCount", errorMessage(err));
}
}
}
catch (err) {
logger.debug("healthCheck runtimeHealth", errorMessage(err));
}
// Check recent sync warnings from background sync
try {
const syncWarningsPath = runtimeFile(phrenPath, "sync-warnings.jsonl");
if (fs.existsSync(syncWarningsPath)) {
const lines = fs.readFileSync(syncWarningsPath, "utf8").trim().split("\n").filter(Boolean);
const recent = lines.slice(-3); // last 3 warnings
for (const line of recent) {
try {
const entry = JSON.parse(line);
if (entry.error) {
warnings.push(`Background sync failed (${entry.at?.slice(0, 16) ?? "unknown"}): ${entry.error}`);
}
}
catch { /* skip malformed lines */ }
}
}
}
catch (err) {
logger.debug("healthCheck syncWarnings", errorMessage(err));
}
// Check embedding/LLM availability
try {
const { getOllamaUrl } = await import("../shared/ollama.js");
const ollamaUrl = getOllamaUrl();
const hasEmbeddingApi = !!process.env.PHREN_EMBEDDING_API_URL;
if (!ollamaUrl && !hasEmbeddingApi) {
warnings.push("Embeddings: unavailable (no Ollama or API endpoint configured)");
}
const hasLlmEndpoint = !!process.env.PHREN_LLM_ENDPOINT;
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasOpenAiKey = !!process.env.OPENAI_API_KEY;
if (!hasLlmEndpoint && !hasAnthropicKey && !hasOpenAiKey) {
warnings.push("LLM features: unavailable (no API key configured for semantic dedup/conflict detection)");
}
}
catch (err) {
logger.debug("healthCheck serviceAvailability", errorMessage(err));
}
const warningsSummary = warnings.length > 0
? `Warnings: ${warnings.length}\n ${warnings.join("\n ")}`
: null;
const lines = [
`Phren v${version}`,
`Profile: ${activeProfile || "(default)"}`,
machineName ? `Machine: ${machineName}` : null,
`Projects: ${projectCount}`,
`FTS index: ${indexStatus.exists ? `ok (${Math.round((indexStatus.sizeBytes ?? 0) / 1024)} KB)` : "missing"}`,
`MCP: ${mcpEnabled ? "enabled" : "disabled"}`,
`Hooks: ${hooksEnabled ? "enabled" : "disabled"}`,
`Proactivity: ${proactivity}`,
`Task mode: ${taskMode}`,
`Sync: ${syncStatus}${syncStatus !== "synced" ? ` (${syncDetail})` : ""}`,
consolSummary,
warningsSummary,
`Path: ${phrenPath}`,
].filter(Boolean);
return mcpResponse({
ok: true,
message: lines.join("\n"),
data: {
version,
profile: activeProfile || "(default)",
machine: machineName ?? null,
projectCount,
index: indexStatus,
mcpEnabled,
hooksEnabled,
proactivity,
taskMode,
syncStatus,
syncDetail,
consolidation,
warnings,
phrenPath,
},
});
}
async function handleDoctorFix(_ctx, { check_data }) {
const { phrenPath } = _ctx;
const { runDoctor } = await import("../link/doctor.js");
const result = await runDoctor(phrenPath, true, check_data ?? false);
const lines = result.checks.map((c) => `${c.ok ? "ok" : "FAIL"} ${c.name}: ${c.detail}`);
const failCount = result.checks.filter((c) => !c.ok).length;
return mcpResponse({
ok: result.ok,
...(result.ok ? {} : { error: `${failCount} check(s) could not be auto-fixed: ${lines.filter((l) => l.startsWith("FAIL")).join("; ")}` }),
message: result.ok
? `Doctor fix complete: all ${result.checks.length} checks passed`
: `Doctor fix complete: ${failCount} issue(s) remain`,
data: {
machine: result.machine,
profile: result.profile,
checks: result.checks,
summary: lines.join("\n"),
},
});
}
async function handleListHookErrors(ctx, { limit }) {
const { phrenPath } = ctx;
const maxEntries = limit ?? 20;
const ERROR_PATTERNS = [
/\berror\b/i,
/\bfail(ed|ure|s)?\b/i,
/\bcrash(ed)?\b/i,
/\btimeout\b/i,
/\bEXCEPTION\b/i,
/\bEACCES\b/,
/\bENOENT\b/,
/\bEPERM\b/,
/\bENOSPC\b/,
];
function readErrorLines(filePath, filterPatterns) {
try {
if (!fs.existsSync(filePath))
return [];
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split("\n").filter(l => l.trim());
if (!filterPatterns)
return lines; // hook-errors.log: every line is an error
return lines.filter(line => ERROR_PATTERNS.some(p => p.test(line)));
}
catch (err) {
logger.debug("readErrorLines", errorMessage(err));
return [];
}
}
// hook-errors.log contains only hook failure lines (no filtering needed)
const hookErrors = readErrorLines(runtimeFile(phrenPath, "hook-errors.log"), false);
// debug.log may contain non-error lines, so filter
const debugErrors = readErrorLines(runtimeFile(phrenPath, "debug.log"), true);
const allErrors = [...hookErrors, ...debugErrors];
if (allErrors.length === 0) {
return mcpResponse({
ok: true,
message: "No error entries found. Hook errors go to hook-errors.log; general errors require PHREN_DEBUG=1.",
data: { errors: [], total: 0 },
});
}
const recent = allErrors.slice(-maxEntries);
return mcpResponse({
ok: true,
message: `Found ${allErrors.length} error(s), showing last ${recent.length}:\n\n${recent.join("\n")}`,
data: { errors: recent, total: allErrors.length, sources: { hookErrors: hookErrors.length, debugErrors: debugErrors.length } },
});
}
async function handleGetReviewQueue(ctx, { project }) {
const { phrenPath, profile } = ctx;
if (project && !isValidProjectName(project)) {
return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
}
if (project) {
const result = readReviewQueue(phrenPath, project);
if (!result.ok) {
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
}
const items = result.data.map((item) => ({ ...item, project }));
return mcpResponse({
ok: true,
message: `${items.length} queue item(s) for "${project}".`,
data: { items },
});
}
const result = readReviewQueueAcrossProjects(phrenPath, profile);
if (!result.ok) {
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
}
return mcpResponse({
ok: true,
message: `${result.data.length} queue item(s) across all projects.`,
data: { items: result.data },
});
}
async function handleManageReviewItem(ctx, { project, line, action, new_text }) {
const { phrenPath, withWriteQueue } = ctx;
if (!isValidProjectName(project)) {
return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
}
if (action === "edit" && !new_text) {
return mcpResponse({ ok: false, error: "new_text is required when action is 'edit'." });
}
return withWriteQueue(async () => {
let result;
switch (action) {
case "approve":
result = approveQueueItem(phrenPath, project, line);
break;
case "reject":
result = rejectQueueItem(phrenPath, project, line);
break;
case "edit":
result = editQueueItem(phrenPath, project, line, new_text);
break;
}
if (!result.ok) {
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
}
return mcpResponse({ ok: true, message: result.data });
});
}
// ── Registration ─────────────────────────────────────────────────────────────
export function register(server, ctx) {
server.registerTool("add_project", {
title: "◆ phren · add project",
description: "Bootstrap a project into phren from a repo or working directory. " +
"Copies or creates AGENTS.md/summary/tasks/findings under ~/.phren/<project> and adds the project to the active profile.",
inputSchema: z.object({
path: z.string().describe("Project path to import. Pass the current repo path explicitly."),
profile: z.string().optional().describe("Profile to update. Defaults to the active profile."),
ownership: z.enum(PROJECT_OWNERSHIP_MODES).optional()
.describe("How Phren should treat repo-facing instruction files: phren-managed, detached, or repo-managed."),
store: z.string().optional()
.describe("Target store name (one attached on this machine). If omitted, auto-routes to the store that claims this project, or falls back to the primary store."),
}),
}, (params) => handleAddProject(ctx, params));
server.registerTool("health_check", {
title: "◆ phren · health",
description: "Return phren health status: version, FTS index status, hook registration, profile/machine info, and consolidation status for all projects.",
inputSchema: z.object({
include_consolidation: z.boolean().optional()
.describe("Include consolidation status for all projects (default true)."),
}),
}, (params) => handleHealthCheck(ctx, params));
server.registerTool("doctor_fix", {
title: "◆ phren · doctor fix",
description: "Run phren doctor with --fix: re-links hooks, symlinks, context, and memory pointers. " +
"Returns the list of checks and repair actions taken.",
inputSchema: z.object({
check_data: z.boolean().optional()
.describe("Also validate data files (tasks, findings, governance). Default false."),
}),
}, (params) => handleDoctorFix(ctx, params));
server.registerTool("list_hook_errors", {
title: "◆ phren · hook errors",
description: "List recent error entries from phren hook-errors.log and debug.log. " +
"Useful for diagnosing hook or index failures.",
inputSchema: z.object({
limit: z.number().int().min(1).max(200).optional()
.describe("Max error entries to return (default 20)."),
}),
}, (params) => handleListHookErrors(ctx, params));
server.registerTool("get_review_queue", {
title: "◆ phren · get review queue",
description: "List all items in a project's review queue (review.md), or across all projects when omitted. " +
"Returns items with their id, section (Review/Stale/Conflicts), date, text, confidence, and risky flag.",
inputSchema: z.object({
project: z.string().optional().describe("Project name. Omit to read the review queue across all projects in the active profile."),
}),
}, (params) => handleGetReviewQueue(ctx, params));
server.registerTool("manage_review_item", {
title: "◆ phren · manage review item",
description: "Manage a review queue item: approve (PROMOTES the queued line into FINDINGS.md — if the finding is already present or archived it just dequeues), reject (removes from queue AND FINDINGS.md), or edit (updates text in both).",
inputSchema: z.object({
project: z.string().describe("Project name."),
line: z.string().max(10000).describe("The raw queue line text (as returned by get_review_queue)."),
action: z.enum(["approve", "reject", "edit"]).describe("Action to perform on the queue item."),
new_text: z.string().max(10000).optional().describe("Required when action is 'edit'."),
}),
}, (params) => handleManageReviewItem(ctx, params));
}