UNPKG

@phren/cli

Version:

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

1,491 lines • 72.7 kB
import { taskView } from "../data/task-contract.js";
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { moduleSnapshot } from "../modules/runtime.js";
import { disabledHint } from "../modules/registry.js";
import * as http from "http";
import * as crypto from "crypto";
import { timingSafeEqual } from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as querystring from "querystring";
import { spawn, execFileSync } from "child_process";
import { computePhrenLiveStateToken, getProjectDirs, } from "../shared.js";
import { getNonPrimaryStores, subscribeStoreProjects, unsubscribeStoreProjects } from "../store-registry.js";
import { editFinding, editQueueItem, approveQueueItem, readReviewQueue, rejectQueueItem, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, updateTask as updateTaskStore, TASKS_FILENAME, FINDINGS_FILENAME, } from "../data/access.js";
import { entryScoreKey } from "../governance/scores.js";
import { isValidProjectName, errorMessage, safeProjectPath } from "../utils.js";
import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "../init/preferences.js";
import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedHookConfigPath, isAllowedSkillPath, readSyncSnapshot, recentAccepted, recentLookups, recentUsage, } from "./data.js";
import { lookupEventsLogFile } from "../shared.js";
import { LookupTail } from "../shared/lookup-tail.js";
import { CONSOLIDATION_ENTRY_THRESHOLD } from "../content/validate.js";
import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "../project-topics.js";
import { getWorkflowPolicy, updateWorkflowPolicy, updateRetentionPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, getIndexPolicy, updateIndexPolicy, VALID_TASK_MODES } from "../governance/policy.js";
import { listProfiles, setMachineProfile, getDefaultMachineAlias } from "../profile-store.js";
import { setAccessRoles, ACCESS_ROLE_KEYS, permissionDeniedError } from "../governance/rbac.js";
import { readProjectConfig, updateProjectConfigOverrides } from "../project-config.js";
import { buildConfigView } from "../config/resolve.js";
import { CONFIG_DOMAINS } from "../config/schema.js";
import { findSkill } from "../skill/registry.js";
import { setSkillEnabledAndSync } from "../skill/files.js";
import { repairPreexistingInstall } from "../init/setup.js";
import { logger } from "../logger.js";
import { addNote, editNote, listNotes, removeNote } from "../data/notes.js";
import { promoteNote } from "../core/note.js";
import { FINDING_TYPES } from "../shared.js";
import { storeCommitMessage } from "../machine-identity.js";
const CSRF_TOKEN_TTL_MS = 15 * 60 * 1000;
const MAX_FORM_BODY_BYTES = 1_048_576;
const WEB_UI_READY_ATTEMPTS = 12;
const WEB_UI_READY_DELAY_MS = 75;
const WEB_UI_PORT_RETRY_ATTEMPTS = 3;
export function getWebUiBrowserCommand(url, platform = process.platform) {
    if (platform === "darwin")
        return { command: "open", args: [url] };
    if (platform === "win32")
        return { command: process.env.ComSpec || "cmd.exe", args: ["/c", "start", "", url] };
    return { command: "xdg-open", args: [url] };
}
async function launchWebUiBrowser(url) {
    const { command, args } = getWebUiBrowserCommand(url);
    await new Promise((resolve, reject) => {
        try {
            const child = spawn(command, args, { detached: true, stdio: "ignore" });
            child.once("error", reject);
            child.once("spawn", () => {
                child.removeListener("error", reject);
                child.unref();
                resolve();
            });
        }
        catch (err) {
            reject(err);
        }
    });
}
export async function waitForWebUiReady(url, attempts = WEB_UI_READY_ATTEMPTS, delayMs = WEB_UI_READY_DELAY_MS) {
    for (let attempt = 0; attempt < attempts; attempt++) {
        const ready = await new Promise((resolve) => {
            const req = http.get(url, (res) => {
                res.resume();
                resolve(true);
            });
            req.setTimeout(1000, () => {
                req.destroy();
                resolve(false);
            });
            req.on("error", () => resolve(false));
        });
        if (ready)
            return true;
        if (attempt < attempts - 1) {
            await new Promise((resolve) => setTimeout(resolve, delayMs));
        }
    }
    return false;
}
function isAddressInUse(err) {
    return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
}
async function listenOnLoopback(server, port) {
    return await new Promise((resolve, reject) => {
        const onError = (err) => {
            server.off("listening", onListening);
            reject(err);
        };
        const onListening = () => {
            server.off("error", onError);
            const address = server.address();
            if (!address || typeof address === "string") {
                reject(new Error("failed to determine web-ui port"));
                return;
            }
            resolve(address.port);
        };
        server.once("error", onError);
        server.once("listening", onListening);
        server.listen(port, "127.0.0.1");
    });
}
async function bindWebUiPort(server, requestedPort, allowPortFallback) {
    const candidates = [requestedPort];
    if (allowPortFallback && requestedPort > 0) {
        for (let i = 1; i <= WEB_UI_PORT_RETRY_ATTEMPTS; i++) {
            candidates.push(requestedPort + i);
        }
    }
    let lastError = null;
    for (let i = 0; i < candidates.length; i++) {
        const candidate = candidates[i];
        try {
            if (candidate !== requestedPort) {
                logger.info("web-ui", `port ${candidate - 1} is busy, retrying on ${candidate}`);
            }
            return await listenOnLoopback(server, candidate);
        }
        catch (err) {
            lastError = err;
            if (!allowPortFallback || !isAddressInUse(err) || i === candidates.length - 1)
                break;
        }
    }
    throw lastError instanceof Error ? lastError : new Error("failed to bind web-ui server");
}
function pruneExpiredCsrfTokens(csrfTokens) {
    if (!csrfTokens)
        return;
    const now = Date.now();
    for (const [token, createdAt] of csrfTokens) {
        if (now - createdAt > CSRF_TOKEN_TTL_MS)
            csrfTokens.delete(token);
    }
}
function setCommonHeaders(res, nonce) {
    res.setHeader("Referrer-Policy", "no-referrer");
    if (nonce) {
        // Page responses: allow nonce-gated inline scripts but disallow inline event handlers
        res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src https://fonts.bunny.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'`);
    }
    else {
        // API responses: no inline scripts needed
        res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");
    }
    res.setHeader("X-Content-Type-Options", "nosniff");
    res.setHeader("X-Frame-Options", "DENY");
}
function getSubmittedAuthToken(req, url, parsedBody) {
    const authHeader = req.headers.authorization;
    if (typeof authHeader === "string") {
        const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
        if (bearerMatch)
            return bearerMatch[1];
    }
    const query = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
    const queryAuth = query._auth;
    if (typeof queryAuth === "string")
        return queryAuth;
    const bodyAuth = parsedBody?._auth;
    if (typeof bodyAuth === "string")
        return bodyAuth;
    return "";
}
function authTokensMatch(submitted, authToken) {
    if (!authToken || !submitted)
        return false;
    const submittedBuffer = Buffer.from(submitted);
    const authTokenBuffer = Buffer.from(authToken);
    if (submittedBuffer.length !== authTokenBuffer.length)
        return false;
    return timingSafeEqual(submittedBuffer, authTokenBuffer);
}
function rejectUnauthorized(res, json = false) {
    if (json) {
        res.writeHead(401, { "content-type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "Unauthorized" }));
        return;
    }
    res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
    res.end("Unauthorized");
}
function requireGetAuth(req, res, url, authToken, json = false) {
    if (!authToken)
        return true;
    const submitted = getSubmittedAuthToken(req, url);
    if (authTokensMatch(submitted, authToken))
        return true;
    rejectUnauthorized(res, json);
    return false;
}
function readFormBody(req, res) {
    const contentLength = parseInt(req.headers["content-length"] || "0", 10);
    if (contentLength > MAX_FORM_BODY_BYTES) {
        res.writeHead(413, { "content-type": "text/plain" });
        res.end("Request body too large");
        return Promise.resolve(null);
    }
    return new Promise((resolve) => {
        let body = "";
        let received = 0;
        req.on("data", (chunk) => {
            received += chunk.length;
            if (received > MAX_FORM_BODY_BYTES) {
                res.writeHead(413, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ ok: false, error: "Request body too large" }));
                req.destroy();
                resolve(null);
                return;
            }
            body += String(chunk);
        });
        req.on("end", () => resolve(querystring.parse(body)));
        req.on("error", () => resolve(null));
        req.on("close", () => {
            if (received > MAX_FORM_BODY_BYTES)
                resolve(null);
        });
    });
}
function requirePostAuth(req, res, url, parsed, authToken, json = false) {
    if (!authToken)
        return true;
    const submitted = getSubmittedAuthToken(req, url, parsed);
    if (authTokensMatch(submitted, authToken))
        return true;
    rejectUnauthorized(res, json);
    return false;
}
function requireCsrf(res, parsed, csrfTokens, json = false) {
    if (!csrfTokens)
        return true;
    pruneExpiredCsrfTokens(csrfTokens);
    const submitted = String(parsed._csrf || "");
    if (submitted && csrfTokens.delete(submitted))
        return true;
    if (json) {
        res.writeHead(403, { "content-type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "Invalid or missing CSRF token" }));
        return false;
    }
    res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
    res.end("Invalid or missing CSRF token");
    return false;
}
function readProjectQueue(phrenPath, profile) {
    const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
    const items = [];
    for (const project of projects) {
        const queueResult = readReviewQueue(phrenPath, project);
        const queueItems = queueResult.ok ? queueResult.data : [];
        for (const item of queueItems) {
            items.push({
                project,
                section: item.section,
                line: item.line,
                text: item.text,
                date: item.date,
                machine: item.machine || undefined,
                model: item.model || undefined,
            });
        }
    }
    return items;
}
function parseTopicsPayload(raw) {
    try {
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed))
            return null;
        return parsed.map((topic) => ({
            slug: String(topic?.slug || ""),
            label: String(topic?.label || ""),
            description: String(topic?.description || ""),
            keywords: Array.isArray(topic?.keywords) ? topic.keywords.map((keyword) => String(keyword)) : [],
        }));
    }
    catch {
        return null;
    }
}
function parseTopicPayload(raw) {
    try {
        const topic = JSON.parse(raw);
        if (!topic || typeof topic !== "object")
            return null;
        return {
            slug: String(topic.slug || ""),
            label: String(topic.label || ""),
            description: String(topic.description || ""),
            keywords: Array.isArray(topic.keywords)
                ? topic.keywords.map((keyword) => String(keyword))
                : [],
        };
    }
    catch {
        return null;
    }
}
function parseQs(url) {
    return url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
}
function jsonOk(res, data, status = 200) {
    res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
    res.end(JSON.stringify(data));
}
function jsonErr(res, error, status = 200) {
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify({ ok: false, error }));
}
function handleRequestError(res, err) {
    if (res.destroyed || res.writableEnded)
        return;
    if (res.headersSent) {
        res.destroy();
        return;
    }
    if (err instanceof URIError) {
        jsonErr(res, "Invalid URL encoding", 400);
    }
    else {
        logger.debug("web-ui", `request failed: ${errorMessage(err)}`);
        jsonErr(res, "Internal server error", 500);
    }
}
function withPostBody(req, res, url, ctx, handler) {
    void readFormBody(req, res).then((parsed) => {
        if (!parsed)
            return;
        if (!requirePostAuth(req, res, url, parsed, ctx.authToken, true))
            return;
        if (!requireCsrf(res, parsed, ctx.csrfTokens, true))
            return;
        handler(parsed);
    }).catch((err) => handleRequestError(res, err));
}
// ── GET handlers ──────────────────────────────────────────────────────────────
function handleGetHome(res, ctx) {
    const nonce = crypto.randomBytes(16).toString("base64");
    setCommonHeaders(res, nonce);
    const html = ctx.renderPage(ctx.phrenPath, ctx.authToken, nonce);
    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
    res.end(html);
}
/** Returns the store base path that contains the given project (primary or team store). */
function resolveProjectBasePath(phrenPath, project) {
    const primaryDir = path.join(phrenPath, project);
    if (fs.existsSync(primaryDir))
        return phrenPath;
    try {
        for (const store of getNonPrimaryStores(phrenPath)) {
            if (fs.existsSync(path.join(store.path, project)))
                return store.path;
        }
    }
    catch { /* fall through */ }
    return phrenPath;
}
function handleGetProjects(res, ctx) {
    jsonOk(res, collectProjectsForUI(ctx.phrenPath, ctx.profile));
}
function handleGetChangeToken(res, ctx) {
    jsonOk(res, { token: computePhrenLiveStateToken(ctx.phrenPath) });
}
function handleGetStores(res, ctx) {
    try {
        const stores = getNonPrimaryStores(ctx.phrenPath);
        const storeData = stores.map((store) => {
            const availableProjects = getProjectDirs(store.path)
                .map((dir) => path.basename(dir))
                .filter((p) => p !== "global");
            const subscribedProjects = store.projects || [];
            return {
                name: store.name,
                role: store.role,
                path: store.path,
                availableProjects,
                subscribedProjects,
            };
        });
        jsonOk(res, { ok: true, stores: storeData });
    }
    catch (err) {
        jsonErr(res, errorMessage(err), 500);
    }
}
function handleGetRuntimeHealth(res, ctx) {
    jsonOk(res, readSyncSnapshot(ctx.phrenPath));
}
function handleGetReviewQueue(res, ctx) {
    jsonOk(res, readProjectQueue(ctx.phrenPath, ctx.profile));
}
function handleGetReviewActivity(res, ctx) {
    jsonOk(res, { accepted: recentAccepted(ctx.phrenPath), usage: recentUsage(ctx.phrenPath) });
}
/** Initial snapshot of recent memory lookups (newest first) for the Activity feed. */
function handleGetLookups(res, ctx) {
    jsonOk(res, { ok: true, lookups: recentLookups(ctx.phrenPath, 40) });
}
/**
 * Server-Sent Events stream of memory lookups. The server tails
 * .runtime/lookup-events.jsonl and pushes each newly appended event to the
 * browser, so the Activity tab lights up in real time as phren lands on
 * memories. A short poll loop reads only appended bytes (robust across
 * platforms vs. fs.watch); delivery to the client is push-based via SSE.
 */
function handleLookupStream(req, res, ctx) {
    res.writeHead(200, {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no",
    });
    res.write("retry: 3000\n\n");
    // Only stream events appended after the client connects; the initial
    // snapshot comes from /api/lookups.
    const tail = new LookupTail(lookupEventsLogFile(ctx.phrenPath));
    const flushNew = () => {
        for (const line of tail.pollLines())
            res.write(`data: ${line}\n\n`);
    };
    const pollTimer = setInterval(flushNew, 1000);
    const heartbeat = setInterval(() => {
        try {
            res.write(": ping\n\n");
        }
        catch { /* connection gone */ }
    }, 25000);
    const cleanup = () => {
        clearInterval(pollTimer);
        clearInterval(heartbeat);
        try {
            res.end();
        }
        catch { /* already closed */ }
    };
    req.on("close", cleanup);
    req.on("error", cleanup);
}
function handleGetProjectContent(res, url, ctx) {
    const qs = parseQs(url);
    const project = String(qs.project || "");
    const file = String(qs.file || "");
    if (!project || !isValidProjectName(project) || !file)
        return jsonErr(res, "Invalid project or file", 400);
    const allowedFiles = [FINDINGS_FILENAME, TASKS_FILENAME, "AGENTS.md", "summary.md"];
    if (!allowedFiles.includes(file))
        return jsonErr(res, `File not allowed: ${file}`, 400);
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    const filePath = safeProjectPath(basePath, project, file);
    if (!filePath)
        return jsonErr(res, "Invalid project or file path", 400);
    if (!fs.existsSync(filePath))
        return jsonErr(res, `File not found: ${file}`);
    jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
}
function handleGetProjectTopics(res, url, ctx) {
    const project = String(parseQs(url).project || "");
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project", 400);
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    jsonOk(res, { ok: true, ...getProjectTopicsResponse(basePath, project) });
}
function handleGetProjectReferenceList(res, url, ctx) {
    const project = String(parseQs(url).project || "");
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project", 400);
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    jsonOk(res, { ok: true, ...listProjectReferenceDocs(basePath, project) });
}
function handleGetProjectReferenceContent(res, url, ctx) {
    const qs = parseQs(url);
    const project = String(qs.project || "");
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    const contentResult = readReferenceContent(basePath, project, String(qs.file || ""));
    res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
    res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
}
function handleGetSkills(res, ctx) {
    jsonOk(res, collectSkillsForUI(ctx.phrenPath, ctx.profile));
}
function handleGetSkillContent(res, url, ctx) {
    const filePath = String(parseQs(url).path || "");
    if (!filePath || (!isAllowedSkillPath(filePath, ctx.phrenPath) && !isAllowedHookConfigPath(filePath, ctx.phrenPath))) {
        return jsonErr(res, "Invalid path", 400);
    }
    if (!fs.existsSync(filePath))
        return jsonErr(res, "File not found");
    jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
}
function handleGetHooks(res, ctx) {
    jsonOk(res, getHooksData(ctx.phrenPath));
}
async function handleGetSearch(res, url, ctx) {
    const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
    const query = searchParams.get("q") || searchParams.get("query") || "";
    const searchProject = searchParams.get("project") || undefined;
    const searchType = searchParams.get("type") || undefined;
    const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
    if (!query.trim())
        return jsonErr(res, "Missing query parameter (q or query).");
    try {
        const { runSearch } = await import("../cli/search.js");
        const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, ctx.phrenPath, ctx.profile || "");
        const fileDates = {};
        for (const line of result.lines) {
            const srcMatch = line.match(/^\[([^\]]+)\]\s/);
            if (srcMatch) {
                const sourceKey = srcMatch[1];
                if (fileDates[sourceKey])
                    continue;
                const slashIdx = sourceKey.indexOf("/");
                if (slashIdx > 0) {
                    try {
                        const filePath = path.join(ctx.phrenPath, sourceKey.slice(0, slashIdx), sourceKey.slice(slashIdx + 1));
                        if (fs.existsSync(filePath))
                            fileDates[sourceKey] = fs.statSync(filePath).mtime.toISOString();
                    }
                    catch { /* skip */ }
                }
            }
        }
        jsonOk(res, { ok: true, query, results: result.lines, fileDates });
    }
    catch (err) {
        jsonErr(res, errorMessage(err));
    }
}
async function handleGetGraph(res, url, ctx) {
    const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
    jsonOk(res, await buildGraph(ctx.phrenPath, ctx.profile, graphParams.get("project") || undefined));
}
function handleGetScores(res, ctx) {
    let scores = {};
    try {
        const raw = fs.readFileSync(path.join(ctx.phrenPath, ".runtime", "memory-scores.json"), "utf-8");
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === "object")
            scores = parsed;
    }
    catch { /* file missing or unparseable */ }
    jsonOk(res, scores);
}
function handleGetTasks(res, ctx) {
    try {
        const docs = readTasksAcrossProjects(ctx.phrenPath, ctx.profile);
        const tasks = [];
        for (const doc of docs) {
            for (const section of ["Active", "Queue", "Done"]) {
                for (const item of doc.items[section]) {
                    tasks.push({
                        ...taskView(ctx.phrenPath, doc, item),
                        project: doc.project, section: item.section, line: item.line, priority: item.priority,
                        pinned: item.pinned, githubIssue: item.githubIssue, githubUrl: item.githubUrl,
                        context: item.context, checked: item.checked, sessionId: item.sessionId,
                    });
                }
            }
        }
        jsonOk(res, { ok: true, tasks });
    }
    catch (err) {
        jsonOk(res, { ok: false, error: errorMessage(err), tasks: [] });
    }
}
function handleGetSettings(res, url, ctx) {
    try {
        const prefs = readInstallPreferences(ctx.phrenPath);
        const workflowPolicy = getWorkflowPolicy(ctx.phrenPath);
        const retentionPolicy = getRetentionPolicy(ctx.phrenPath);
        const hooksData = getHooksData(ctx.phrenPath);
        const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
        const settingsProject = String(parseQs(url).project || "");
        const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(ctx.phrenPath, settingsProject) : null;
        const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(ctx.phrenPath, settingsProject) : null;
        let projectInfo = null;
        if (settingsProject && isValidProjectName(settingsProject)) {
            const projectDir = path.join(ctx.phrenPath, settingsProject);
            const configFile = path.join(projectDir, "phren.project.yaml");
            const projConfig = readProjectConfig(ctx.phrenPath, settingsProject);
            const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
            const taskPath = path.join(projectDir, "tasks.md");
            let findingCount = 0;
            if (fs.existsSync(findingsPath))
                findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
            let taskCount = 0;
            if (fs.existsSync(taskPath)) {
                const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
                if (queueMatch)
                    taskCount = (queueMatch[0].match(/^- /gm) || []).length;
            }
            projectInfo = {
                diskPath: projConfig.sourcePath || projectDir, ownership: projConfig.ownership || "default",
                configFile, configExists: fs.existsSync(configFile), hasFindings: fs.existsSync(findingsPath),
                hasTasks: fs.existsSync(taskPath), hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
                hasClaudeMd: fs.existsSync(path.join(projectDir, "AGENTS.md")), findingCount, taskCount,
            };
        }
        const indexPolicy = getIndexPolicy(ctx.phrenPath);
        const view = buildConfigView(ctx.phrenPath, settingsProject && isValidProjectName(settingsProject) ? settingsProject : undefined);
        jsonOk(res, {
            ok: true, proactivity: prefs.proactivity || "high", proactivityFindings,
            proactivityTask: prefs.proactivityTask || prefs.proactivity || "high", taskMode: workflowPolicy.taskMode,
            findingSensitivity: workflowPolicy.findingSensitivity || "balanced", autoCaptureEnabled: proactivityFindings !== "low",
            consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD, hooksEnabled: hooksData.globalEnabled,
            mcpEnabled: prefs.mcpEnabled !== false, hookTools: hooksData.tools,
            retentionPolicy, workflowPolicy, indexPolicy, merged, overrides, projectInfo, view,
        });
    }
    catch (err) {
        jsonErr(res, errorMessage(err));
    }
}
function handleGetConfig(res, url, ctx) {
    const project = String(parseQs(url).project || "");
    if (project && !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    try {
        const config = mergeConfig(ctx.phrenPath, project || undefined);
        const projects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
        const view = buildConfigView(ctx.phrenPath, project || undefined);
        jsonOk(res, { ok: true, config, projects, view });
    }
    catch (err) {
        jsonErr(res, errorMessage(err));
    }
}
/**
 * Resolved config view plus the shared schema — everything the settings UI
 * needs to render every domain with a consistent source chip, in one call.
 */
function handleGetConfigView(res, url, ctx) {
    const project = String(parseQs(url).project || "");
    if (project && !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    try {
        const view = buildConfigView(ctx.phrenPath, project || undefined);
        jsonOk(res, { ok: true, view, schema: CONFIG_DOMAINS });
    }
    catch (err) {
        jsonErr(res, errorMessage(err));
    }
}
function handleGetCsrfToken(res, ctx) {
    if (!ctx.csrfTokens)
        return jsonOk(res, { ok: true, token: null });
    pruneExpiredCsrfTokens(ctx.csrfTokens);
    const token = crypto.randomUUID();
    ctx.csrfTokens.set(token, Date.now());
    jsonOk(res, { ok: true, token });
}
function handleGetFindings(res, pathname, ctx) {
    const project = decodeURIComponent(pathname.slice("/api/findings/".length));
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    const result = readFindings(basePath, project);
    jsonOk(res, result.ok ? { ok: true, data: { project, findings: result.data } } : { ok: false, error: result.error });
}
function handleGetNotes(res, url, pathname, ctx) {
    const project = decodeURIComponent(pathname.slice("/api/notes/".length));
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    const date = String(parseQs(url).date || "") || undefined;
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    const result = listNotes(basePath, project, { date, limit: 500 });
    jsonOk(res, result.ok
        ? { ok: true, data: { project, notes: result.data } }
        : { ok: false, error: result.error, errorCode: result.code });
}
// ── POST handlers ─────────────────────────────────────────────────────────────
function handlePostSync(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const message = String(parsed.message || "update phren");
        try {
            const EXEC_TIMEOUT = 15_000;
            const runGit = (args) => execFileSync("git", args, { env: nonInteractiveGitEnv(), cwd: ctx.phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
            const status = runGit(["status", "--porcelain"]);
            if (!status)
                return jsonOk(res, { ok: true, message: "Nothing to sync — working tree clean." });
            // Stage each extension separately: `git add` exits 128 as soon as ONE
            // pathspec matches nothing, and a normal store has no tracked .yml,
            // .jsonl or .txt (.runtime/*.jsonl is gitignored, and gitignored files do
            // not satisfy a pathspec). One combined add therefore failed on every
            // non-clean store and the user saw a raw git fatal instead of a sync.
            for (const spec of ["*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]) {
                try {
                    execFileSync("git", ["add", "--", spec], {
                        env: nonInteractiveGitEnv(),
                        cwd: ctx.phrenPath,
                        encoding: "utf8",
                        timeout: EXEC_TIMEOUT,
                        // A non-matching pathspec is expected here; don't print git's fatal.
                        stdio: ["ignore", "ignore", "ignore"],
                    });
                }
                catch { /* no file with this extension */ }
            }
            const stagedFiles = runGit(["diff", "--cached", "--name-only"]);
            if (!stagedFiles)
                return jsonOk(res, { ok: true, message: "Nothing to sync — no matching files to commit." });
            runGit(["commit", "-m", storeCommitMessage(message), "--only", "--", ...stagedFiles.split("\n").filter(Boolean)]);
            let pushed = false;
            try {
                if (runGit(["remote"])) {
                    runGit(["push"]);
                    pushed = true;
                }
            }
            catch { /* no remote or push failed */ }
            const changedFiles = status.split("\n").filter(Boolean).length;
            jsonOk(res, { ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostApprove(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const line = String(parsed.line || "");
        if (!project || !isValidProjectName(project) || !line)
            return jsonErr(res, "Missing project or line");
        try {
            const result = approveQueueItem(ctx.phrenPath, project, line);
            jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostReject(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const line = String(parsed.line || "");
        if (!project || !isValidProjectName(project) || !line)
            return jsonErr(res, "Missing project or line");
        try {
            const result = rejectQueueItem(ctx.phrenPath, project, line);
            jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostEdit(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const line = String(parsed.line || "");
        const newText = String(parsed.new_text || "");
        if (!project || !isValidProjectName(project) || !line || !newText)
            return jsonErr(res, "Missing project, line, or new_text");
        try {
            const result = editQueueItem(ctx.phrenPath, project, line, newText);
            jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostSkillSave(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const filePath = String(parsed.path || "");
        const content = String(parsed.content || "");
        if (!filePath || (!isAllowedSkillPath(filePath, ctx.phrenPath) && !isAllowedHookConfigPath(filePath, ctx.phrenPath))) {
            return jsonErr(res, "Invalid path");
        }
        try {
            fs.mkdirSync(path.dirname(filePath), { recursive: true });
            const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
            fs.writeFileSync(tmpPath, content);
            fs.renameSync(tmpPath, filePath);
            jsonOk(res, { ok: true });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostSkillToggle(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const name = String(parsed.name || "");
        const enabled = String(parsed.enabled || "") === "true";
        if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project)))
            return jsonErr(res, "Invalid skill toggle request");
        const skill = findSkill(ctx.phrenPath, ctx.profile || "", project, name);
        if (!skill || "error" in skill)
            return jsonErr(res, skill && "error" in skill ? skill.error : "Skill not found");
        setSkillEnabledAndSync(ctx.phrenPath, skill.source, skill.name, enabled);
        jsonOk(res, { ok: true, enabled });
    });
}
function handlePostHookToggle(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const tool = String(parsed.tool || "").toLowerCase();
        if (!["claude", "copilot", "cursor", "codex"].includes(tool))
            return jsonErr(res, "Invalid tool");
        const prefs = readInstallPreferences(ctx.phrenPath);
        const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
        const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
        writeInstallPreferences(ctx.phrenPath, { hookTools: { ...toolPrefs, [tool]: !current } });
        jsonOk(res, { ok: true, enabled: !current });
    });
}
function handlePostTopicsSave(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        if (!project || !isValidProjectName(project))
            return jsonErr(res, "Invalid project", 400);
        const topics = parseTopicsPayload(String(parsed.topics || ""));
        if (!topics)
            return jsonErr(res, "Invalid topics payload", 400);
        const saved = writeProjectTopics(ctx.phrenPath, project, topics);
        if (!saved.ok)
            return jsonOk(res, saved);
        for (const topic of saved.topics) {
            const ensured = ensureTopicReferenceDoc(ctx.phrenPath, project, topic);
            if (!ensured.ok)
                return jsonErr(res, ensured.error);
        }
        jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
    });
}
function handlePostTopicsReclassify(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        if (!project || !isValidProjectName(project))
            return jsonErr(res, "Invalid project", 400);
        jsonOk(res, { ok: true, ...reclassifyLegacyTopicDocs(ctx.phrenPath, project) });
    });
}
function handlePostTopicsPin(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        if (!project || !isValidProjectName(project))
            return jsonErr(res, "Invalid project", 400);
        const topic = parseTopicPayload(String(parsed.topic || ""));
        if (!topic)
            return jsonErr(res, "Invalid topic payload", 400);
        const pinned = pinProjectTopicSuggestion(ctx.phrenPath, project, topic);
        if (!pinned.ok)
            return jsonOk(res, pinned);
        jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
    });
}
function handlePostTopicsUnpin(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        if (!project || !isValidProjectName(project))
            return jsonErr(res, "Invalid project", 400);
        const unpinned = unpinProjectTopicSuggestion(ctx.phrenPath, project, String(parsed.slug || ""));
        if (!unpinned.ok)
            return jsonOk(res, unpinned);
        jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
    });
}
function handlePostTaskAction(req, res, url, ctx, action) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const item = String(parsed.item || "");
        if (!project || !item || !isValidProjectName(project))
            return jsonErr(res, "Missing or invalid project/item", 400);
        if (action === "complete") {
            const result = completeTaskStore(ctx.phrenPath, project, item);
            jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
        }
        else if (action === "add") {
            const result = addTaskStore(ctx.phrenPath, project, item);
            jsonOk(res, { ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error });
        }
        else {
            const result = removeTaskStore(ctx.phrenPath, project, item);
            jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
        }
    });
}
function handlePostTaskUpdate(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "");
        const item = String(parsed.item || "");
        if (!project || !item || !isValidProjectName(project))
            return jsonErr(res, "Missing or invalid project/item", 400);
        const denied = permissionDeniedError(ctx.phrenPath, "update_task", project);
        if (denied)
            return jsonErr(res, denied, 403);
        const updates = {};
        if (parsed.responsibility !== undefined) {
            if (parsed.responsibility !== "human" && parsed.responsibility !== "agent")
                return jsonErr(res, "Responsibility must be human or agent", 400);
            updates.responsibility = parsed.responsibility;
        }
        if (parsed.dependencies !== undefined) {
            try {
                updates.dependencies = JSON.parse(String(parsed.dependencies));
            }
            catch {
                return jsonErr(res, "Dependencies must be a JSON array", 400);
            }
        }
        if (Object.prototype.hasOwnProperty.call(parsed, "text"))
            updates.text = String(parsed.text || "");
        if (Object.prototype.hasOwnProperty.call(parsed, "priority"))
            updates.priority = String(parsed.priority || "");
        if (Object.prototype.hasOwnProperty.call(parsed, "section"))
            updates.section = String(parsed.section || "");
        const result = updateTaskStore(ctx.phrenPath, project, item, updates);
        jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
    });
}
function handlePostSettingsFindingSensitivity(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const value = String(parsed.value || "");
        const valid = ["minimal", "conservative", "balanced", "aggressive"];
        if (!valid.includes(value))
            return jsonErr(res, `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}`);
        const result = updateWorkflowPolicy(ctx.phrenPath, { findingSensitivity: value });
        jsonOk(res, result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error });
    });
}
function handlePostSettingsTaskMode(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const value = String(parsed.value || "").trim().toLowerCase();
        const valid = VALID_TASK_MODES;
        if (!valid.includes(value))
            return jsonErr(res, `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}`);
        const result = updateWorkflowPolicy(ctx.phrenPath, { taskMode: value });
        jsonOk(res, result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error });
    });
}
function handlePostSettingsProactivity(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const value = String(parsed.value || "").trim().toLowerCase();
        const valid = ["high", "medium", "low"];
        if (!valid.includes(value))
            return jsonErr(res, `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}`);
        writeInstallPreferences(ctx.phrenPath, { proactivity: value });
        writeGovernanceInstallPreferences(ctx.phrenPath, { proactivity: value });
        jsonOk(res, { ok: true, proactivity: value });
    });
}
function handlePostSettingsAutoCapture(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const enabled = String(parsed.enabled || "").toLowerCase() === "true";
        const next = enabled ? "high" : "low";
        writeInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
        writeGovernanceInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
        jsonOk(res, { ok: true, autoCaptureEnabled: enabled, proactivityFindings: next });
    });
}
function handlePostSettingsIndexPolicy(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const patch = {};
        const toGlobList = (v) => (Array.isArray(v) ? v : String(v ?? "").split(","))
            .map((s) => String(s).trim())
            .filter(Boolean);
        if (Object.prototype.hasOwnProperty.call(parsed, "includeGlobs"))
            patch.includeGlobs = toGlobList(parsed.includeGlobs);
        if (Object.prototype.hasOwnProperty.call(parsed, "excludeGlobs"))
            patch.excludeGlobs = toGlobList(parsed.excludeGlobs);
        if (Object.prototype.hasOwnProperty.call(parsed, "includeHidden")) {
            patch.includeHidden = String(parsed.includeHidden).toLowerCase() === "true";
        }
        const result = updateIndexPolicy(ctx.phrenPath, patch);
        jsonOk(res, result.ok ? { ok: true, indexPolicy: result.data } : { ok: false, error: result.error });
    });
}
function handlePostSettingsAccess(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const project = String(parsed.project || "").trim();
        if (project && !isValidProjectName(project))
            return jsonErr(res, "Invalid project name", 400);
        const toList = (v) => (Array.isArray(v) ? v : String(v ?? "").split(","))
            .map((s) => String(s).trim())
            .filter(Boolean);
        const patch = {};
        for (const role of ACCESS_ROLE_KEYS) {
            if (Object.prototype.hasOwnProperty.call(parsed, role))
                patch[role] = toList(parsed[role]);
        }
        if (!Object.keys(patch).length)
            return jsonErr(res, "Provide at least one of: admins, contributors, readers");
        // Rewriting the ACL is admin-only — otherwise a denied actor could POST
        // itself into `admins` here and undo every other check on this server.
        const denied = permissionDeniedError(ctx.phrenPath, "manage_config", project || undefined);
        if (denied)
            return jsonErr(res, denied, 403);
        try {
            const written = setAccessRoles(ctx.phrenPath, patch, project || undefined);
            jsonOk(res, { ok: true, access: written });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
function handlePostSettingsMcpEnabled(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const enabled = String(parsed.enabled || "").toLowerCase() === "true";
        writeInstallPreferences(ctx.phrenPath, { mcpEnabled: enabled });
        jsonOk(res, { ok: true, mcpEnabled: enabled });
    });
}
function handleGetProfiles(res, ctx) {
    try {
        const profileResult = listProfiles(ctx.phrenPath);
        if (!profileResult.ok) {
            return jsonOk(res, { ok: false, error: profileResult.error, profiles: [] });
        }
        const profiles = profileResult.data || [];
        jsonOk(res, { ok: true, profiles, activeProfile: ctx.profile || undefined });
    }
    catch (err) {
        jsonOk(res, { ok: false, error: errorMessage(err), profiles: [] });
    }
}
function handlePostProfile(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const profileName = String(parsed.profile || "").trim();
        if (!profileName)
            return jsonErr(res, "Profile name required", 400);
        try {
            const machineAlias = getDefaultMachineAlias();
            const result = setMachineProfile(ctx.phrenPath, machineAlias, profileName);
            if (!result.ok)
                return jsonErr(res, result.error || "Failed to switch profile", 500);
            jsonOk(res, { ok: true, message: `Switched to profile: ${profileName}`, profile: profileName });
        }
        catch (err) {
            jsonErr(res, errorMessage(err), 500);
        }
    });
}
function handlePostStoreSubscribe(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const storeName = String(parsed.store || "");
        const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
        if (!storeName)
            return jsonErr(res, "Missing store name", 400);
        try {
            subscribeStoreProjects(ctx.phrenPath, storeName, projects);
            jsonOk(res, { ok: true });
        }
        catch (err) {
            jsonErr(res, errorMessage(err), 500);
        }
    });
}
function handlePostStoreUnsubscribe(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const storeName = String(parsed.store || "");
        const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
        if (!storeName)
            return jsonErr(res, "Missing store name", 400);
        try {
            unsubscribeStoreProjects(ctx.phrenPath, storeName, projects);
            jsonOk(res, { ok: true });
        }
        catch (err) {
            jsonErr(res, errorMessage(err), 500);
        }
    });
}
function handlePostSettingsProjectOverrides(req, res, url, ctx) {
    withPostBody(req, res, url, ctx, (parsed) => {
        const globalUpdate = String(parsed.globalUpdate || "") === "true";
        const project = String(parsed.project || "");
        const field = String(parsed.field || "");
        const value = String(parsed.value || "");
        const clearField = String(parsed.clear || "") === "true";
        const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
        const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
        // Handle global retention/workflow policy updates
        if (globalUpdate) {
            try {
                if (NUMERIC_RETENTION_FIELDS.includes(field)) {
                    let updateData = {};
                    if (!clearField) {
                        const num = parseFloat(value);
                        if (!Number.isFinite(num) || num < 0)
                            throw new Error("Invalid numeric value for " + field);
                        updateData[field] = num;
                    }
                    const result = updateRetentionPolicy(ctx.phrenPath, updateData);
                    if (!result.ok)
                        return jsonErr(res, result.error || "Failed to update retention policy", 500);
                    return jsonOk(res, { ok: true, retentionPolicy: result.data });
                }
                else if (field.startsWith("decay.")) {
                    const point = field.slice("decay.".length);
                    let updateData = {};
                    if (!clearField) {
                        const num = parseFloat(value);
                        if (!Number.isFinite(num) || num < 0 || num > 1)
                            throw new Error("Invalid value for " + field + " (must be 0-1)");
                        updateData = { decay: { [point]: num } };
                    }
                    const result = updateRetentionPolicy(ctx.phrenPath, updateData);
                    if (!result.ok)
                        return jsonErr(res, result.error || "Failed to update retention policy", 500);
                    return jsonOk(res, { ok: true, retentionPolicy: result.data });
                }
                else if (field === "riskySections") {
                    const sections = (clearField ? [] : value.split(",").map((s) => s.trim()).filter(Boolean));
                    const result = updateWorkflowPolicy(ctx.phrenPath, { riskySections: sections });
                    if (!result.ok)
                        return jsonErr(res, result.error || "Failed to update workflow policy", 500);
                    return jsonOk(res, { ok: true, workflowPolicy: result.data });
                }
                else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
                    let updateData = {};
                    if (!clearField) {
                        const num = parseFloat(value);
                        if (!Number.isFinite(num) || num < 0 || num > 1)
                            throw new Error("Invalid value for " + field + " (must be 0-1)");
                        updateData[field] = num;
                    }
                    const result = updateWorkflowPolicy(ctx.phrenPath, updateData);
                    if (!result.ok)
                        return jsonErr(res, result.error || "Failed to update workflow policy", 500);
                    return jsonOk(res, { ok: true, workflowPolicy: result.data });
                }
                else {
                    return jsonErr(res, "Unknown config field: " + field, 400);
                }
            }
            catch (err) {
                return jsonErr(res, errorMessage(err));
            }
        }
        if (!project || !isValidProjectName(project))
            return jsonErr(res, "Invalid project name", 400);
        const registeredProjects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
        const registrationWarning = registeredProjects.includes(project) ? undefined : `Project '${project}' is not registered in the active profile. Config was saved but it will have no effect until the project is added with 'phren add'.`;
        const VALID_FIELDS = {
            findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"],
            proactivity: ["high", "medium", "low"], proactivityFindings: ["high", "medium", "low"],
            proactivityTask: ["high", "medium", "low"], taskMode: ["off", "manual", "suggest", "auto"],
        };
        try {
            updateProjectConfigOverrides(ctx.phrenPath, project, (current) => {
                const next = { ...current };
                if (clearField) {
                    if (field in VALID_FIELDS)
                        delete next[field];
                    else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
                        if (next.retentionPolicy)
                            delete next.retentionPolicy[field];
                    }
                    else if (field.startsWith("decay.")) {
                        if (next.retentionPolicy?.decay)
                            delete next.retentionPolicy.decay[field.slice("decay.".length)];
                    }
                    else if (field === "riskySections") {
                        if (next.workflowPolicy)
                            delete next.workflowPolicy.riskySections;
                    }
                    else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
                        if (next.workflowPolicy)
                            delete next.workflowPolicy[field];
                    }
                    return next;
                }
                if (field in VALID_FIELDS) {
                    if (!VALID_FIELDS[field].includes(value))
                        throw new Error(`Invalid value "${value}" for ${field}`);
                    next[field] = value;
                }
                else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
                    const num = parseFloat(value);
                    if (!Number.isFinite(num) || num < 0)
                        throw new Error(`Invalid numeric value for ${field}`);
                    next.retentionPolicy = { ...next.retentionPolicy, [field]: num };
                }
                else if (field.startsWith("decay.")) {
                    const num = parseFloat(value);
                    if (!Number.isFinite(num) || num < 0 || num > 1)
                        throw new Error(`Invalid value for ${field} (must be 0-1)`);
                    next.retentionPolicy = { ...next.retentionPolicy, decay: { ...next.retentionPolicy?.decay, [field.slice("decay.".length)]: num } };
                }
                else if (field === "riskySections") {
                    const sections = value.split(",").map((s) => s.trim()).filter(Boolean);
                    next.workflowPolicy = { ...next.workflowPolicy, riskySections: sections };
                }
                else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
                    const num = parseFloat(value);
                    if (!Number.isFinite(num) || num < 0 || num > 1)
                        throw new Error(`Invalid value for ${field} (must be 0-1)`);
                    next.workflowPolicy = { ...next.workflowPolicy, [field]: num };
                }
                else {
                    throw new Error(`Unknown config field: ${field}`);
                }
                return next;
            });
            jsonOk(res, { ok: true, config: mergeConfig(ctx.phrenPath, project), ...(registrationWarning ? { warning: registrationWarning } : {}) });
        }
        catch (err) {
            jsonErr(res, errorMessage(err));
        }
    });
}
/**
 * Resolve a graph node's score_key back to the exact FINDINGS.md bullet text
 * (including its [tag] prefix). Score keys are minted per-bullet by the graph
 * builder, so this pins the precise entry even when display texts are
 * ambiguous (e.g. two findings differing only by tag). Returns null when the
 * key doesn't resolve — callers fall back to text matching.
 */
function findBulletTextByScoreKey(phrenPath, project, scoreKey) {
    try {
        const file = path.join(phrenPath, project, FINDINGS_FILENAME);
        if (!fs.existsSync(file))
            return null;
        for (const line of fs.readFileSync(file, "utf8").split("\n")) {
            const tag = line.match(/^-\s+\[([a-z_-]+)\]\s+(.+?)(?:\s*<!--.*-->)?$/);
            const plain = tag ? null : line.match(/^-\s+(.+?)(?:\s*<!--.*-->)?$/);
            const entry = tag ? `[${tag[1]}] ${tag[2].trim()}` : plain ? plain[1].trim() : null;
            if (!entry)
                continue;
            if (entryScoreKey(project, FINDINGS_FILENAME, entry) === scoreKey)
                return entry;
        }
    }
    catch {
        // unreadable file — fall back to text matching
    }
    return null;
}
function handleFindingsWrite(req, res, url, pathname, ctx) {
    const project = decodeURIComponent(pathname.slice("/api/findings/".length));
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    if (req.method === "POST") {
        withPostBody(req, res, url, ctx, (parsed) => {
            const text = String(parsed.text || "");
            if (!text)
                return jsonErr(res, "text is required");
            const result = addFindingStore(ctx.phrenPath, project, text);
            jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
        });
    }
    else if (req.method === "PUT") {
        withPostBody(req, res, url, ctx, (parsed) => {
            const oldText = String(parsed.old_text || "");
            const newText = String(parsed.new_text || "");
            if (!oldText || !newText)
                return jsonErr(res, "old_text and new_text are required");
            const scoreKey = String(parsed.score_key || "");
            const resolved = scoreKey ? findBulletTextByScoreKey(ctx.phrenPath, project, scoreKey) : null;
            const result = editFinding(ctx.phrenPath, project, resolved || oldText, newText);
            jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
        });
    }
    else {
        // DELETE
        withPostBody(req, res, url, ctx, (parsed) => {
            const text = String(parsed.text || "");
            if (!text)
                return jsonErr(res, "text is required");
            const scoreKey = String(parsed.score_key || "");
            const resolved = scoreKey ? findBulletTextByScoreKey(ctx.phrenPath, project, scoreKey) : null;
            const result = removeFinding(ctx.phrenPath, project, resolved || text);
            jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
        });
    }
}
function handleNotesWrite(req, res, url, pathname, ctx) {
    const project = decodeURIComponent(pathname.slice("/api/notes/".length));
    if (!project || !isValidProjectName(project))
        return jsonErr(res, "Invalid project name", 400);
    const basePath = resolveProjectBasePath(ctx.phrenPath, project);
    withPostBody(req, res, url, ctx, (parsed) => {
        const selector = String(parsed.note || "");
        if (req.method === "POST" && String(parsed.action || "") === "promote") {
            if (!selector)
                return jsonErr(res, "note is required", 400);
            const denied = permissionDeniedError(basePath, "promote_note", project);
            if (denied)
                return jsonErr(res, denied, 403);
            const findingTypeRaw = String(parsed.finding_type || "");
            if (findingTypeRaw && !FINDING_TYPES.includes(findingTypeRaw)) {
                return jsonErr(res, `Invalid finding type: ${findingTypeRaw}`, 400);
            }
            const result = promoteNote(basePath, project, selector, findingTypeRaw);
            return jsonOk(res, result.ok
                ? { ok: true, data: result.data, message: `Promoted ${result.data.note.id} to a finding.` }
                : { ok: false, error: result.error, errorCode: result.code });
        }
        if (req.method === "POST") {
            const text = String(parsed.text || "");
            if (!text)
                return jsonErr(res, "text is required", 400);
            const denied = permissionDeniedError(basePath, "add_note", project);
            if (denied)
                return jsonErr(res, denied, 403);
            const date = String(parsed.date || "") || undefined;
            const result = addNote(basePath, project, text, { date });
            return jsonOk(res, result.ok
                ? { ok: true, data: { note: result.data }, message: `Added ${result.data.id}.` }
                : { ok: false, error: result.error, errorCode: result.code });
        }
        if (!selector)
            return jsonErr(res, "note is required", 400);
        if (req.method === "PUT") {
            const text = String(parsed.text || "");
            if (!text)
                return jsonErr(res, "text is required", 400);
            const denied = permissionDeniedError(basePath, "edit_note", project);
            if (denied)
                return jsonErr(res, denied, 403);
            const result = editNote(basePath, project, selector, text);
            return jsonOk(res, result.ok
                ? { ok: true, data: { note: result.data }, message: `Updated ${result.data.id}.` }
                : { ok: false, error: result.error, errorCode: result.code });
        }
        const denied = permissionDeniedError(basePath, "remove_note", project);
        if (denied)
            return jsonErr(res, denied, 403);
        const result = removeNote(basePath, project, selector);
        return jsonOk(res, result.ok
            ? { ok: true, data: { note: result.data }, message: `Removed ${result.data.id}.` }
            : { ok: false, error: result.error, errorCode: result.code });
    });
}
// ── Main router ───────────────────────────────────────────────────────────────
export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
    try {
        repairPreexistingInstall(phrenPath);
    }
    catch (err) {
        logger.debug("web-ui", `web-ui repair: ${errorMessage(err)}`);
    }
    const modules = moduleSnapshot(phrenPath, profile);
    const ctx = {
        phrenPath, profile, authToken: opts?.authToken, csrfTokens: opts?.csrfTokens, renderPage,
    };
    const handleRequest = async (req, res) => {
        const url = req.url || "/";
        const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
        // Home page
        if (req.method === "GET" && pathname === "/") {
            if (!requireGetAuth(req, res, url, ctx.authToken))
                return;
            pruneExpiredCsrfTokens(ctx.csrfTokens);
            if (ctx.csrfTokens)
                ctx.csrfTokens.set(crypto.randomUUID(), Date.now());
            return handleGetHome(res, ctx);
        }
        setCommonHeaders(res);
        // Auth gate for all GET /api/* routes
        if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, ctx.authToken))
            return;
        if (!modules.has("tasks") && (pathname === "/api/tasks" || pathname.startsWith("/api/tasks/") || pathname === "/api/settings/task-mode")) {
            return jsonErr(res, disabledHint("tasks"), 404);
        }
        // ── GET routes ──
        if (req.method === "GET") {
            switch (pathname) {
                case "/api/projects": return handleGetProjects(res, ctx);
                case "/api/change-token": return handleGetChangeToken(res, ctx);
                case "/api/stores": return handleGetStores(res, ctx);
                case "/api/runtime-health": return handleGetRuntimeHealth(res, ctx);
                case "/api/review-queue": return handleGetReviewQueue(res, ctx);
                case "/api/review-activity": return handleGetReviewActivity(res, ctx);
                case "/api/lookups": return handleGetLookups(res, ctx);
                case "/api/lookups/stream": return handleLookupStream(req, res, ctx);
                case "/api/project-topics": return handleGetProjectTopics(res, url, ctx);
                case "/api/project-reference-list": return handleGetProjectReferenceList(res, url, ctx);
                case "/api/project-reference-content": return handleGetProjectReferenceContent(res, url, ctx);
                case "/api/skills": return handleGetSkills(res, ctx);
                case "/api/hooks": return handleGetHooks(res, ctx);
                case "/api/scores": return handleGetScores(res, ctx);
                case "/api/tasks": return handleGetTasks(res, ctx);
                case "/api/settings": return handleGetSettings(res, url, ctx);
                case "/api/config": return handleGetConfig(res, url, ctx);
                case "/api/config/view": return handleGetConfigView(res, url, ctx);
                case "/api/csrf-token": return handleGetCsrfToken(res, ctx);
                case "/api/profiles": return handleGetProfiles(res, ctx);
                case "/api/search": return await handleGetSearch(res, url, ctx);
            }
            // Prefix-matched GET routes
            if (pathname.startsWith("/api/project-content"))
                return handleGetProjectContent(res, url, ctx);
            if (pathname.startsWith("/api/skill-content"))
                return handleGetSkillContent(res, url, ctx);
            if (pathname.startsWith("/api/graph"))
                return await handleGetGraph(res, url, ctx);
            if (pathname.startsWith("/api/findings/"))
                return handleGetFindings(res, pathname, ctx);
            if (pathname.startsWith("/api/notes/"))
                return handleGetNotes(res, url, pathname, ctx);
        }
        // ── POST/PUT/DELETE routes ──
        if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
            switch (pathname) {
                case "/api/sync": return handlePostSync(req, res, url, ctx);
                case "/api/approve": return handlePostApprove(req, res, url, ctx);
                case "/api/reject": return handlePostReject(req, res, url, ctx);
                case "/api/edit": return handlePostEdit(req, res, url, ctx);
                case "/api/skill-save": return handlePostSkillSave(req, res, url, ctx);
                case "/api/skill-toggle": return handlePostSkillToggle(req, res, url, ctx);
                case "/api/hook-toggle": return handlePostHookToggle(req, res, url, ctx);
                case "/api/profile": return handlePostProfile(req, res, url, ctx);
                case "/api/stores/subscribe": return handlePostStoreSubscribe(req, res, url, ctx);
                case "/api/stores/unsubscribe": return handlePostStoreUnsubscribe(req, res, url, ctx);
                case "/api/project-topics/save": return handlePostTopicsSave(req, res, url, ctx);
                case "/api/project-topics/reclassify": return handlePostTopicsReclassify(req, res, url, ctx);
                case "/api/project-topics/pin": return handlePostTopicsPin(req, res, url, ctx);
                case "/api/project-topics/unpin": return handlePostTopicsUnpin(req, res, url, ctx);
                case "/api/tasks/complete": return handlePostTaskAction(req, res, url, ctx, "complete");
                case "/api/tasks/add": return handlePostTaskAction(req, res, url, ctx, "add");
                case "/api/tasks/remove": return handlePostTaskAction(req, res, url, ctx, "remove");
                case "/api/tasks/update": return handlePostTaskUpdate(req, res, url, ctx);
                case "/api/settings/finding-sensitivity": return handlePostSettingsFindingSensitivity(req, res, url, ctx);
                case "/api/settings/task-mode": return handlePostSettingsTaskMode(req, res, url, ctx);
                case "/api/settings/proactivity": return handlePostSettingsProactivity(req, res, url, ctx);
                case "/api/settings/auto-capture": return handlePostSettingsAutoCapture(req, res, url, ctx);
                case "/api/settings/mcp-enabled": return handlePostSettingsMcpEnabled(req, res, url, ctx);
                case "/api/settings/index-policy": return handlePostSettingsIndexPolicy(req, res, url, ctx);
                case "/api/settings/access": return handlePostSettingsAccess(req, res, url, ctx);
                case "/api/settings/project-overrides": return handlePostSettingsProjectOverrides(req, res, url, ctx);
            }
            // Prefix-matched write routes
            if (pathname.startsWith("/api/findings/"))
                return handleFindingsWrite(req, res, url, pathname, ctx);
            if (pathname.startsWith("/api/notes/"))
                return handleNotesWrite(req, res, url, pathname, ctx);
        }
        res.writeHead(404, { "content-type": "text/plain" });
        res.end("Not found");
    };
    return http.createServer((req, res) => {
        // Node does not handle rejected promises from request listeners. Contain
        // malformed URI components and unexpected route failures to this request.
        void handleRequest(req, res).catch((err) => handleRequestError(res, err));
    });
}
export async function startWebUiServer(phrenPath, port, renderPage, profile, opts = {}) {
    const authToken = crypto.randomUUID();
    const csrfTokens = new Map();
    const server = createWebUiHttpServer(phrenPath, renderPage, profile, { authToken, csrfTokens });
    const boundPort = await bindWebUiPort(server, port, Boolean(opts.allowPortFallback && port !== 0));
    const publicUrl = `http://127.0.0.1:${boundPort}`;
    const reviewUrl = `${publicUrl}/?_auth=${encodeURIComponent(authToken)}`;
    const ready = await waitForWebUiReady(reviewUrl);
    process.stdout.write(`phren web-ui running at ${publicUrl}\n`);
    process.stderr.write(`open: ${reviewUrl}\n`);
    if (!ready) {
        logger.warn("web-ui", "health check did not confirm readiness before launch");
    }
    const shouldAutoOpen = opts.autoOpen ?? Boolean(process.stdout.isTTY);
    if (shouldAutoOpen && ready) {
        try {
            if (opts.browserLauncher)
                await opts.browserLauncher(reviewUrl);
            else
                await launchWebUiBrowser(reviewUrl);
        }
        catch (err) {
            logger.warn("web-ui", `browser launch failed: ${errorMessage(err)}`);
            process.stdout.write(`secure session URL: ${reviewUrl}\n`);
        }
    }
    else if (shouldAutoOpen && !ready) {
        logger.warn("web-ui", "skipped auto-open because readiness check failed; use the secure URL below");
        process.stdout.write(`secure session URL: ${reviewUrl}\n`);
    }
    else {
        process.stdout.write(`secure session URL: ${reviewUrl}\n`);
    }
    await new Promise((resolve) => {
        const shutdown = () => {
            server.close(() => resolve());
        };
        process.on("SIGTERM", shutdown);
        process.on("SIGINT", shutdown);
    });
}